diff --git a/.changeset/recursive-schema-input-types.md b/.changeset/recursive-schema-input-types.md new file mode 100644 index 0000000000..496ae978f6 --- /dev/null +++ b/.changeset/recursive-schema-input-types.md @@ -0,0 +1,74 @@ +--- +"@objectstack/spec": minor +"@objectstack/platform-objects": patch +--- + +fix(spec): the remaining six recursive schemas name both type parameters, and the authoring artifacts stop spelling out defaults (#4195) + +#4221 fixed `NavigationItemSchema` — the worst instance, and the one with a +reproducible "`defineApp` compiles `navigation: [42, 'nonsense']`" demo. This +finishes the sweep: **six more schemas** had the same shape, and the authoring +artifacts that #4171 had to work around can now be typed honestly. + +`z.ZodType` takes `` and `Input` defaults to `unknown`, so naming +only the first parameter leaves `z.input` of anything embedding that schema at +`unknown`. Measured with a type probe: + +| | was | now | +|---|---|---| +| `QueryInput['joins']` | `unknown[]` | `JoinNodeInput[]` | +| `QueryInput['fields']` | `unknown[]` | `FieldNode[]` | +| `z.input` | `unknown` | `FormFieldInput` | +| `z.input` | `unknown` | `QueryInput` | +| `z.input` | `unknown` | `StateNodeConfig` | +| `z.input` | `unknown` | `BaseValidationRuleShape` | + +New exported types: `FormFieldInput`, `JoinNodeInput`, `NavigationContributionInput`. +`FilterCondition`, `NormalizedFilter` and `FieldNode` carry no `.default()` or +`.transform()`, so their input is their output and the second parameter is the +first. + +**The `z.ZodType` single-parameter form is now absent from the codebase.** + +## 26 hand-written defaults deleted + +This is the half #4221 left on the table. #4171 had to spell out +`expanded: false` (×16) and `target: '_self'` (×10) across `setup.app.ts`, +`studio.app.ts` and `setup-nav.contributions.ts`, because those artifacts are +annotated with the PARSED type where a `.default()`ed key is required — and +retyping them to the input surface would have traded eight loud errors for no +checking at all. + +With `NavigationItemInput` landed (#4221) and `NavigationContributionInput` +added here, they are annotated `AppInput` / `NavigationContributionInput`, the +defaults are defaults again, and the literals are checked for the first time. +Net across those four files: 21 lines added, 54 removed. + +Verified live, not nominal: a literal omitting `expanded`/`target` compiles, and +one writing `defaultOpen` — the non-spec key #4171 found in `account.app.ts` — +is a compile error whose suggestion list names `expanded`. + +## Two typed with a documented caveat + +`StateNodeSchema` and `ValidationRuleSchema` reuse their hand-written type for +both parameters: exact on the input side, loose on the output side. +`StateNodeConfig` marks `type` optional though `.default('atomic')` makes it +always present; `BaseValidationRuleShape` carries a `[key: string]: unknown` +index signature. Both were already that loose — input went from `unknown` (types +nothing) to a real type, output is untouched. Making them exact means deriving +those types from their schemas instead of maintaining them beside one, which is +separate work; the caveat is written at each declaration rather than left for a +reader to find. + +## Why there is still no CI gate for this + +Worth recording, since #4195 proposed one: extend `check:exported-any` to fail on +"output precise but input `unknown`". Measured after this change — exactly two +schemas match, `TranslationItemSchema` and `InlineActionSchema`, and **both are +correct**: they are `z.preprocess(...)`, where an `unknown` input is zod's +semantics rather than a missing annotation. Separating those from a genuinely +missing parameter needs heuristics on emitted type names, and per the rule in +that script's own header — zero false positives, so red keeps meaning broken — a +gate that cannot be made reliable is worse than none. #4221's +`app.nav-type-assertions.ts` is the better pattern where it applies: pin the +contract at compile level rather than infer intent from shape. diff --git a/packages/platform-objects/src/apps/account.app.ts b/packages/platform-objects/src/apps/account.app.ts index 408976f539..6edf6277c3 100644 --- a/packages/platform-objects/src/apps/account.app.ts +++ b/packages/platform-objects/src/apps/account.app.ts @@ -27,9 +27,9 @@ * self-service view, platform admins get the browsable tables. */ -import type { App } from '@objectstack/spec/ui'; +import type { AppInput } from '@objectstack/spec/ui'; -export const ACCOUNT_APP: App = { +export const ACCOUNT_APP: AppInput = { name: 'account', label: 'Account', description: 'Personal security and identity settings', @@ -55,12 +55,9 @@ export const ACCOUNT_APP: App = { // manage their own linked accounts / personal OAuth apps. RLS on each // object scopes rows to the caller. // - // Group open-state is `expanded` — the spec key. These entries said - // `defaultOpen`, objectui's legacy alias, which the spec has never declared - // and `.strict()` would reject; it worked only because objectui's - // NavigationRenderer still falls back to it, and because `NavigationItem` - // resolved to `any` so nothing checked the key here (#4171). Per Prime - // Directive #12 the producer is the place to fix that, not the renderer. + // Group open-state is `expanded` — the spec key. These said `defaultOpen`, + // objectui's legacy alias the spec never declared; #4171 fixed that at the + // producer per Prime Directive #12. navigation: [ // Profile is the canonical landing — a hand-written React settings card // (Vercel/Linear style) registered in the Console SPA as diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 7d7b3b50d8..5871922c2b 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -22,19 +22,14 @@ * contributions in the same group (mirrors object owner priority). */ -import type { NavigationContribution } from '@objectstack/spec/ui'; +import type { NavigationContributionInput } from '@objectstack/spec/ui'; const BASE_PRIORITY = 100; -// `target: '_self'` on every `url` entry is the schema's own default, spelled -// out: this array is annotated with the PARSED type, where a `.default()`ed key -// is required. Omitting it was only possible while `NavigationItem` resolved to -// `any` and nothing checked these literals at all (#4171). - // Marketplace entries (browse / installed) moved to // @objectstack/cloud-connection's marketplace plugins (cloud ADR-0009: // the nav lives and dies with the capability — no plugin, no entry). -export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ +export const SETUP_NAV_CONTRIBUTIONS: NavigationContributionInput[] = [ { app: 'setup', group: 'group_overview', @@ -102,7 +97,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ group: 'group_configuration', priority: BASE_PRIORITY, items: [ - { id: 'nav_settings_hub', type: 'url', target: '_self', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_hub', type: 'url', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] }, // Workspace identity first — Localization (order 2) and Company (order 3) // are the lowest-`order` settings manifests and the first thing a new // company admin configures. They ship as `service-settings` manifests @@ -111,15 +106,15 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ // admin consoles (Salesforce "Company Information", ServiceNow) surface // both directly. No `requiredPermissions` — matches Branding (read perm is // the app's base `setup.access`). - { id: 'nav_settings_localization', type: 'url', target: '_self', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' }, - { id: 'nav_settings_company', type: 'url', target: '_self', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' }, - { id: 'nav_settings_branding', type: 'url', target: '_self', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' }, - { id: 'nav_settings_auth', type: 'url', target: '_self', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_mail', type: 'url', target: '_self', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_storage', type: 'url', target: '_self', label: 'File Storage', url: '/apps/setup/system/settings/storage', icon: 'hard-drive', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_ai', type: 'url', target: '_self', label: 'AI & Embedder', url: '/apps/setup/system/settings/ai', icon: 'sparkles', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_knowledge', type: 'url', target: '_self', label: 'Knowledge', url: '/apps/setup/system/settings/knowledge', icon: 'book-open', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_feature_flags', type: 'url', target: '_self', label: 'Feature Flags', url: '/apps/setup/system/settings/feature_flags', icon: 'flag' }, + { id: 'nav_settings_localization', type: 'url', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' }, + { id: 'nav_settings_company', type: 'url', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' }, + { id: 'nav_settings_branding', type: 'url', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' }, + { id: 'nav_settings_auth', type: 'url', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_mail', type: 'url', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_storage', type: 'url', label: 'File Storage', url: '/apps/setup/system/settings/storage', icon: 'hard-drive', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_ai', type: 'url', label: 'AI & Embedder', url: '/apps/setup/system/settings/ai', icon: 'sparkles', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_knowledge', type: 'url', label: 'Knowledge', url: '/apps/setup/system/settings/knowledge', icon: 'book-open', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_feature_flags', type: 'url', label: 'Feature Flags', url: '/apps/setup/system/settings/feature_flags', icon: 'flag' }, ], }, { diff --git a/packages/platform-objects/src/apps/setup.app.ts b/packages/platform-objects/src/apps/setup.app.ts index 6de9acdeb0..4e44a7f64f 100644 --- a/packages/platform-objects/src/apps/setup.app.ts +++ b/packages/platform-objects/src/apps/setup.app.ts @@ -25,9 +25,9 @@ * matching the convention used by the HotCRM reference app. */ -import type { App } from '@objectstack/spec/ui'; +import type { AppInput } from '@objectstack/spec/ui'; -export const SETUP_APP: App = { +export const SETUP_APP: AppInput = { name: 'setup', label: 'Setup', description: 'Platform settings and administration', @@ -47,18 +47,12 @@ export const SETUP_APP: App = { requiredPermissions: ['setup.access'], // Shell only — the stable group anchors. Children are supplied by // `navigationContributions` from the packages that own the objects. - // - // `expanded: false` is the schema's own default, spelled out: this literal is - // annotated with the PARSED `App` type, where a `.default()`ed key is - // required. Omitting it was only possible while `NavigationItem` resolved to - // `any` and nothing checked these entries at all (#4171). navigation: [ { id: 'group_overview', type: 'group', label: 'Overview', icon: 'layout-dashboard', - expanded: false, requiredPermissions: ['manage_platform_settings'], children: [], }, @@ -67,7 +61,6 @@ export const SETUP_APP: App = { type: 'group', label: 'Apps', icon: 'package', - expanded: false, children: [], }, { @@ -75,7 +68,6 @@ export const SETUP_APP: App = { type: 'group', label: 'People & Organization', icon: 'users', - expanded: false, children: [], }, { @@ -83,7 +75,6 @@ export const SETUP_APP: App = { type: 'group', label: 'Access Control', icon: 'shield', - expanded: false, children: [], }, { @@ -91,7 +82,6 @@ export const SETUP_APP: App = { type: 'group', label: 'Approvals', icon: 'check-circle', - expanded: false, requiredPermissions: ['manage_platform_settings'], children: [], }, @@ -100,7 +90,6 @@ export const SETUP_APP: App = { type: 'group', label: 'Configuration', icon: 'sliders-horizontal', - expanded: false, children: [], }, { @@ -108,7 +97,6 @@ export const SETUP_APP: App = { type: 'group', label: 'Diagnostics', icon: 'stethoscope', - expanded: false, requiredPermissions: ['manage_platform_settings'], children: [], }, @@ -117,7 +105,6 @@ export const SETUP_APP: App = { type: 'group', label: 'Integrations', icon: 'plug', - expanded: false, requiredPermissions: ['manage_platform_settings'], children: [], }, diff --git a/packages/platform-objects/src/apps/studio.app.ts b/packages/platform-objects/src/apps/studio.app.ts index 1743fddfc1..08bfd8fc32 100644 --- a/packages/platform-objects/src/apps/studio.app.ts +++ b/packages/platform-objects/src/apps/studio.app.ts @@ -25,9 +25,9 @@ * the box whenever the auth/security trio is loaded. */ -import type { App } from '@objectstack/spec/ui'; +import type { AppInput } from '@objectstack/spec/ui'; -export const STUDIO_APP: App = { +export const STUDIO_APP: AppInput = { name: 'studio', label: 'Studio', description: 'Metadata workbench for developers, analysts, and implementers', @@ -77,17 +77,12 @@ export const STUDIO_APP: App = { placement: 'sidebar_header', }, ], - // `expanded: false` on each group is the schema's own default, spelled out: - // this literal is annotated with the PARSED `App` type, where a `.default()`ed - // key is required. Omitting it was only possible while `NavigationItem` - // resolved to `any` and nothing checked these entries at all (#4171). navigation: [ { id: 'group_overview', type: 'group', label: 'Overview', icon: 'layout-dashboard', - expanded: false, children: [ { // The application builder's front door (ADR-0080/0084): pick or @@ -126,7 +121,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'Data Model', icon: 'database', - expanded: false, children: [ { id: 'nav_objects', @@ -152,7 +146,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'User Experience', icon: 'layout', - expanded: false, children: [ { id: 'nav_apps', @@ -211,7 +204,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'Logic', icon: 'function-square', - expanded: false, children: [ { id: 'nav_actions', @@ -237,7 +229,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'Automation', icon: 'workflow', - expanded: false, children: [ { id: 'nav_flows', @@ -262,7 +253,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'AI', icon: 'sparkles', - expanded: false, children: [ { id: 'nav_agents', @@ -299,7 +289,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'Developer', icon: 'terminal', - expanded: false, children: [ { id: 'nav_api_console', @@ -333,7 +322,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'Integration', icon: 'plug', - expanded: false, children: [ { id: 'nav_email_templates', diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index aa33f8a6ec..7a38a5480a 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -397,6 +397,7 @@ "JSONValidation (type)", "JSONValidationSchema (const)", "JoinNode (type)", + "JoinNodeInput (type)", "JoinNodeSchema (const)", "JoinStrategy (const)", "JoinType (const)", @@ -3290,6 +3291,7 @@ "FormButtonConfig (type)", "FormButtonConfigSchema (const)", "FormField (type)", + "FormFieldInput (type)", "FormFieldSchema (const)", "FormSection (type)", "FormSectionSchema (const)", @@ -3350,6 +3352,7 @@ "NavigationConfig (type)", "NavigationConfigSchema (const)", "NavigationContribution (type)", + "NavigationContributionInput (type)", "NavigationContributionSchema (const)", "NavigationItem (type)", "NavigationItemInput (type)", diff --git a/packages/spec/src/automation/state-machine.zod.ts b/packages/spec/src/automation/state-machine.zod.ts index 8ae59ac0d2..857305cdd7 100644 --- a/packages/spec/src/automation/state-machine.zod.ts +++ b/packages/spec/src/automation/state-machine.zod.ts @@ -79,8 +79,20 @@ export type StateNodeConfig = { /** * State Node Definition + * + * Both type arguments are given (#4195) so `z.input` is not `unknown` — a state + * machine is hand-authored, and an `unknown` input type means nothing checks + * what an author writes into `states`. + * + * Caveat worth knowing before trusting it: {@link StateNodeConfig} is written by + * hand in the AUTHORING shape (`type?` is optional), while `type` is + * `.default('atomic')` and so always present once parsed. Using it for both + * arguments is therefore exact on the input side and slightly loose on the + * output side — which is what this schema already claimed before, so nothing + * regressed. Making the output exact means re-deriving `StateNodeConfig` from + * the schema rather than maintaining it beside one; that is a separate change. */ -export const StateNodeSchema: z.ZodType = z.lazy(() => z.object({ +export const StateNodeSchema: z.ZodType = z.lazy(() => z.object({ /** Type of state */ type: z.enum(['atomic', 'compound', 'parallel', 'final', 'history']).default('atomic'), diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 2e021e7f59..9aa0a53cde 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -206,8 +206,15 @@ export type FilterCondition = { /** * Zod schema for recursive filter validation. * Uses z.lazy() to handle recursive structure. + * + * Annotated with BOTH type arguments (#4195). `z.ZodType` leaves `Input` + * at its `unknown` default, and that `unknown` propagates: every schema that + * embeds this one reports `unknown` for the corresponding key of its own + * `z.input`, so the authoring surface underneath goes unchecked. Nothing here + * carries a `.default()` or a `.transform()`, so input and output are the same + * type and the second argument is simply the first. */ -export const FilterConditionSchema: z.ZodType = z.lazy(() => +export const FilterConditionSchema: z.ZodType = z.lazy(() => z.record(z.string(), z.unknown()).and( z.object({ $and: z.array(FilterConditionSchema).optional(), @@ -331,8 +338,13 @@ export type NormalizedFilter = { * annotation. Annotating with `z.ZodType` (as this did before #4171) made * the exported `NormalizedFilter` resolve to `any`, so the adapters that walk * this AST were writing against a type that constrained nothing. + * + * Both type arguments are given (#4195): this AST is produced by the normalizer + * rather than authored, and carries no `.default()` or `.transform()`, so input + * and output are the same type. Leaving `Input` at its `unknown` default would + * make `z.input` of anything embedding it `unknown` for no reason. */ -export const NormalizedFilterSchema: z.ZodType = z.lazy(() => +export const NormalizedFilterSchema: z.ZodType = z.lazy(() => z.object({ $and: z.array( z.union([ diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index 75257e8309..0be048b7da 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -203,6 +203,19 @@ export type JoinNode = z.infer & { subquery?: QueryAST; }; +/** + * The authoring shape of a join — the INPUT half of {@link JoinNodeSchema} + * (#4195), the same relationship {@link QueryInput} has to {@link QueryAST}. + * + * Kept as its own type rather than reusing `JoinNode` because the recursive + * knot differs: a nested `subquery` is authored, so it is a `QueryInput`, not + * the parsed `QueryAST`. + */ +export type JoinNodeInput = z.input & { + /** Join against a derived dataset instead of a plain object/table. */ + subquery?: QueryInput; +}; + /** * Join Node * Represents table joins for combining data from multiple objects. @@ -281,7 +294,7 @@ export type JoinNode = z.infer & { * ] * } */ -export const JoinNodeSchema: z.ZodType = z.lazy(() => +export const JoinNodeSchema: z.ZodType = z.lazy(() => JoinNodeBaseSchema.extend({ subquery: z.lazy(() => QuerySchema).optional().describe('Subquery instead of object'), }) @@ -459,7 +472,7 @@ export type FieldNode = * Field Selection Node * Represents "Select" attributes, including joins. */ -export const FieldNodeSchema: z.ZodType = z.lazy(() => +export const FieldNodeSchema: z.ZodType = z.lazy(() => z.union([ z.string(), // Primitive field: "name" z.object({ @@ -620,7 +633,7 @@ export type QueryInput = z.input & { expand?: Record; }; -export const QuerySchema: z.ZodType = lazySchema(() => BaseQuerySchema.extend({ +export const QuerySchema: z.ZodType = lazySchema(() => BaseQuerySchema.extend({ expand: z.lazy(() => z.record(z.string(), QuerySchema)).optional().describe( 'Recursive relation loading map. Keys are lookup/master_detail field names; ' + 'values are nested QueryAST objects that control select (`fields`) and filter ' diff --git a/packages/spec/src/data/validation.zod.ts b/packages/spec/src/data/validation.zod.ts index c3fa424b58..683c200903 100644 --- a/packages/spec/src/data/validation.zod.ts +++ b/packages/spec/src/data/validation.zod.ts @@ -251,7 +251,19 @@ export interface BaseValidationRuleShape { [key: string]: unknown; } -export const ValidationRuleSchema: z.ZodType = z.lazy(() => +/** + * Both type arguments are given (#4195) so `z.input` is not `unknown` — + * validation rules are hand-authored, and an `unknown` input type means nothing + * checks what an author writes. + * + * Read {@link BaseValidationRuleShape} before trusting the result: it carries a + * `[key: string]: unknown` index signature, so it types the KNOWN keys and + * accepts any others. That is a real improvement over `unknown` (which types + * nothing) but it is not strictness — the discriminated union below is what + * actually rejects a malformed rule, at parse time. Removing the index + * signature is the #4075 family of work, not this change. + */ +export const ValidationRuleSchema: z.ZodType = z.lazy(() => z.discriminatedUnion('type', [ ScriptValidationSchema, StateMachineValidationSchema, diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index f36805d30d..dfcf580436 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -570,6 +570,12 @@ export const NavigationContributionSchema = lazySchema(() => z.object({ }), }).strict().describe('A navigation contribution: a package injecting nav items into an app it does not own (ADR-0029 D7)')); export type NavigationContribution = z.infer; +/** + * The authoring shape of a contribution (#4195) — `priority` is `.default(200)` + * and each item is a {@link NavigationItemInput}, so this is what a package + * declaring its menu entries actually writes. + */ +export type NavigationContributionInput = z.input; /** * App Branding Configuration diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 251086064b..b4a5efa348 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -913,6 +913,23 @@ export type FormField = Omit, 'visibleOn'> & fields?: FormField[]; }; +/** + * The authoring shape of a form field — the INPUT half of + * {@link FormFieldSchema} (#4195). + * + * Differs from {@link FormField} in both directions, which is exactly why it + * has to be declared: `span` is `.default('auto')` so it is optional here and + * required there, and the deprecated `visibleOn` is accepted here and stripped + * by the ADR-0089 D2 transform before it reaches the output. + * + * Without this, `z.input` was `unknown` — so a form + * section's `fields` were unchecked at every authoring site. + */ +export type FormFieldInput = z.input & { + /** Sub-fields for composite/repeater/record types. Recursive. */ + fields?: FormFieldInput[]; +}; + /** * Form Field Configuration Schema * Detailed configuration for individual form fields. @@ -930,7 +947,7 @@ export type FormField = Omit, 'visibleOn'> & * @example Custom widget override * { field: 'filter', widget: 'filter-builder' } */ -export const FormFieldSchema: z.ZodType = lazySchema(() => +export const FormFieldSchema: z.ZodType = lazySchema(() => FormFieldBaseSchema.extend({ fields: z.array(z.lazy(() => FormFieldSchema)).optional() .describe('Sub-fields for composite/repeater/record types'),