From 6df2e274f28835a33d28ace1c0eb518f6e1e5e1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:18:22 +0000 Subject: [PATCH 1/3] feat(types)!: a form view's subforms[].columns entry is the spec's InlineGridColumnSchema, by reference (objectui#11266) The object-form mirror held z.array(z.any()) for subforms[].columns while @objectstack/spec 17.6.0 judges FormViewSchema.subforms[].columns with its closed InlineGridColumnSchema (objectstack#20927), so objectui validate accepted a column with an undeclared key, or a typed currency column carrying scale, that os validate refuses. - zod: columns is z.array(stripImportedDefaults(SpecInlineGridColumnSchema)), crossing the import boundary like every other spec read; the 8317 census measures the new crossing. - TS: ObjectFormSchema subforms[].columns is the spec's InlineGridColumn. - Pin: object-form-subforms-columns-11266 on both faces, with the spec's FormViewSchema read live as the other door. - Docs: the plugin-view master-detail example wrote bare field-name strings as columns, which the renderer never read and the spec refuses. Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ Co-authored-by: Claude --- .changeset/11266-subforms-columns-mirror.md | 26 +++ content/docs/plugins/plugin-view.mdx | 7 +- packages/plugin-view/README.md | 7 +- .../__tests__/imported-defaults-8317.test.ts | 12 +- ...object-form-subforms-columns-11266.test.ts | 206 ++++++++++++++++++ packages/types/src/objectql.ts | 11 +- packages/types/src/zod/objectql.zod.ts | 17 +- 7 files changed, 279 insertions(+), 7 deletions(-) create mode 100644 .changeset/11266-subforms-columns-mirror.md create mode 100644 packages/types/src/__tests__/object-form-subforms-columns-11266.test.ts diff --git a/.changeset/11266-subforms-columns-mirror.md b/.changeset/11266-subforms-columns-mirror.md new file mode 100644 index 0000000000..0b64d2d845 --- /dev/null +++ b/.changeset/11266-subforms-columns-mirror.md @@ -0,0 +1,26 @@ +--- +'@object-ui/types': minor +--- + +A form view's `subforms[].columns` entry is judged by `@objectstack/spec`'s `InlineGridColumnSchema` now, by reference, so `objectui validate` and `os validate` give one verdict on a column (objectui#11266). + +BREAKING (`@object-ui/types`): the accept set of the tolerant face narrows. (The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.) + +`@objectstack/spec` 17.6.0 holds `FormViewSchema.subforms[].columns` to its closed inline grid column schema (objectstack-ai/objectstack#20927). The `object-form` mirror still read `z.array(z.any())` there, so `objectui validate` accepted columns that `os validate` refuses. + +What each face does now: + +- **zod (`@object-ui/types/zod`).** NARROWS on the tolerant face (`safeValidateSchema`, which `objectui validate` runs) and on the strict authoring face, wherever `subforms` is read: the object-view `form` slot and the `object-form` mirror. A column with an undeclared key is refused at the column, with one `unrecognized_keys` issue naming the key. A column that declares `type: 'currency'` and carries `scale` is refused at that `scale`, in the spec's own words. A bare field-name string is refused at the column with `invalid_type`. Each verdict is the spec schema's, because the column is handed to it. +- **TypeScript.** A `subforms[].columns` entry on `ObjectFormSchema` (and so on `ObjectViewSchema['form']`) is `InlineGridColumn` from `@objectstack/spec/data`, by reference, where it was `any`. A string column no longer compiles, and neither does an object literal with an undeclared column key. + +**Migration.** + +- FROM `columns: ['product', 'quantity']` → TO `columns: [{ name: 'product' }, { name: 'quantity' }]` +- FROM a column carrying a key `InlineGridColumn` does not declare → TO the same column without that key. A column that declares no `type` takes its label, type and the rest from the child object's field. +- FROM `{ name: 'amount', type: 'currency', scale: 2 }` → TO `{ name: 'amount', type: 'currency' }`. A currency amount's decimal places come from its currency's minor unit, not from the column. + +Not refused here: a `scale` on a column that declares no `type` (`{ name: 'amount', scale: 2 }`) when its child field is a currency. Seeing that takes the child object's fields, which are not in the document the validator judges. `defineStack` refuses it at publish, and the master-detail form reports it at render. + +The parse output is the spec schema's too: a column `readonlyWhen` or `requiredWhen` written as a string comes back from `safeValidateSchema` as the spec's `{ dialect: 'cel', source }` envelope. The document you pass in is not changed. + +**Clause-②: yes (narrowing)**: a `subforms[].columns` entry that is not a valid `InlineGridColumn` used to parse with its value kept, and is now refused at the column. diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index 3eacd46a7c..7ed7b86bdb 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -596,7 +596,7 @@ const orderView: ObjectViewSchema = { { childObject: 'order_items', title: 'Line items', - columns: ['product', 'quantity', 'price'], + columns: [{ name: 'product' }, { name: 'quantity' }, { name: 'price' }], }, ], }, @@ -605,7 +605,10 @@ const orderView: ObjectViewSchema = { Only `childObject` is required — the relationship field and the grid columns are derived from the child object's metadata unless you override them -(`relationshipField`, `columns`). +(`relationshipField`, `columns`). A column is `@objectstack/spec`'s +`InlineGridColumn`: an object keyed by `name`, never a bare field name. One that +declares no `type` takes its label, type and the rest from the child field; +an undeclared column key is refused by the validator. ### View tabs diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index 507d767338..86b9ad3fb4 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -697,7 +697,7 @@ const schema: ObjectViewSchema = { { childObject: 'order_items', title: 'Line items', - columns: ['product', 'quantity', 'price'], + columns: [{ name: 'product' }, { name: 'quantity' }, { name: 'price' }], }, ], }, @@ -706,7 +706,10 @@ const schema: ObjectViewSchema = { Only `childObject` is required — the relationship field and the grid columns are derived from the child object's metadata unless you override them -(`relationshipField`, `columns`). +(`relationshipField`, `columns`). A column is `@objectstack/spec`'s +`InlineGridColumn`: an object keyed by `name`, never a bare field name. One that +declares no `type` takes its label, type and the rest from the child field; +an undeclared column key is refused by the validator. ### View tabs diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 9478300384..aff774ee18 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -137,7 +137,12 @@ import { checkDashboardWidgetStageOrder, checkDashboardWidgetMetricMeasureArity, } from '@objectstack/spec/ui'; -import { FieldSchema as SpecFieldSchema, SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; +import { + FieldSchema as SpecFieldSchema, + SelectOptionSchema as SpecSelectOptionSchema, + // objectui#11266 — one `ObjectFormSchema.subforms[].columns` entry. + InlineGridColumnSchema as SpecInlineGridColumnSchema, +} from '@objectstack/spec/data'; import { EvaluatedExpressionInputSchema as SpecEvaluatedExpressionInputSchema, EvaluatedExpressionSchema as SpecEvaluatedExpressionSchema, @@ -372,6 +377,11 @@ const IMPORTED: Array = [ // spec's `.default(false)` — // exactly what this boundary exists to keep out of a parse output. ['FieldSchema', SpecFieldSchema], + // objectui#11266: one `ObjectFormSchema.subforms[].columns` entry is the spec's + // inline grid column, crossed through this boundary. It carries no default and + // reaches no `z.lazy`, so the strip is the identity function: the row is here + // because the census below requires every imported symbol to be measured. + ['InlineGridColumnSchema', SpecInlineGridColumnSchema], ] as const; /** The subset that actually carries an imported default — where the strip does work. */ diff --git a/packages/types/src/__tests__/object-form-subforms-columns-11266.test.ts b/packages/types/src/__tests__/object-form-subforms-columns-11266.test.ts new file mode 100644 index 0000000000..946fe0bc55 --- /dev/null +++ b/packages/types/src/__tests__/object-form-subforms-columns-11266.test.ts @@ -0,0 +1,206 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11266 — one `ObjectFormSchema.subforms[].columns` entry is the spec's + * `InlineGridColumnSchema`, by reference. + * + * `@objectstack/spec` 17.6.0 judges `FormViewSchema.subforms[].columns` with its + * closed inline grid column schema (objectstack#20927). The mirror held + * `z.array(z.any())` there, so `objectui validate` accepted a column with an + * undeclared key, and a typed `currency` column carrying `scale`, both of which + * `os validate` refuses. Two authoring doors gave two verdicts on one document. + * + * An authored document reaches `subforms` through the object-view `form` slot: + * `subforms` is a form-VIEW member, which the `object-form` row refuses in its + * `properties` bag (objectui#10859 batch 4), and the slot is this mirror minus + * its identity keys. So the face rows below author it there. The mirror rows + * parse the flat node as `ObjectForm` reads it. + * + * ## What is pinned + * + * - BY REFERENCE: a column's schema IS the spec schema as it crosses the + * import boundary, so a faithful hand copy turns this red. The TypeScript + * face IS the spec's `InlineGridColumn` (judged by `tsc -p tsconfig.test.json`). + * - THE VERDICTS, on the tolerant face (`safeValidateSchema`, which + * `objectui validate` runs) and on the strict authoring face. An undeclared + * column key is refused at the column, with the key named. A `scale` on a + * column that DECLARES `type: 'currency'` is refused at that `scale`. A bare + * field-name string is refused at the column. A column `{ name }` is + * accepted: the lit control. + * - ONE VERDICT ACROSS THE TWO DOORS: each probe column gets the verdict the + * spec's own `FormViewSchema` gives it, read live in the same run. + * + * ⛔ Not refused here, and pinned as ACCEPTED so that reaching for it is a + * deliberate change: a `scale` on an identity-only column (`{ name, scale }`) + * whose child field is a currency. Refusing it takes the child object's fields, + * which are not in the document this mirror judges. The spec's column schema + * accepts it too; `defineStack` refuses it at publish, by resolving `name` + * through `childObject`, and `plugin-form`'s `hydrateColumns` reports it at + * render (objectui#10783). + */ + +import { describe, it, expect } from 'vitest'; +import { + InlineGridColumnSchema as SpecInlineGridColumnSchema, + type InlineGridColumn as SpecInlineGridColumn, +} from '@objectstack/spec/data'; +import { FormViewSchema as SpecFormViewSchema } from '@objectstack/spec/ui'; +import type { ObjectFormSchema as DeclaredObjectFormSchema } from '../objectql'; +import { ObjectFormSchema as ObjectFormMirror } from '../zod/objectql.zod.js'; +import { safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; +import { stripImportedDefaults } from '../zod/imported-defaults.js'; + +/* ── Type level ───────────────────────────────────────────────────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +type DeclaredSubform = NonNullable[number]; +type DeclaredColumn = NonNullable[number]; + +// By reference: a declared column IS the spec's authoring type. A local +// restatement, or the `any` this replaced, turns this red. +export type _ColumnIsTheSpecInput = Expect>; + +export const identityOnlyColumnIsAuthorable: DeclaredSubform = { + childObject: 'order_line', + columns: [{ name: 'quantity' }, { name: 'amount', type: 'currency', label: 'Amount' }], +}; +export const stringColumnIsRefused: DeclaredSubform = { + childObject: 'order_line', + // @ts-expect-error a column is an object keyed by `name`, never a bare field-name string + columns: ['quantity'], +}; + +/* ── Runtime ──────────────────────────────────────────────────────────────── */ + +const CHILD = 'order_line'; + +/** An authored object-view whose form declares one subform with one column. */ +const viewWith = (column: unknown) => ({ + type: 'object-view', + objectName: 'order', + form: { subforms: [{ childObject: CHILD, columns: [column] }] }, +}); +/** The flat `object-form` node as `ObjectForm` reads it, with the same subform. */ +const flatWith = (column: unknown) => ({ + type: 'object-form', + objectName: 'order', + mode: 'create', + subforms: [{ childObject: CHILD, columns: [column] }], +}); +/** The spec's form view with the same subform: the other door's verdict. */ +const specFormViewWith = (column: unknown) => ({ + type: 'simple', + subforms: [{ childObject: CHILD, columns: [column] }], +}); + +const VALID = { name: 'quantity' }; +const BOGUS_KEY = { name: 'quantity', bogusKey: 1 }; +const TYPED_CURRENCY_WITH_SCALE = { name: 'amount', type: 'currency', scale: 2 }; +const IDENTITY_ONLY_WITH_SCALE = { name: 'amount', scale: 2 }; +const BARE_STRING = 'quantity'; + +/** The column's own position on the object-view face. */ +const VIEW_COLUMN = ['form', 'subforms', 0, 'columns', 0]; +/** …and on the flat mirror. */ +const FLAT_COLUMN = ['subforms', 0, 'columns', 0]; + +type Face = { name: string; parse: (doc: unknown) => ReturnType }; +const FACES: ReadonlyArray = [ + { name: 'tolerant (`safeValidateSchema`, which `objectui validate` runs)', parse: (doc) => safeValidateSchema(doc) }, + { name: 'strict authoring face', parse: (doc) => StrictAnyComponentSchema.safeParse(doc) as ReturnType }, +]; + +/** The one item schema of `subforms`, unwrapped from its `optional` / `array`. */ +function subformItemShape(): Record { + const subforms = (ObjectFormMirror.shape as Record).subforms; + return subforms.unwrap().element.shape; +} + +describe('objectui#11266 — `subforms[].columns` is the spec\'s `InlineGridColumnSchema`, by reference', () => { + it('a column\'s schema IS the spec schema as it crosses the import boundary', () => { + const columns = subformItemShape().columns as any; + expect(columns.unwrap().element).toBe(stripImportedDefaults(SpecInlineGridColumnSchema)); + // The crossing is the identity here (the column schema carries no default), + // so the member is the spec's own object. + expect(columns.unwrap().element).toBe(SpecInlineGridColumnSchema); + }); + + describe.each(FACES)('on the $name', ({ parse }) => { + it('CONTROL: a column `{ name }` is accepted', () => { + const parsed = parse(viewWith(VALID)); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it('an undeclared column key is refused at the column, with the key named', () => { + const parsed = parse(viewWith(BOGUS_KEY)); + expect(parsed.success).toBe(false); + expect(parsed.error!.issues).toHaveLength(1); + const [issue] = parsed.error!.issues; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual(VIEW_COLUMN); + expect((issue as { keys?: string[] }).keys).toEqual(['bogusKey']); + }); + + it('a `scale` on a column that declares `type: \'currency\'` is refused at that `scale`', () => { + const parsed = parse(viewWith(TYPED_CURRENCY_WITH_SCALE)); + expect(parsed.success).toBe(false); + expect(parsed.error!.issues).toHaveLength(1); + const [issue] = parsed.error!.issues; + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual([...VIEW_COLUMN, 'scale']); + // The refusal is the spec's own, sentence included: the same column + // judged by the spec's schema in this run yields the same message. + const spec = SpecInlineGridColumnSchema.safeParse(TYPED_CURRENCY_WITH_SCALE); + expect(spec.success).toBe(false); + expect(issue.message).toBe(spec.error!.issues[0].message); + }); + + it('a bare field-name string is refused at the column', () => { + const parsed = parse(viewWith(BARE_STRING)); + expect(parsed.success).toBe(false); + expect(parsed.error!.issues).toHaveLength(1); + const [issue] = parsed.error!.issues; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(VIEW_COLUMN); + }); + + it('⛔ an identity-only column with `scale` is accepted: the currency is the child field\'s, which this document does not carry', () => { + const parsed = parse(viewWith(IDENTITY_ONLY_WITH_SCALE)); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + }); + + it('the flat mirror gives the same verdicts at the same column', () => { + expect(ObjectFormMirror.safeParse(flatWith(VALID)).success).toBe(true); + + const bogus = ObjectFormMirror.safeParse(flatWith(BOGUS_KEY)); + expect(bogus.success).toBe(false); + expect(bogus.error!.issues.map((i) => [i.code, i.path])).toEqual([['unrecognized_keys', FLAT_COLUMN]]); + + const currency = ObjectFormMirror.safeParse(flatWith(TYPED_CURRENCY_WITH_SCALE)); + expect(currency.success).toBe(false); + expect(currency.error!.issues.map((i) => [i.code, i.path])).toEqual([['custom', [...FLAT_COLUMN, 'scale']]]); + }); + + it.each([ + ['a column `{ name }`', VALID, true], + ['an undeclared column key', BOGUS_KEY, false], + ['a typed currency column with `scale`', TYPED_CURRENCY_WITH_SCALE, false], + ['an identity-only column with `scale`', IDENTITY_ONLY_WITH_SCALE, true], + ['a bare field-name string', BARE_STRING, false], + ] as const)('one verdict across the two doors: %s', (_label, column, expected) => { + // The other door, read live: the spec's form view judging the same column. + const spec = SpecFormViewSchema.safeParse(specFormViewWith(column)); + expect(spec.success).toBe(expected); + expect(safeValidateSchema(viewWith(column)).success).toBe(spec.success); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 56042f8cfd..dad56421b8 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -36,6 +36,10 @@ import type { ElementDataSource } from '@objectstack/spec/ui'; // objectui#10872 batch 9 — the per-breakpoint style maps `responsiveStyles` declares // on `ObjectGridSchema` and `ObjectChartSchema`, by reference. import type { ResponsiveStyles as SpecResponsiveStyles } from '@objectstack/spec/ui'; +// objectui#11266 — one `ObjectFormSchema.subforms[].columns` entry, by reference: +// the spec's `InlineGridColumn` (its `z.input`, the authoring face), which the zod +// twin judges with the spec's own `InlineGridColumnSchema`. Type-only. +import type { InlineGridColumn as SpecInlineGridColumn } from '@objectstack/spec/data'; // objectui#7928 — `ObjectViewSchema.listViews` is the protocol's record of this // schema, BY REFERENCE. The spec publishes a TS type for `ListView` and none for // the object-scoped `ObjectListView`, so the member reads it as `z.input` (the @@ -2042,11 +2046,16 @@ export interface ObjectFormSchema extends BaseSchema { * derived from the child object's metadata (override with * `relationshipField` / `columns`). This lets a regular form view declare * master-detail without a bespoke page. + * + * A `columns` entry is the spec's `InlineGridColumn` (objectui#11266): an + * object keyed by `name`, never a bare field-name string. The minimal entry is + * `{ name: 'quantity' }`; a column that declares no `type` takes its label, + * type and the rest from the child object's field. */ subforms?: Array<{ childObject: string; relationshipField?: string; - columns?: any[]; + columns?: SpecInlineGridColumn[]; amountField?: string; totalField?: string; title?: string; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 1216368886..27ae77d708 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -77,6 +77,11 @@ import { EmptyStateSchema as SpecEmptyStateSchema, checkListViewCalendarVisualization, } from '@objectstack/spec/ui'; +// objectui#11266 — one inline master-detail grid column, the closed shape +// `@objectstack/spec` 17.6.0 judges `FormViewSchema.subforms[].columns` by +// (objectstack#20927); `ObjectFormSchema.subforms[].columns` below takes it by +// reference. +import { InlineGridColumnSchema as SpecInlineGridColumnSchema } from '@objectstack/spec/data'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; import { aliasKeyRefusal, handlerKeyRefusal, neitherContentChannelGuidance, retirementTombstone } from './tombstone.zod.js'; import { DataTableSchema, DrillDownConfigSchema, PivotTableSchema } from './data-display.zod.js'; @@ -971,7 +976,17 @@ export const ObjectFormSchema = BaseSchema.extend({ subforms: z.array(z.object({ childObject: z.string().describe('Child object name'), relationshipField: z.string().optional().describe('Foreign-key field on the child (derived from metadata when omitted)'), - columns: z.array(z.any()).optional().describe('Grid columns for the child rows (derived from metadata when omitted)'), + // objectui#11266 — each column is the spec's `InlineGridColumnSchema`, by + // reference: the schema `@objectstack/spec` 17.6.0 holds + // `FormViewSchema.subforms[].columns` to, so `objectui validate` and + // `os validate` give one verdict on a column. It refuses an undeclared key + // by name, and a `scale` on a column that DECLARES `type: 'currency'`. + // ⛔ It cannot see a `scale` on an identity-only column (`{ name, scale }`) + // whose child field is a currency: that takes the child object's fields, + // which are not in the document judged here. `defineStack` refuses it at + // publish, and `plugin-form`'s `hydrateColumns` reports it at render. + columns: z.array(stripImportedDefaults(SpecInlineGridColumnSchema)).optional() + .describe('Grid columns for the child rows (derived from metadata when omitted). @objectstack/spec InlineGridColumn, by reference'), amountField: z.string().optional(), totalField: z.string().optional(), title: z.string().optional(), From ce55b4653e8d9f0adecb7cff4014ae9d466509e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:31:08 +0000 Subject: [PATCH 2/3] test(types): read the mirror's column schema through its typed shape, not an any cast (objectui#11266) Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ Co-authored-by: Claude --- .../object-form-subforms-columns-11266.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/types/src/__tests__/object-form-subforms-columns-11266.test.ts b/packages/types/src/__tests__/object-form-subforms-columns-11266.test.ts index 946fe0bc55..62eababf7e 100644 --- a/packages/types/src/__tests__/object-form-subforms-columns-11266.test.ts +++ b/packages/types/src/__tests__/object-form-subforms-columns-11266.test.ts @@ -119,19 +119,15 @@ const FACES: ReadonlyArray = [ { name: 'strict authoring face', parse: (doc) => StrictAnyComponentSchema.safeParse(doc) as ReturnType }, ]; -/** The one item schema of `subforms`, unwrapped from its `optional` / `array`. */ -function subformItemShape(): Record { - const subforms = (ObjectFormMirror.shape as Record).subforms; - return subforms.unwrap().element.shape; -} +/** One column's schema on the mirror, unwrapped from `subforms`' and `columns`' `optional` / `array`. */ +const mirrorColumnSchema = () => ObjectFormMirror.shape.subforms.unwrap().element.shape.columns.unwrap().element; describe('objectui#11266 — `subforms[].columns` is the spec\'s `InlineGridColumnSchema`, by reference', () => { it('a column\'s schema IS the spec schema as it crosses the import boundary', () => { - const columns = subformItemShape().columns as any; - expect(columns.unwrap().element).toBe(stripImportedDefaults(SpecInlineGridColumnSchema)); + expect(mirrorColumnSchema()).toBe(stripImportedDefaults(SpecInlineGridColumnSchema)); // The crossing is the identity here (the column schema carries no default), // so the member is the spec's own object. - expect(columns.unwrap().element).toBe(SpecInlineGridColumnSchema); + expect(mirrorColumnSchema()).toBe(SpecInlineGridColumnSchema); }); describe.each(FACES)('on the $name', ({ parse }) => { From b1d035bbcd8ce7c52ba910d5145f7d1049cce156 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:46:46 +0000 Subject: [PATCH 3/3] docs(plugin-form): the currency-scale report's prose says both validators judge a declared subform column now (objectui#11266) reportCurrencyColumnScale's docblock and the currencyScale-10783 test header and one test name said a subform's columns is z.array(z.any()) in the object-form mirror and in the spec's FormViewSchema, and that the spec does not judge that path. This PR makes the mirror half false, and @objectstack/spec 17.6.0 already made the spec half false: both now judge a declared column with InlineGridColumnSchema. The reason the render-time report exists is restated truthfully: neither zod face sees an identity-only { name, scale } column whose child field is a currency, only defineStack does at publish (objectstack#20927), and nothing runs either validator between a stored or code-built form view and the render. Comment and test-name bytes only; the TS printer with removeComments shows no other change. Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ Co-authored-by: Claude --- ...veMasterDetail.currencyScale-10783.test.ts | 11 ++++++---- .../plugin-form/src/deriveMasterDetail.ts | 22 +++++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/plugin-form/src/deriveMasterDetail.currencyScale-10783.test.ts b/packages/plugin-form/src/deriveMasterDetail.currencyScale-10783.test.ts index 9ed71dafe6..26b7201b4b 100644 --- a/packages/plugin-form/src/deriveMasterDetail.currencyScale-10783.test.ts +++ b/packages/plugin-form/src/deriveMasterDetail.currencyScale-10783.test.ts @@ -14,9 +14,12 @@ * `@objectstack/spec` 17.5.0 refuses `scale` on an inline grid column that * declares `type: 'currency'`. A column that declares no `type` * (`{ name: 'amount', scale: 2 }`) still parses: `hydrateColumns` fills its - * `type` from the child field at render time, which the spec cannot see, and - * `objectui validate` cannot either (a subform's `columns` is - * `z.array(z.any())`, and the child object's fields are not in the document). + * `type` from the child field at render time. The spec's column schema cannot + * see that, and `objectui validate` cannot either: it judges a subform's + * `columns` with that same schema (objectui#11266), and the child object's + * fields are not in the document. Only `defineStack` refuses it, at publish, by + * resolving `name` against the child object the stack declares + * (objectstack-ai/objectstack#20927); nothing in objectui does. * `GridField`'s `currencyWidth` no longer reads a currency column's `scale`, so * without a report here the key would be accepted everywhere and read by * nothing. @@ -69,7 +72,7 @@ describe('hydrateColumns reports a `scale` on a column that hydrates to currency expect(computeRow(cols, { quantity: 3, unit_price: 1.2345 }, 'KWD').amount).toBe(3.704); }); - it('a declared currency column carrying `scale` is reported too (the form-view `subforms[].columns` path the spec does not judge)', () => { + it('a declared currency column carrying `scale` is reported too (a stored or code-built form view reaches the render without passing either validator)', () => { const warnings = spyWarn(); hydrateColumns([{ name: 'amount', type: 'currency', computed: true, expr: 'quantity * unit_price', scale: 2 }], lineSchema('line_declared')); const reports = warnings().filter((m) => m.includes('`scale`')); diff --git a/packages/plugin-form/src/deriveMasterDetail.ts b/packages/plugin-form/src/deriveMasterDetail.ts index 90aa44c9e6..a8519172c2 100644 --- a/packages/plugin-form/src/deriveMasterDetail.ts +++ b/packages/plugin-form/src/deriveMasterDetail.ts @@ -296,14 +296,22 @@ export function deriveColumns( * docblock says the reach stops there: an identity-only entry * (`{ name: 'amount', scale: 2 }`) takes its type from the child field at * render time, here, so it still parses. `objectui validate` cannot see it - * either: a subform's `columns` is `z.array(z.any())` in the `object-form` - * mirror, and the child object's fields are not in the document it judges. - * This is the first place the child field is known, so the report is made - * here — ⛔ never a silent drop. + * either: the `object-form` mirror judges a subform's `columns` with the spec's + * own `InlineGridColumnSchema` (objectui#11266), and the child object's fields + * are not in the document it judges. Only `defineStack` refuses it, at publish, by resolving + * `name` against the child object's fields: on a relationship field's + * `inlineColumns` and on a form view's `subforms[].columns`, and only when the + * stack declares that child object (objectstack-ai/objectstack#20927). Nothing + * in objectui does that, and this is the first place in objectui the child + * field is known, so the report is made here — ⛔ never a silent drop. * - * The declared arm is reported too. A form view's `subforms[].columns` is - * `z.array(z.any())` in the spec's `FormViewSchema`, so a typed currency column - * carrying `scale` reaches this function unjudged on that path. + * The declared arm is reported too. On a form view's `subforms[].columns` both + * validators refuse a typed currency column carrying `scale`: the spec's + * `FormViewSchema` holds each column to `InlineGridColumnSchema` from 17.6.0, + * and the `object-form` mirror takes that schema by reference (objectui#11266). + * Neither runs between a stored or code-built form view and this function, so + * such a column can still arrive here, and it is reported rather than read in + * silence. * * Once per column per page load (the `sectionFields.ts` convention): this runs * on every child-schema resolve. The first sentence is the spec's refusal with