diff --git a/.changeset/20901-inline-grid-column-carriers.md b/.changeset/20901-inline-grid-column-carriers.md new file mode 100644 index 00000000000..10cfedb1c33 --- /dev/null +++ b/.changeset/20901-inline-grid-column-carriers.md @@ -0,0 +1,31 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: a form view's subform columns are the inline grid column contract, and a column that declares no `type` is judged as the type it renders (#20901) + +Clause-②: yes (narrowing) + + + +**BREAKING** — an accept-set narrowing on two published authoring surfaces, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The console's master-detail grid reads one column shape from two carriers: a relationship field's `inlineColumns` and a form view's `subforms[].columns`. Only the first was judged, and only by the type a column declares. + +**`@objectstack/spec`** + +- **`FormViewSchema.subforms[].columns`** now references `InlineGridColumnSchema`, the strict, name-keyed column a relationship field's `inlineColumns` already takes. It was `z.array(z.any())`, so every column published clean, including a key the grid never reads and a key the other carrier refuses. Every rule the column schema holds now applies on the form view too, with its own message: an unknown key is named; the retired `field` spelling (and `fieldName`, `key`) is refused with the prescription naming `name`; a column without `name` is refused; `scale` on a column declaring `type: 'currency'` is refused with the currency ruling's remedy. This reaches `view.form` and every `view.formViews` entry, wherever a view is parsed against the spec: `defineStack`, `objectstack validate`, and the `view` metadata type's registered schema (`ViewMetadataSchema`). +- **`defineStack`'s cross-reference check** now judges a column that declares no `type` as the type it renders. The console fills such a column's type from the child field, so an identity-only column over a `currency` field renders as a currency column. The check resolves the child field, re-parses the column with that type through `InlineGridColumnSchema`, and reports that schema's own refusal (`STACK_CROSS_REFERENCE_INVALID`, 422). Today that means `scale` on an identity-only column over a `currency` child field. Both carriers are walked: `inlineColumns` resolves against the object that owns the relationship field, and `subforms[].columns` against the subform's `childObject`. A child object the stack does not declare, or a column naming no field of it, is not judged. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `subforms: [{ childObject: 'invoice_line', columns: [{ field: 'quantity' }] }]` | `subforms: [{ childObject: 'invoice_line', columns: [{ name: 'quantity' }] }]` | +| `subforms: [{ childObject: 'invoice_line', columns: [{ name: 'amount', type: 'currency', scale: 2 }] }]` | `subforms: [{ childObject: 'invoice_line', columns: [{ name: 'amount', type: 'currency' }] }]` | +| `columns: [{ name: 'amount', scale: 2 }]` where `amount` is a `currency` field of the child object (either carrier) | `columns: [{ name: 'amount' }]` | +| a column carrying a key the column schema does not declare | the column without that key | + +The one-line fix: write each form-view subform column as `{ name, … }` using only the keys a relationship field's `inlineColumns` accepts, and delete `scale` from any column that renders as a currency column, whether it declares `type: 'currency'` or takes it from a `currency` child field. Nothing replaces `scale` there: the currency's ISO 4217 minor unit decides the displayed decimals. + +## Who is affected, measured + +On `origin/main` `cb4c31dd52`: zero authored `subforms` in the repository, and one authored `inlineColumns` block (the showcase invoice, seven identity-only columns, none carrying `scale`). No example, template or test fixture outside this change's own pins changes verdict. Deployed metadata was not measured. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 5659f9bb66b..10463176cd7 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1735,7 +1735,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | -| **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | +| **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | @@ -1820,7 +1820,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | -| **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | +| **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 76cdb01cc63..b35b9b07ab3 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -432,7 +432,7 @@ Form-view select option — the object-field option shape minus the per-option ` | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | -| **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | +| **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | @@ -508,7 +508,7 @@ Form-view select option — the object-field option shape minus the per-option ` | :--- | :--- | :--- | :--- | | **childObject** | `string` | ✅ | Child object whose records are entered inline | | **relationshipField** | `string` | optional | FK on the child pointing back to the parent (auto-detected when omitted) | -| **columns** | `any[]` | optional | Editable grid columns (derived from the child object when omitted) | +| **columns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Editable grid columns (derived from the child object when omitted). Each entry is the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes (`{ name, label?, type?, … }` — objectui GridColumn); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **amountField** | `string` | optional | Numeric child column summed for the running total | | **totalField** | `string` | optional | Parent field to receive the rolled-up sum | | **title** | `string` | optional | Section title | @@ -1849,7 +1849,7 @@ Tab configuration for multi-tab view interface | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | -| **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | +| **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | @@ -1934,7 +1934,7 @@ Tab configuration for multi-tab view interface | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | -| **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | +| **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | @@ -2209,7 +2209,7 @@ This schema accepts one of the following structures: | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | -| **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | +| **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | @@ -2399,7 +2399,7 @@ This schema accepts one of the following structures: | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | | **groups** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. | -| **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | +| **subforms** | `{ childObject: string; relationshipField?: string; columns?: object[]; amountField?: string; … }[]` | optional | Inline master-detail child collections | | **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form | | **submitBehavior** | `{ kind: 'thank-you'; title?: string; message?: string } \| { kind: 'redirect'; url: string; delayMs?: integer } \| { kind: 'continue' } \| { kind: 'next-record' }` | optional | Post-submit behavior. On the `redirect` arm, `url` is relative-only and interpolates only declared record fields as `{{record.field_name}}`, URL-escaped (ruled 2026-08-11). | diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index a2255f62221..0de62491174 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 212, - "droppedRefinementSites": 612, + "droppedRefinementSites": 617, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -212,6 +212,7 @@ "sites": [ "form", "form.sections.element.in", + "form.subforms.element.columns.element", "form.submitBehavior.options[1].url", "list", "list.bulkActionDefs.element", @@ -1235,6 +1236,7 @@ "", "sections.element.in", "sections.element.in.fields.element.options[1].in.publicPicker.filter.element", + "subforms.element.columns.element", "submitBehavior.options[1].url" ] }, @@ -1420,6 +1422,7 @@ "sites": [ "form", "form.sections.element.in", + "form.subforms.element.columns.element", "form.submitBehavior.options[1].url", "list", "list.bulkActionDefs.element", @@ -1446,6 +1449,7 @@ "options[0].config.timeline.groupByField", "options[1].config", "options[1].config.sections.element.in", + "options[1].config.subforms.element.columns.element", "options[1].config.submitBehavior.options[1].url" ] }, @@ -1460,6 +1464,7 @@ "options[0].config.timeline.groupByField", "options[1].config", "options[1].config.sections.element.in", + "options[1].config.subforms.element.columns.element", "options[1].config.submitBehavior.options[1].url" ] }, diff --git a/packages/spec/src/inline-grid-column-carriers.test.ts b/packages/spec/src/inline-grid-column-carriers.test.ts new file mode 100644 index 00000000000..12e0e96dd94 --- /dev/null +++ b/packages/spec/src/inline-grid-column-carriers.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20901 — an inline grid column is judged on BOTH of its carriers, and by the + * type it renders as. + * + * The objectui master-detail grid reads one column shape from two carriers: a + * relationship field's `inlineColumns` and a form view's `subforms[].columns`. + * `InlineGridColumnSchema` judged only the first, and only by the column's + * DECLARED `type`. Measured by the card through a public door (`objectui + * validate`, exit 0) and by `FormViewSchema.safeParse` at 17.5.0, three + * columns published green on the form-view carrier: + * + * A. a typed `currency` column carrying `scale` (refused on the other carrier); + * B. an identity-only column carrying `scale` over a `currency` child field; + * C. a column whose only key is the bogus `zzz_not_a_key`. + * + * What is pinned here: + * + * 1. THE REFERENCE — the form-view carrier's column element IS + * `InlineGridColumnSchema` (the same object, not a copy), and A and C are + * refused by `FormViewSchema` with the column schema's own issues. + * 2. THE REACH OF THE SCHEMA — B passes `FormViewSchema`: the schema cannot + * see the child field, so B is judged where the child field is a fact — + * `defineStack`'s cross-reference check, on BOTH carriers, with the column + * schema's own refusal (`code` + `status` of the ADR-0112 envelope, the + * located finding, and the ruled first sentence). + * 3. THE CONTROLS — valid columns on both carriers, a column that declares a + * `type`, a child field of another type, and a child object this stack + * does not declare are all accepted. + * + * Key-vs-value note: the column rules judge the KEY on one type whatever its + * value, so each refusal is a full parse failure at the column's own path, and + * each control is a full success — never mere absence of `unrecognized_keys`. + */ + +import { describe, expect, it } from 'vitest'; +import { InlineGridColumnSchema } from './data/field.zod'; +import { FormViewSchema } from './ui/view.zod'; +import { defineStack } from './stack.zod'; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +type Result = { success: boolean; error?: { issues: Issue[] }; data?: unknown }; + +/** The column refusal's ruled first sentence (ruling B on #19629, remedy 乙 on #19910). */ +const COLUMN_FIRST_SENTENCE = '`scale` is not valid on a `currency` inline grid column — delete the key.'; + +/** The card's three columns. */ +const TYPED_CURRENCY_WITH_SCALE = { name: 'amount', type: 'currency', scale: 2 } as const; +const IDENTITY_ONLY_WITH_SCALE = { name: 'amount', scale: 2 } as const; +const BOGUS_KEY_ONLY = { zzz_not_a_key: 1 } as const; + +const FORM_BASE = { type: 'simple', sections: [{ fields: ['title'] }] } as const; + +const parseSubformColumns = (columns: unknown[]): Result => + FormViewSchema.safeParse({ ...FORM_BASE, subforms: [{ childObject: 'crm_invoice_line', columns }] }) as Result; + +// --------------------------------------------------------------------------- +// A stack whose child object carries one field of each type the pins need. +// --------------------------------------------------------------------------- + +const manifest = { + id: 'com.example.inlinecarriers', + name: 'inline-grid-column-carriers-test', + version: '1.0.0', + type: 'app' as const, +}; + +const PARENT = { name: 'crm_invoice', label: 'Invoice', fields: { title: { type: 'text' as const } } }; + +const childObject = (inlineColumns?: unknown[]) => ({ + name: 'crm_invoice_line', + label: 'Invoice Line', + fields: { + invoice: { + type: 'master_detail' as const, + reference: 'crm_invoice', + inlineEdit: 'grid' as const, + ...(inlineColumns ? { inlineColumns } : {}), + }, + quantity: { type: 'number' as const }, + amount: { type: 'currency' as const }, + }, +}); + +type FormSlot = 'form' | 'formViews'; + +const stackWithSubformColumns = (columns: unknown[], slot: FormSlot = 'form', child = 'crm_invoice_line') => { + const form = { ...FORM_BASE, subforms: [{ childObject: child, columns }] }; + return { + manifest, + objects: [PARENT, childObject()], + views: [{ + name: 'crm_invoice', + object: 'crm_invoice', + ...(slot === 'form' ? { form } : { formViews: { entry: form } }), + }], + }; +}; + +const stackWithInlineColumns = (inlineColumns: unknown[]) => ({ + manifest, + objects: [PARENT, childObject(inlineColumns)], +}); + +/** `defineStack` over a fixture typed loosely on purpose — several are refused by design. */ +const build = (stack: unknown) => defineStack(stack as Parameters[0]); + +type Refusal = { code?: string; status?: number; issues?: unknown[]; message: string }; + +/** Run `defineStack` and return the refusal it threw — fails the test when it accepted. */ +function refusalOf(stack: unknown): Refusal { + try { + build(stack); + } catch (error) { + return error as Refusal; + } + throw new Error('expected defineStack to REFUSE this stack, and it accepted it'); +} + +/** The one cross-reference finding the stack raised — its located subject and the column refusal. */ +function expectHydratedCurrencyRefusal(stack: unknown, location: string): void { + const refusal = refusalOf(stack); + expect(refusal.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refusal.status).toBe(422); + expect(refusal.issues).toHaveLength(1); + const finding = String(refusal.issues![0]); + expect(finding.startsWith(`${location}: column 'amount' declares no \`type\``)).toBe(true); + expect(finding).toContain(`field 'crm_invoice_line.amount' is \`currency\``); + expect(finding).toContain(COLUMN_FIRST_SENTENCE); +} + +describe('#20901 — the form-view carrier references the column contract', () => { + it('its column element IS InlineGridColumnSchema — one contract, not a copy', () => { + const subforms = (FormViewSchema.shape as unknown as Record } } }>).subforms; + const columnElement = subforms.unwrap().element.shape.columns.unwrap().element; + expect(columnElement).toBe(InlineGridColumnSchema); + }); + + it('refuses the card\'s typed currency column carrying `scale`, at the column\'s `scale`, with the ruled first sentence', () => { + const result = parseSubformColumns([{ name: 'quantity' }, TYPED_CURRENCY_WITH_SCALE]); + expect(result.success).toBe(false); + expect(result.error!.issues).toHaveLength(1); + const [issue] = result.error!.issues; + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['subforms', 0, 'columns', 1, 'scale']); + expect(issue.message.startsWith(COLUMN_FIRST_SENTENCE)).toBe(true); + }); + + it('refuses the card\'s bogus-key column: the key is named, and the missing `name` is required', () => { + const result = parseSubformColumns([BOGUS_KEY_ONLY]); + expect(result.success).toBe(false); + const issues = result.error!.issues; + const unknown = issues.find((i) => i.code === 'unrecognized_keys'); + expect(unknown?.path).toEqual(['subforms', 0, 'columns', 0]); + expect(unknown?.message).toContain('`zzz_not_a_key`'); + expect(issues.some((i) => i.code === 'invalid_type' && i.path.join('.') === 'subforms.0.columns.0.name')).toBe(true); + }); + + it('refuses the retired `field` spelling with the prescription naming `name` — the other carrier\'s alias table, reached by reference', () => { + const result = parseSubformColumns([{ field: 'quantity' }]); + expect(result.success).toBe(false); + const unknown = result.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(unknown?.path).toEqual(['subforms', 0, 'columns', 0]); + expect(unknown?.message).toContain('`field` → `name`'); + }); + + it('passes the card\'s identity-only column carrying `scale`: the schema cannot see the child field — the cross-reference check judges it (below)', () => { + const result = parseSubformColumns([IDENTITY_ONLY_WITH_SCALE]); + expect(result.success).toBe(true); + }); + + it('CONTROLS — valid columns parse and keep their keys', () => { + const columns = [ + { name: 'quantity' }, + { name: 'weight', type: 'number', computed: true, expr: 'quantity * 2', scale: 3 }, + { name: 'amount', type: 'currency', prefix: 'US$' }, + { name: 'status', type: 'select', options: [{ label: 'Open', value: 'open' }] }, + ]; + const result = parseSubformColumns(columns); + expect(result.success).toBe(true); + const parsed = (result.data as { subforms: Array<{ columns: unknown[] }> }).subforms[0].columns; + expect(parsed).toEqual(columns); + }); +}); + +describe('#20901 — defineStack judges an identity-only column by the type it renders as', () => { + it('refuses the card\'s identity-only column over a currency child field on the form view\'s `form`', () => { + expectHydratedCurrencyRefusal( + stackWithSubformColumns([{ name: 'quantity' }, IDENTITY_ONLY_WITH_SCALE]), + 'View[0].form.subforms[0].columns[1].scale', + ); + }); + + it('refuses it on a named `formViews` entry too', () => { + expectHydratedCurrencyRefusal( + stackWithSubformColumns([IDENTITY_ONLY_WITH_SCALE], 'formViews'), + 'View[0].formViews.entry.subforms[0].columns[0].scale', + ); + }); + + it('refuses it on the other carrier, a relationship field\'s `inlineColumns`, where the column names a field of the object that owns the field', () => { + expectHydratedCurrencyRefusal( + stackWithInlineColumns([{ name: 'quantity' }, IDENTITY_ONLY_WITH_SCALE]), + "Object 'crm_invoice_line' field 'invoice' inlineColumns[1].scale", + ); + }); + + it('refuses the card\'s other two columns on the form-view carrier at the schema parse, before any cross-reference', () => { + for (const column of [TYPED_CURRENCY_WITH_SCALE, BOGUS_KEY_ONLY]) { + const refusal = refusalOf(stackWithSubformColumns([column])); + expect(refusal.code, JSON.stringify(column)).toBe('STACK_SCHEMA_INVALID'); + expect(refusal.status, JSON.stringify(column)).toBe(422); + } + }); + + it('CONTROLS — valid columns build on both carriers', () => { + const columns = [ + { name: 'quantity', scale: 1 }, + { name: 'amount' }, + { name: 'amount', prefix: 'US$' }, + { name: 'amount', type: 'number', scale: 2 }, + { name: 'not_a_child_field', scale: 2 }, + ]; + expect(() => build(stackWithSubformColumns(columns))).not.toThrow(); + expect(() => build(stackWithSubformColumns(columns, 'formViews'))).not.toThrow(); + expect(() => build(stackWithInlineColumns(columns))).not.toThrow(); + }); + + it('CONTROL — a child object this stack does not declare is not judged: an unresolved column is not a wrong one', () => { + const stack = stackWithSubformColumns([IDENTITY_ONLY_WITH_SCALE], 'form', 'ext_line'); + expect(() => build(stack)).not.toThrow(); + }); + + it('following the remedy builds: the refused column with `scale` deleted and nothing added', () => { + const remedied: Record = { ...IDENTITY_ONLY_WITH_SCALE }; + delete remedied.scale; + expect(Object.keys(remedied)).toEqual(['name']); + expect(() => build(stackWithSubformColumns([remedied]))).not.toThrow(); + expect(() => build(stackWithInlineColumns([remedied]))).not.toThrow(); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.form-view-subform-columns-closed.ts b/packages/spec/src/migrations/entries/semantic/18.form-view-subform-columns-closed.ts new file mode 100644 index 00000000000..f296a99f318 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.form-view-subform-columns-closed.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #20901 — the form-view carrier of the inline grid column. `subforms[].columns` +// was `z.array(z.any())` while the other carrier, a relationship field's +// `inlineColumns`, has been the strict `InlineGridColumnSchema` since #9227; the +// carrier now REFERENCES that schema, so both carriers are judged by one +// contract. D3 only, deliberately: the one mechanical respelling the family +// had (`field` → `name`, conversion `field-column-lists-canonicalized`) is +// already retired from the load path, and every other refused shape is a +// judgment only the author can make. A stored view whose column fails is +// refused with the column schema's own prescription, never stripped. +export const entry: SemanticMigration = { + id: 'form-view-subform-columns-closed', + surface: 'view.form.subforms[].columns[] and view.formViews..subforms[].columns[] — the ' + + 'form view\'s inline grid columns, which used to accept any value', + replacement: 'each entry is the strict, name-keyed inline grid column a relationship field\'s ' + + '`inlineColumns` takes — `{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest ' + + 'from the child object\'s field. Write `name` where a column said `field` (or `fieldName`, ' + + '`key`); delete `scale` from a column declaring `type: \'currency\'`; delete any key the ' + + 'column schema does not declare.', + reason: 'Both carriers feed the one console grid, which reads only the keys the column schema ' + + 'declares and keys a column by `name` alone. On the form view the columns were never judged, ' + + 'so a mis-keyed column published clean and drew a blank grid column, and a key the other ' + + 'carrier refuses — `scale` on a currency column, under the maintainer\'s ruling of 2026-09-23 ' + + '(option B, `scale` retired from the currency type) and the remedy ruled on 2026-09-24 ' + + '(option 乙 — a currency\'s ISO 4217 minor unit decides its display) — published green here. ' + + 'The carrier now references the column schema, so every rule it holds applies here too, with ' + + 'its own prescription. NOT mechanically converted: the `field` → `name` respelling this family ' + + 'had is already retired from the load path, and which column an unknown key or a mixed ' + + '`field`/`name` entry meant is the author\'s call — a conversion that dropped the key would ' + + 'accept on every load what the parse now refuses. Population measured at the change, on ' + + 'origin/main cb4c31dd52: zero authored `subforms` in the repository (the showcase derives its ' + + 'master-detail grids from the data model instead), against one authored `inlineColumns` block ' + + 'as the control. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every view in the stack parses: `objectstack validate` and a view parse report ' + + 'no issue on a `subforms[].columns[]` path. Every column entry is an object carrying `name`, ' + + 'no entry carries `field`, `fieldName` or `key`, and no column declaring `type: \'currency\'` ' + + 'carries `scale`. Each column `name` names a field of the subform\'s `childObject`, and the ' + + 'master-detail grid renders a value — not a blank cell — in each column for a row that has one.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-identity-only-currency-scale-refused.ts b/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-identity-only-currency-scale-refused.ts new file mode 100644 index 00000000000..34aa79041e6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.inline-grid-column-identity-only-currency-scale-refused.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #20901 — the reach of `inline-grid-column-currency-scale-refused`, extended to +// the column that declares no `type`. The column schema judges only a DECLARED +// type; an identity-only column takes its type from the child field when the +// console hydrates it, and the child field is a fact the stack holds. So +// `defineStack`'s cross-reference check re-parses such a column as the type it +// renders as and reports the column schema's own refusal — no second scale +// rule. D3 only, for the reason the declared-type entry gives: a conversion +// that dropped the key would accept it on every load, the grace window the +// ruling refused. +export const entry: SemanticMigration = { + id: 'inline-grid-column-identity-only-currency-scale-refused', + surface: 'object.fields..inlineColumns[].scale and view.form.subforms[].columns[].scale ' + + '(and each formViews entry) on a column that declares NO `type` and whose `name` is a ' + + '`currency` field of the child object — any value, `scale: 0` included. A column declaring ' + + '`type: \'number\'`, and a column over a field of any other type, keep `scale`', + replacement: 'no `scale` on the column. DELETE the key — that is the whole migration: the column ' + + 'renders as a currency column, and a currency amount\'s decimal places are its currency\'s. ' + + 'The currency\'s ISO 4217 minor unit decides how the cell displays the amount and the width a ' + + 'computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under ' + + 'any other key, and do not add `type: \'number\'` to keep it on a currency amount.', + reason: 'The refusal of `scale` on a currency inline grid column (entry ' + + '`inline-grid-column-currency-scale-refused`, under the maintainer\'s rulings of 2026-09-23, ' + + 'option B, and 2026-09-24, option 乙) reached only a column that DECLARES `type: \'currency\'`, ' + + 'because the column schema cannot see the child field. An identity-only column — the ' + + 'recommended form — over a currency field renders as a currency column all the same, so it ' + + 'published green carrying the refused key, and the console ignored it. `defineStack`\'s ' + + 'cross-reference check, which holds the child object\'s fields, now judges such a column as the ' + + 'type it renders as and refuses it with the column schema\'s own message. Reach: the child ' + + 'object must be declared in the same stack; a column naming no field of it, or a subform whose ' + + 'child object comes from another package, is not judged there. Population measured at the ' + + 'change, on origin/main cb4c31dd52: one authored `inlineColumns` block (the showcase invoice, ' + + 'seven identity-only columns, none carrying `scale`) and zero authored `subforms`. Deployed ' + + 'metadata NOT MEASURED.', + acceptanceCriteria: '`objectstack validate` and `defineStack` report no cross-reference finding on ' + + 'an `inlineColumns[].scale` or `subforms[].columns[].scale` path. A column that carried `scale` ' + + 'over a currency child field no longer declares it, and a diff of the column shows that one line ' + + 'deleted and no key added. Columns over `number` fields, and columns declaring `type: ' + + '\'number\'`, keep their `scale`.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 5b627ab6b4d..3f4d9bba6f0 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -11867,6 +11867,44 @@ const step18: MigrationStep = { + 'the field left empty is stored with that value — or the author has decided the form needs ' + 'no pre-selection and the object field is unchanged.', }, + // #20901 — the form-view carrier of the inline grid column. `subforms[].columns` + // was `z.array(z.any())` while the other carrier, a relationship field's + // `inlineColumns`, has been the strict `InlineGridColumnSchema` since #9227; the + // carrier now REFERENCES that schema, so both carriers are judged by one + // contract. D3 only, deliberately: the one mechanical respelling the family + // had (`field` → `name`, conversion `field-column-lists-canonicalized`) is + // already retired from the load path, and every other refused shape is a + // judgment only the author can make. A stored view whose column fails is + // refused with the column schema's own prescription, never stripped. + { + id: 'form-view-subform-columns-closed', + surface: 'view.form.subforms[].columns[] and view.formViews..subforms[].columns[] — the ' + + 'form view\'s inline grid columns, which used to accept any value', + replacement: 'each entry is the strict, name-keyed inline grid column a relationship field\'s ' + + '`inlineColumns` takes — `{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest ' + + 'from the child object\'s field. Write `name` where a column said `field` (or `fieldName`, ' + + '`key`); delete `scale` from a column declaring `type: \'currency\'`; delete any key the ' + + 'column schema does not declare.', + reason: 'Both carriers feed the one console grid, which reads only the keys the column schema ' + + 'declares and keys a column by `name` alone. On the form view the columns were never judged, ' + + 'so a mis-keyed column published clean and drew a blank grid column, and a key the other ' + + 'carrier refuses — `scale` on a currency column, under the maintainer\'s ruling of 2026-09-23 ' + + '(option B, `scale` retired from the currency type) and the remedy ruled on 2026-09-24 ' + + '(option 乙 — a currency\'s ISO 4217 minor unit decides its display) — published green here. ' + + 'The carrier now references the column schema, so every rule it holds applies here too, with ' + + 'its own prescription. NOT mechanically converted: the `field` → `name` respelling this family ' + + 'had is already retired from the load path, and which column an unknown key or a mixed ' + + '`field`/`name` entry meant is the author\'s call — a conversion that dropped the key would ' + + 'accept on every load what the parse now refuses. Population measured at the change, on ' + + 'origin/main cb4c31dd52: zero authored `subforms` in the repository (the showcase derives its ' + + 'master-detail grids from the data model instead), against one authored `inlineColumns` block ' + + 'as the control. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every view in the stack parses: `objectstack validate` and a view parse report ' + + 'no issue on a `subforms[].columns[]` path. Every column entry is an object carrying `name`, ' + + 'no entry carries `field`, `fieldName` or `key`, and no column declaring `type: \'currency\'` ' + + 'carries `scale`. Each column `name` names a field of the subform\'s `childObject`, and the ' + + 'master-detail grid renders a value — not a blank cell — in each column for a row that has one.', + }, { id: 'hook-register-undispatched-lifecycle-event-refused', surface: @@ -12303,6 +12341,45 @@ const step18: MigrationStep = { + 'that one line deleted and no key added. `number` columns, and columns declaring no `type`, ' + 'keep their `scale`; a column\'s `prefix` is still accepted on a currency column.', }, + // #20901 — the reach of `inline-grid-column-currency-scale-refused`, extended to + // the column that declares no `type`. The column schema judges only a DECLARED + // type; an identity-only column takes its type from the child field when the + // console hydrates it, and the child field is a fact the stack holds. So + // `defineStack`'s cross-reference check re-parses such a column as the type it + // renders as and reports the column schema's own refusal — no second scale + // rule. D3 only, for the reason the declared-type entry gives: a conversion + // that dropped the key would accept it on every load, the grace window the + // ruling refused. + { + id: 'inline-grid-column-identity-only-currency-scale-refused', + surface: 'object.fields..inlineColumns[].scale and view.form.subforms[].columns[].scale ' + + '(and each formViews entry) on a column that declares NO `type` and whose `name` is a ' + + '`currency` field of the child object — any value, `scale: 0` included. A column declaring ' + + '`type: \'number\'`, and a column over a field of any other type, keep `scale`', + replacement: 'no `scale` on the column. DELETE the key — that is the whole migration: the column ' + + 'renders as a currency column, and a currency amount\'s decimal places are its currency\'s. ' + + 'The currency\'s ISO 4217 minor unit decides how the cell displays the amount and the width a ' + + 'computed amount is rounded to. ⛔ Nothing replaces the key: do not re-declare its value under ' + + 'any other key, and do not add `type: \'number\'` to keep it on a currency amount.', + reason: 'The refusal of `scale` on a currency inline grid column (entry ' + + '`inline-grid-column-currency-scale-refused`, under the maintainer\'s rulings of 2026-09-23, ' + + 'option B, and 2026-09-24, option 乙) reached only a column that DECLARES `type: \'currency\'`, ' + + 'because the column schema cannot see the child field. An identity-only column — the ' + + 'recommended form — over a currency field renders as a currency column all the same, so it ' + + 'published green carrying the refused key, and the console ignored it. `defineStack`\'s ' + + 'cross-reference check, which holds the child object\'s fields, now judges such a column as the ' + + 'type it renders as and refuses it with the column schema\'s own message. Reach: the child ' + + 'object must be declared in the same stack; a column naming no field of it, or a subform whose ' + + 'child object comes from another package, is not judged there. Population measured at the ' + + 'change, on origin/main cb4c31dd52: one authored `inlineColumns` block (the showcase invoice, ' + + 'seven identity-only columns, none carrying `scale`) and zero authored `subforms`. Deployed ' + + 'metadata NOT MEASURED.', + acceptanceCriteria: '`objectstack validate` and `defineStack` report no cross-reference finding on ' + + 'an `inlineColumns[].scale` or `subforms[].columns[].scale` path. A column that carried `scale` ' + + 'over a currency child field no longer declares it, and a diff of the column shows that one line ' + + 'deleted and no key added. Columns over `number` fields, and columns declaring `type: ' + + '\'number\'`, keep their `scale`.', + }, // #14478 (maintainer ruling B: a duration key carries its unit in its NAME) — // the D3 entry of the `job-timeout-to-timeout-ms` family (ruling B on #17152: // one D3 entry per retirement family, even when D2 is lossless). The job half diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 723a8356534..9102d7a0316 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -26,6 +26,7 @@ import { lintUnknownAuthoringKeys, lintUnknownStackKeys } from './kernel/metadat // Data Protocol import { ObjectSchema, ObjectExtensionSchema } from './data/object.zod'; +import { InlineGridColumnSchema } from './data/field.zod'; import { SeedSchema } from './data/seed.zod'; // UI Protocol @@ -2664,6 +2665,115 @@ function collectPermissionGrantObjectErrors( return errors; } +/** + * The inline grid column type a column that declares NO `type` renders as, + * keyed by the type of the child field it names — for the field types whose + * column type carries a type-conditional refusal in + * {@link InlineGridColumnSchema}, and for no other. + * + * Read from the renderer, not decided here: objectui's `hydrateColumns` + * (`packages/plugin-form/src/deriveMasterDetail.ts`, at the `.objectui-sha` + * pin) leaves a column that declares a `type` alone and otherwise sets + * `type: fieldTypeToColumnType(childField.type)`, whose only `currency` arm is + * the `currency` field type. Today the column schema's one type-conditional + * rule is the `currency` refusal of `scale`, so `currency` is the one row; a + * new type-conditional rule on the column adds its row here. + */ +const HYDRATED_INLINE_COLUMN_TYPE: Readonly> = { + currency: 'currency', +}; + +/** Own-key lookup — a column `name` such as `constructor` must not resolve up the prototype chain. */ +const hasOwnKey = (record: object, key: string): boolean => Object.prototype.hasOwnProperty.call(record, key); + +/** + * [#20901] Inline grid columns whose TYPE comes from the child field, judged by + * the column contract's own rules against the type they will render as. + * + * `InlineGridColumnSchema` refuses `scale` on a column that DECLARES + * `type: 'currency'` (ruling B on #19629, remedy 乙 on #19910), and sees only + * the declared type: an identity-only column (`{ name: 'amount', scale: 2 }`) + * takes its type from the child field when the grid hydrates it, so a column + * over a `currency` child field published green carrying the refused key. The + * child field is a fact the stack holds, so this is where it is judged. + * + * ⛔ No second rule. The verdict is the column schema's own: the column is + * re-parsed as `{ ...column, type: }` and every issue that parse + * raises is reported, with the schema's own message. The column already passed + * the stack's parse without the type, so an issue here is one the resolved + * type brings. + * + * Both carriers of the column are walked: + * + * - a relationship field's `inlineColumns` — the field sits on the CHILD + * object, so a column names a field of the object that owns the field; + * - a form view's `subforms[].columns` (the container's `form` and each + * `formViews` entry) — a column names a field of the subform's + * `childObject`. + * + * Resolution is against the stack's own objects, like the view data-source + * check in {@link validateCrossReferences}: a `childObject` this stack does not + * declare, or a column naming no field of it, is not judged here — an + * unresolved column is not a wrong one, and the console's render-time report + * stays the backstop for it. + */ +function collectHydratedInlineColumnErrors(config: ObjectStackDefinition): string[] { + const errors: string[] = []; + const fieldsByObject = new Map>(); + for (const obj of config.objects ?? []) { + if (!isRecord(obj) || !isRecord(obj.fields)) continue; + fieldsByObject.set(obj.name, obj.fields); + } + + const judge = (columns: unknown, childObject: string, where: string): void => { + const childFields = fieldsByObject.get(childObject); + if (!childFields || !Array.isArray(columns)) return; + columns.forEach((column: unknown, k: number) => { + if (!isRecord(column) || column.type !== undefined || typeof column.name !== 'string') return; + const childField = hasOwnKey(childFields, column.name) ? childFields[column.name] : undefined; + const fieldType = isRecord(childField) ? childField.type : undefined; + const resolved = typeof fieldType === 'string' && hasOwnKey(HYDRATED_INLINE_COLUMN_TYPE, fieldType) + ? HYDRATED_INLINE_COLUMN_TYPE[fieldType] + : undefined; + if (!resolved) return; + const verdict = InlineGridColumnSchema.safeParse({ ...column, type: resolved }); + if (verdict.success) return; + for (const issue of verdict.error.issues) { + const at = issue.path.map((segment) => `.${String(segment)}`).join(''); + errors.push( + `${where}[${k}]${at}: column '${column.name}' declares no \`type\`, so it renders as a ` + + `\`${resolved}\` column (field '${childObject}.${column.name}' is \`${fieldType}\`). ${issue.message}`, + ); + } + }); + }; + + for (const obj of config.objects ?? []) { + if (!isRecord(obj) || !isRecord(obj.fields)) continue; + for (const [fieldName, field] of Object.entries(obj.fields)) { + if (!isRecord(field)) continue; + judge(field.inlineColumns, obj.name, `Object '${obj.name}' field '${fieldName}' inlineColumns`); + } + } + + for (const [i, view] of (config.views ?? []).entries()) { + const forms: Array<[where: string, form: unknown]> = []; + if (view.form) forms.push([`View[${i}].form`, view.form]); + for (const [key, form] of Object.entries(view.formViews ?? {})) { + forms.push([`View[${i}].formViews.${key}`, form]); + } + for (const [where, form] of forms) { + const subforms = isRecord(form) ? form.subforms : undefined; + if (!Array.isArray(subforms)) continue; + subforms.forEach((subform: unknown, j: number) => { + if (!isRecord(subform) || typeof subform.childObject !== 'string') return; + judge(subform.columns, subform.childObject, `${where}.subforms[${j}].columns`); + }); + } + } + return errors; +} + /** * Perform strict cross-reference validation on a parsed stack definition. * Returns an array of error messages (empty if valid). @@ -2740,6 +2850,10 @@ function validateCrossReferences( } } + // Validate identity-only inline grid columns against the child field's type + // (#20901) — both carriers, the column schema's own verdict. + errors.push(...collectHydratedInlineColumnErrors(config)); + // Validate seed data → object references. ARTIFACT-SCOPED (#18202). errors.push(...collectSeedDataObjectErrors(config, artifactScope)); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 32fdbe585d8..7df808d003a 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -69,7 +69,7 @@ import { retiredKey } from '../shared/retired-key'; // so the bound this row applies costs no published export — see that module's // docblock for the measurement and for the two routes that were not taken. import { MAX_RENDERABLE_SCALE, SCALE_UPPER_BOUND_MESSAGE } from '../shared/scale-ceiling'; -import { FieldType, SelectOptionSchema } from '../data/field.zod'; +import { FieldType, InlineGridColumnSchema, SelectOptionSchema } from '../data/field.zod'; // [#19514] The text-comparand door the Filter Protocol publishes for the // case-insensitive contains operator — the discrimination `FILTER_TEXT_CASES`' // two REJECTION rows are about, and the reason text that answers them. Imported @@ -4371,7 +4371,15 @@ export const FormViewSchema = lazySchema(() => strictObject({ }, { childObject: z.string().describe('Child object whose records are entered inline'), relationshipField: z.string().optional().describe('FK on the child pointing back to the parent (auto-detected when omitted)'), - columns: z.array(z.any()).optional().describe('Editable grid columns (derived from the child object when omitted)'), + // #20901 — the SAME column contract a relationship field's `inlineColumns` + // takes, referenced rather than copied: both carriers feed one objectui + // grid, so the rulings the column carries (`scale` refused on a `currency` + // column — ruling B on #19629, remedy 乙 on #19910) hold on both. Until + // this was a reference the carrier was `z.array(z.any())`, so a refused + // key and a key the grid never reads both published green here. A column + // that declares no `type` takes it from the child field at render time; + // `defineStack`'s cross-reference check judges that resolved type. + columns: z.array(InlineGridColumnSchema).optional().describe("Editable grid columns (derived from the child object when omitted). Each entry is the strict, name-keyed inline grid column a relationship field's `inlineColumns` takes ({ name, label?, type?, … } — objectui GridColumn); identity-only entries ({ name }) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse."), amountField: z.string().optional().describe('Numeric child column summed for the running total'), totalField: z.string().optional().describe('Parent field to receive the rolled-up sum'), title: z.string().optional().describe('Section title'),