diff --git a/.changeset/10872-held-public-block-arms.md b/.changeset/10872-held-public-block-arms.md index 956bc754cc..f74d883707 100644 --- a/.changeset/10872-held-public-block-arms.md +++ b/.changeset/10872-held-public-block-arms.md @@ -24,3 +24,5 @@ - `record:line_items` is still refused at `type`: the spec has no row for it yet. ⚠️ **Dated note, 2026-10-01 — props written flat on the node — objectui#10872 batch 10.** Later in this same release every member of a public block's row written directly on the node is refused by name on both faces, at its own path, with a message naming `properties.KEY`. So the bullet that says the tolerant face passes such a key unjudged, and that the flat `{ "type": "action:button", "label": "Open details", "actionType": "url", "target": "/users/ada" }` "validates through `objectui validate`", no longer describes the release as a whole: `objectui validate` refuses its `actionType` and `target` by name, as the strict face and `@objectstack/spec`'s page component do, and keeps the node-level `label`, which the spec's page component also declares. The bag spelling still passes both faces. The rest of this entry is kept as the reading of this change. + +⚠️ **Dated note, 2026-10-03 — `record:line_items` armed — objectui#10872.** Later in this same release `@objectstack/spec` 17.6.0 carries a `ComponentPropsMap` row for `record:line_items`, and the block is armed from that row (`.changeset/10872-record-line-items-arm.md`). So the bullet "`record:line_items` is still refused at `type`: the spec has no row for it yet" no longer describes the release as a whole. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/10872-public-block-zod-arms.md b/.changeset/10872-public-block-zod-arms.md index e75af83648..51a388981c 100644 --- a/.changeset/10872-public-block-zod-arms.md +++ b/.changeset/10872-public-block-zod-arms.md @@ -25,3 +25,5 @@ ⚠️ **Dated note, 2026-09-30 — the four `page:` containers' child list — objectui#10872 batch 6.** Later in this same release the arms of `page:card`, `page:section`, `page:footer` and `page:sidebar` refuse a node-level `children` and restate `body`, both with a message that names `properties.children`, the member their spec row declares (`.changeset/10872-container-children-channel.md`). So the bullet on a prop written directly on the node, where it says `children` (`page:card`, `page:section`, `page:footer`, `page:sidebar`) is judged against the base's own type and `body` (`page:card`) is refused by the base's objectui#6771 retirement, no longer describes the release as a whole: on those four, both keys are refused by the arm's own refusal. The rest of this entry is kept as the reading of this change. ⚠️ **Dated note, 2026-10-01 — props written flat on the node — objectui#10872 batch 10.** Later in this same release every member of a public block's row written directly on the node is refused by name on both faces, at its own path, with a message naming `properties.KEY`. So the bullet on a prop written directly on the node, where it says such a key "is not judged against the block's row", that `{ "type": "record:details", "columns": "2" }` "passes the tolerant face", and that `disabled` (`element:button`) and `visible` (`record:alert`) are judged against the base's own types, no longer describes the release as a whole: all of those are refused, on both faces, and the message names the bag member. `label` (`element:button`) is the exception, because `@objectstack/spec`'s page component declares a node-level `label` of its own. The rest of this entry is kept as the reading of this change. + +⚠️ **Dated note, 2026-10-03 — `record:line_items` armed — objectui#10872.** Later in this same release `@objectstack/spec` 17.6.0 carries a `ComponentPropsMap` row for `record:line_items`, and the block is armed from that row (`.changeset/10872-record-line-items-arm.md`). So the "Not armed in this release" bullet's `record:line_items` item ("the spec has no row for it yet"), and the 2026-09-30 note's "only `record:line_items` is still refused at `type`", no longer describe the release as a whole: none of the held public blocks is still refused at `type`. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/10872-record-line-items-arm.md b/.changeset/10872-record-line-items-arm.md new file mode 100644 index 0000000000..e478f88ef8 --- /dev/null +++ b/.changeset/10872-record-line-items-arm.md @@ -0,0 +1,17 @@ +--- +'@object-ui/types': minor +--- + +`safeValidateSchema`, and so `objectui validate`, accepts `record:line_items`, the last ADR-0080 public block it refused at `type` (objectui#10872). `@objectstack/spec` 17.6.0 carries the block's `ComponentPropsMap` row, measured at the reads of `LineItemsPanel`, and the arm reads that row by reference. + +**Clause-②: yes.** The accept set of `AnyComponentSchema` widens by one `type` literal, and `@object-ui/types/zod` exports one new schema, `RecordLineItemsBlockSchema`. Nothing that parsed before is refused now: every document naming `record:line_items` was refused before. + +**What it was.** `record:line_items` is registered by `@object-ui/plugin-form` and curated as platform contract by ADR-0080, and `AnyComponentSchema` carried no arm for it. Every document naming it was refused with `invalid_union` at `type`, whatever the rest of the node said. The arm was held until the spec carried a row for the block. + +**What changed, in observable terms.** + +- `@object-ui/types/zod` exports `RecordLineItemsBlockSchema`, a member of `PublicBlockComponentSchema`. The strict authoring face (`StrictAnyComponentSchema`) accepts the same documents and is closed to undeclared keys, as on every other arm. +- The block's props live in `properties`, and the bag is the spec row: its members, value types and strictness are the spec's. `relationshipField` and `columns` are required there, and `childObject` is optional. A key the row does not declare is refused inside the bag, by name. +- A row member written on the node instead of in `properties` is refused by name on both faces, at its own path, with a message naming `properties.KEY`, as on every other public block. +- The node's `dataSource` binding is declared as the spec's `ElementDataSourceSchema`, read by reference. The block's registration wraps `ElementDataSourceGate`, which puts `dataSource.object` into `childObject`, so a bound node without `childObject` passes both faces. A binding that the spec refuses is refused at `dataSource`. +- A node-level `children` or `body` is refused by name on both faces. The block renders the grid of child records and reads neither key, and `@objectstack/spec`'s page component refuses a node-level `children` on it too. diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts index 1397cab69f..324c6a1eba 100644 --- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts +++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -141,9 +141,11 @@ const REFUSED_AT_TYPE = 0; * it. objectui#11440 armed seven bare keys and none of their namespaced twins * (`ARMED_11440` maps each to its twins), so its first pull request did not * move this pin; its second took it down by one, `plugin-report:spec-report`, - * the namespaced twin of the `spec-report` key it unregistered. + * the namespaced twin of the `spec-report` key it unregistered. objectui#10872: + * minus `record:line_items`, armed from the row `@objectstack/spec` 17.6.0 + * gave it, the last public block held. */ -const NAMESPACED_REFUSED_AT_TYPE = 361; +const NAMESPACED_REFUSED_AT_TYPE = 360; /** The bare registry keys — the population the card measured. */ const BARE_KEYS = KNOWN_SCHEMA_TYPES.filter((key) => !key.includes(':')); @@ -170,6 +172,9 @@ const ARMED_PUBLIC_BLOCKS_10872_BATCH_4 = [ 'action:button', 'action:icon', 'action:group', 'action:menu', 'element:definition-list', 'element:repeater', ] as const; +/** The last public block objectui#10872 held, armed once `@objectstack/spec` 17.6.0 carried its row. */ +const ARMED_PUBLIC_BLOCKS_10872_LINE_ITEMS = ['record:line_items'] as const; + /** * The thirty bare keys objectui#10859 batch 8 RETIRED (the seat's ruling on the * card), named so the row below says which keys left the population and how: @@ -549,6 +554,16 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet } }); + it('counts `record:line_items` armed — the last public block held (objectui#10872)', () => { + for (const key of ARMED_PUBLIC_BLOCKS_10872_LINE_ITEMS) { + expect(NAMESPACED_KEYS, key).toContain(key); + expect(refusedAtType(key), key).toBe(false); + } + // Registered with `skipFallback: true`, so the namespaced key is its only + // spelling: it left the namespaced count and no bare twin moved. + expect(BARE_KEYS).not.toContain('line_items'); + }); + it('counts `cloud:plan-status` armed — it registered WITH its arm (objectui#10919)', () => { // One registry key (`skipFallback: true`, so no bare `plan-status`), armed in // `@object-ui/types/zod` in the same change, so the pin above did not move. diff --git a/packages/types/src/__tests__/any-component-union-fanout.test.ts b/packages/types/src/__tests__/any-component-union-fanout.test.ts index 92264ac057..370406be76 100644 --- a/packages/types/src/__tests__/any-component-union-fanout.test.ts +++ b/packages/types/src/__tests__/any-component-union-fanout.test.ts @@ -27,6 +27,18 @@ * unknown `type` refusal 14,855 -> 2,178 chars * refused node 4 deep 19,311 -> 4,330 chars * + * ⚠️ objectui#10872 moved the unknown-`type` bound, and its reading is added here + * rather than written over the one above. The discriminated refusal's message + * carries the discriminator's `options` list, which names every `type` literal + * the union claims, so the whole message grows by one literal per arm that + * lands: 2,178 chars when the row above was taken, 4,007 at 204 literals, past + * the 4,000 bound. So that bound now reads the message LESS its `options` list. + * The flat union carries no `options` at all. Same document, zod 4.6.5, a flat + * `z.union` over the 20 members `AnyComponentSchema` discriminates today (with + * its `Invalid input` message map) -> the discriminated union: + * + * unknown `type` refusal, less `options` 14,126 -> 193 chars (20 arm lists -> none) + * * A bound that also passed on the flat union would assert nothing, which is the * failure mode this card was most exposed to. ⚠️ The reason it was exposed has * since changed and this paragraph is corrected in place rather than deleted: @@ -75,6 +87,28 @@ function issueNodeCount(issues: readonly { errors?: readonly (readonly unknown[] }, 0); } +/** + * A refusal's message with the discriminator's `options` list taken out + * (objectui#10872). Zod builds the message as the issue list printed as JSON + * with a two-space indent, so it is parsed back, each issue's `options` is + * dropped, and the rest is printed the same way. The round trip is asserted + * first, so the reading is of the message itself and not of a re-serialisation + * that drifted from it. + */ +function messageLessOptions(message: string): string { + const issues = JSON.parse(message) as Record[]; + expect(JSON.stringify(issues, null, 2)).toBe(message); + return JSON.stringify( + issues.map((issue) => { + const rest = { ...issue }; + delete rest.options; + return rest; + }), + null, + 2, + ); +} + /** The `type` literals a schema declares to Zod's discriminator dispatch. */ function literalsOf(schema: unknown): string[] { const values = (schema as { _zod?: { propValues?: { type?: Set } } })._zod?.propValues @@ -153,7 +187,12 @@ describe('AnyComponentSchema — a refusal costs one arm, not every arm', () => const result = AnyComponentSchema.safeParse(FOREIGN_DOCUMENT); expect(result.success).toBe(false); if (result.success) return; - expect(result.error.message.length).toBeLessThanOrEqual(4_000); + // objectui#10872: the bound is on the message LESS the discriminator's + // `options` list. That list grows by one literal with every arm that lands, + // by design, while the rest of the message does not; the flat union carries + // no `options` and reads far above the bound (the docblock's readings), so + // the assertion still tells the two shapes apart. + expect(messageLessOptions(result.error.message).length).toBeLessThanOrEqual(4_000); // The literals stay ON the issue, which is where `@object-ui/cli`'s // `union-arm-diagnostics` reads them to build the CAPPED candidate list the // 2026-09-02 maintainer ruling requires. Only the default MESSAGE — which diff --git a/packages/types/src/__tests__/flat-props-refusal-10872.test.ts b/packages/types/src/__tests__/flat-props-refusal-10872.test.ts index 5712cfdd5d..91177b0d5f 100644 --- a/packages/types/src/__tests__/flat-props-refusal-10872.test.ts +++ b/packages/types/src/__tests__/flat-props-refusal-10872.test.ts @@ -146,6 +146,14 @@ const VALID_BAG: Readonly>> = { 'record:quick_actions': { actionNames: ['edit'] }, 'record:reference_rail': { entries: [{ objectName: 'contact', relationshipField: 'account' }] }, 'record:alert': { severity: 'warning', title: 'Overdue', visible: true }, + 'record:line_items': { + childObject: 'order_line', + relationshipField: 'order', + columns: [{ name: 'qty', type: 'number' }], + title: 'Lines', + readonly: false, + limit: 50, + }, 'element:text': { content: 'Hello' }, 'element:number': { object: 'order', aggregate: 'count' }, 'element:button': { label: 'Go', disabled: true }, diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 69f7e04f9b..fe02078f24 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -115,6 +115,8 @@ import { RecordQuickActionsProps as SpecRecordQuickActionsProps, RecordReferenceRailProps as SpecRecordReferenceRailProps, RecordAlertProps as SpecRecordAlertProps, + // objectui#10872 — the row `@objectstack/spec` 17.6.0 carries. + RecordLineItemsProps as SpecRecordLineItemsProps, ElementTextPropsSchema as SpecElementTextPropsSchema, ElementButtonPropsSchema as SpecElementButtonPropsSchema, // objectui#10872 batch 4 — the six rows `@objectstack/spec` 17.5.0 carries. @@ -329,6 +331,9 @@ const IMPORTED: Array = [ ['RecordQuickActionsProps', SpecRecordQuickActionsProps], ['RecordReferenceRailProps', SpecRecordReferenceRailProps], ['RecordAlertProps', SpecRecordAlertProps], + // objectui#10872: the `record:line_items` arm reads its row as its + // `properties` bag, crossed through this boundary like every other row. + ['RecordLineItemsProps', SpecRecordLineItemsProps], ['ElementTextPropsSchema', SpecElementTextPropsSchema], ['ElementButtonPropsSchema', SpecElementButtonPropsSchema], // objectui#10872 batch 4: the six held blocks' arms read their rows as their diff --git a/packages/types/src/__tests__/public-block-arms-10872.test.ts b/packages/types/src/__tests__/public-block-arms-10872.test.ts index 003ebd8c51..0fbb1b0d1b 100644 --- a/packages/types/src/__tests__/public-block-arms-10872.test.ts +++ b/packages/types/src/__tests__/public-block-arms-10872.test.ts @@ -65,6 +65,9 @@ const VALID_BAG: Readonly>> = { 'record:quick_actions': { actionNames: ['edit'] }, 'record:reference_rail': { entries: [{ objectName: 'contact', relationshipField: 'account' }] }, 'record:alert': { severity: 'warning', title: 'Overdue' }, + // objectui#10872 — the row `@objectstack/spec` 17.6.0 carries; its own + // readings are pinned in `./record-line-items-arm-10872.test.ts`. + 'record:line_items': { childObject: 'order_line', relationshipField: 'order', columns: [{ name: 'qty' }] }, 'element:text': { content: 'Hello' }, // objectui#10872 batch 2 — the bag that names its own `object`; the // `dataSource` waiver is pinned in `./element-number-arm-10872.test.ts`. diff --git a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts index 926ca64bac..827f12ee4b 100644 --- a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts +++ b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts @@ -126,6 +126,10 @@ const DECLARES_DATA_SOURCE: ReadonlySet = new Set([ // node level, beside their bags. 'object-pivot', 'embeddable-form', + // objectui#10872: the gate-wrapped `record:line_items`, armed with the binding + // at node level beside its bag — `ElementDataSourceGate` lands + // `dataSource.object` on the row's optional `childObject`. + 'record:line_items', ]); /** diff --git a/packages/types/src/__tests__/record-line-items-arm-10872.test.ts b/packages/types/src/__tests__/record-line-items-arm-10872.test.ts new file mode 100644 index 0000000000..4005ebf48a --- /dev/null +++ b/packages/types/src/__tests__/record-line-items-arm-10872.test.ts @@ -0,0 +1,226 @@ +/** + * 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. + */ + +/** + * `record:line_items` has a zod arm (objectui#10872): the last ADR-0080 public + * block held, armed once `@objectstack/spec` 17.6.0 carried its + * `ComponentPropsMap` row. + * + * ## The defect these pin + * + * `record:line_items` is registered (`@object-ui/plugin-form`), curated in + * `PUBLIC_BLOCKS` and, since 17.6.0, declared by the spec. `AnyComponentSchema` + * had no arm for it, so `safeValidateSchema` (what `objectui validate` runs) + * and the strict authoring face refused every document naming it with one + * `invalid_union` at `type`, whatever the rest of the node said. + * + * ## What is pinned here, and what elsewhere + * + * The batch-1 rows read the arm as they read every public block: + * `./public-block-arms-10872.test.ts` (the bag is the row, by reference; the + * minimal and spec-valid documents pass both faces), and + * `./flat-props-refusal-10872.test.ts` (each row member written flat is + * refused by name). This file carries what this block adds: the row's own + * requiredness through the arm, the node's `dataSource` binding, and the two + * content-channel refusals. Every verdict is held to the INSTALLED spec's own + * answer, read at run time. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { + ComponentPropsMap, + ElementDataSourceSchema, + PageComponentSchema, + type RecordDetailsProps, + type RecordLineItemsProps, +} from '@objectstack/spec/ui'; + +import { + PublicBlockComponentSchema, + RecordLineItemsBlockSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; +import type { RecordLineItemsBlockSchemaType } from '../zod/public-blocks.zod.js'; +import { stripImportedDefaults } from '../zod/imported-defaults.js'; + +type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[] }; +type Result = { success: boolean; error?: { issues: z.core.$ZodIssue[] } }; + +const TYPE = 'record:line_items'; + +const FACES: ReadonlyArray Result]> = [ + ['tolerant', (document) => safeValidateSchema(document)], + ['strict', (document) => StrictAnyComponentSchema.safeParse(document)], +]; + +/** The spec's row for this block, as the published map carries it. */ +const ROW = (ComponentPropsMap as unknown as Record)[TYPE]; + +/** The smallest bag the row accepts: its two required members. */ +const MINIMAL_BAG = { relationshipField: 'order', columns: [{ name: 'qty' }] } as const; + +const issuesOf = (result: Result): Issue[] => (result.success ? [] : (result.error!.issues as unknown as Issue[])); +const pathsOf = (result: Result): string[] => issuesOf(result).map((issue) => issue.path.join('.')); +const refusedAtType = (result: Result): boolean => + issuesOf(result).some((issue) => issue.code === 'invalid_union' && issue.path.join('.') === 'type'); + +describe('`record:line_items` validates by its spec row (objectui#10872)', () => { + it('the row exists in the installed spec — the arm has something to read', () => { + expect(ROW, 'ComponentPropsMap carries no record:line_items row').toBeDefined(); + expect(ROW.safeParse(MINIMAL_BAG).success).toBe(true); + }); + + it.each(FACES)('%s face: `type` is claimed — a bare node and the minimal bag both parse', (_face, judge) => { + for (const document of [{ type: TYPE }, { type: TYPE, properties: { ...MINIMAL_BAG } }]) { + const result = judge(document); + expect(refusedAtType(result), JSON.stringify(document)).toBe(false); + expect(result.success, JSON.stringify(document)).toBe(true); + } + }); + + it.each(FACES)('%s face: the row\'s two required members are required in the bag, at their own path', (_face, judge) => { + for (const missing of ['relationshipField', 'columns'] as const) { + const bag: Record = { ...MINIMAL_BAG }; + delete bag[missing]; + // The spec's own row refuses the same bag — the requiredness is the row's. + expect(ROW.safeParse(bag).success, `row without ${missing}`).toBe(false); + const result = judge({ type: TYPE, properties: bag }); + expect(result.success, missing).toBe(false); + expect(pathsOf(result), missing).toContain(`properties.${missing}`); + } + }); + + it.each(FACES)('%s face: `childObject` is optional, as the row declares it', (_face, judge) => { + expect(ROW.safeParse(MINIMAL_BAG).success).toBe(true); + expect(judge({ type: TYPE, properties: { ...MINIMAL_BAG } }).success).toBe(true); + // Control: a wrong-typed `childObject` is the row's to refuse, at the member. + const result = judge({ type: TYPE, properties: { ...MINIMAL_BAG, childObject: 7 } }); + expect(result.success).toBe(false); + expect(pathsOf(result)).toContain('properties.childObject'); + }); + + it.each(FACES)('%s face: a member the row does not declare is refused inside the bag, by name', (_face, judge) => { + const result = judge({ type: TYPE, properties: { ...MINIMAL_BAG, inventedKey10872: true } }); + expect(result.success).toBe(false); + const issue = issuesOf(result).find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path.join('.')).toBe('properties'); + expect(issue?.keys).toEqual(['inventedKey10872']); + expect(ROW.safeParse({ ...MINIMAL_BAG, inventedKey10872: true }).success).toBe(false); + }); + + it.each(FACES)('%s face: a row member written flat is refused by name, toward its bag member', (_face, judge) => { + const result = judge({ type: TYPE, ...MINIMAL_BAG }); + expect(result.success).toBe(false); + for (const key of ['relationshipField', 'columns']) { + const issue = issuesOf(result).find((i) => i.path.join('.') === key); + expect(issue?.code, key).toBe('invalid_type'); + expect(issue?.message, key).toContain(`properties.${key}`); + } + }); +}); + +describe('`record:line_items` declares the node\'s `dataSource` binding, by reference (objectui#10872)', () => { + /** A bound node with no `childObject` — the case the row makes `childObject` optional for. */ + const BOUND = { type: TYPE, dataSource: { object: 'order_line' }, properties: { ...MINIMAL_BAG } }; + + it('the spec\'s own page component accepts the bound node (lit control)', () => { + expect(PageComponentSchema.safeParse(BOUND).success).toBe(true); + }); + + it.each(FACES)('%s face: the bound node with no `childObject` parses', (_face, judge) => { + expect(judge(BOUND).success).toBe(true); + }); + + it('the member is the spec\'s `ElementDataSourceSchema`, through the import boundary', () => { + const member = (RecordLineItemsBlockSchema.shape.dataSource as unknown as z.ZodOptional).unwrap(); + const spec = ElementDataSourceSchema as unknown as z.ZodType; + // The boundary has nothing to strip in this schema, so it hands back the + // spec's own object; the spec publishes it as a lazy proxy, so the member + // is compared by DEFINITION — the object behind the proxy, not a copy. + expect(stripImportedDefaults(spec)).toBe(spec); + expect((member as unknown as { _zod: { def: unknown } })._zod.def) + .toBe((spec as unknown as { _zod: { def: unknown } })._zod.def); + }); + + it.each(FACES)('%s face: a binding the spec refuses is refused at `dataSource`', (_face, judge) => { + for (const dataSource of ['order_line', { object: 'order_line', objectName: 'order_line' }, { object: 'o', limit: 0 }]) { + const document = { ...BOUND, dataSource }; + // The spec refuses the same binding — the verdict is the spec's, not this face's. + expect(PageComponentSchema.safeParse(document).success, JSON.stringify(dataSource)).toBe(false); + const result = judge(document); + expect(result.success, JSON.stringify(dataSource)).toBe(false); + expect(pathsOf(result).some((path) => path === 'dataSource' || path.startsWith('dataSource.')), JSON.stringify(dataSource)) + .toBe(true); + } + }); +}); + +describe('`record:line_items` refuses both content channels, by name (objectui#10872, the objectui#9256 method)', () => { + const CONTENT = [{ type: 'element:text', properties: { content: 'measured' } }]; + + it.each(['children', 'body'] as const)('`%s`: refused at its own path on both faces, naming what the block renders', (key) => { + for (const [face, judge] of FACES) { + const result = judge({ type: TYPE, properties: { ...MINIMAL_BAG }, [key]: CONTENT }); + expect(result.success, face).toBe(false); + const issue = issuesOf(result).find((i) => i.path.join('.') === key); + expect(issue?.code, face).toBe('invalid_type'); + expect(issue?.message, face).toContain('`record:line_items` reads NEITHER content channel'); + expect(issue?.message, face).toContain('LineItemsPanel'); + } + }); + + it('both members carry ONE string, and it is their `.describe()` text', () => { + const shape = RecordLineItemsBlockSchema.shape as unknown as Record; + expect(shape.children.description).toBeTruthy(); + expect(shape.body.description).toBe(shape.children.description); + }); + + it('the spec\'s own page component refuses a node-level `children` on this block (lit control)', () => { + const spec = PageComponentSchema.safeParse({ type: TYPE, properties: { ...MINIMAL_BAG }, children: CONTENT }); + expect(spec.success).toBe(false); + expect(issuesOf(spec as Result).flatMap((issue) => issue.keys ?? [])).toContain('children'); + }); +}); + +/* + * The arm's TYPE is named (`RecordLineItemsBlockSchemaType`) so declaration + * emit prints it by reference; inlined, it tipped `AnyComponentSchema` past + * TypeScript's serialization ceiling (TS7056) against `@objectstack/spec` built + * from objectstack `main`. These compile-time rows pin that the naming changed + * no type a consumer reads: the arm's input and output are the spec row's and + * the spec binding's, read by reference. `tsc -p tsconfig.test.json` (the + * package's `type-check`) is what evaluates them. + */ +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; + +type ArmIn = z.input; +type ArmOut = z.output; + +type _TypeLiteral = Assert>; +type _BagInputIsTheRow = Assert | undefined>>; +type _BagOutputIsTheRow = Assert | undefined>>; +type _BindingInputIsTheSpecs = Assert | undefined>>; +type _BindingOutputIsTheSpecs = Assert | undefined>>; +// The union carries the named type, so the derived node types read it. +type _UnionMemberIsTheNamedType = Assert< + Equal, RecordLineItemsBlockSchemaType> +>; +// Lit control: the equality distinguishes two rows, so the rows above can fail. +type _ControlAnotherRowIsNotEqual = Assert< + Equal | undefined> extends true ? false : true +>; + +describe('`record:line_items` — the named arm type is the arm (objectui#10872)', () => { + it('the export is the very object the union carries', () => { + const options = (PublicBlockComponentSchema as unknown as { options: unknown[] }).options; + expect(options).toContain(RecordLineItemsBlockSchema); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 960d91219c..7a3fc6aa23 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4761,6 +4761,8 @@ const EXCLUSIONS: Readonly> = { "spec-owned BY REFERENCE — `BaseSchema` + the `record:reference_rail` literal + `properties`, which IS `ComponentPropsMap['record:reference_rail']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#RecordAlertBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `record:alert` literal + `properties`, which IS `ComponentPropsMap['record:alert']`, + the objectui#9256 `children` refusal, + the objectui#10872 `body` alias refusal naming `properties.body` (its `body` is a text prop, so no neither-channel tombstone); no TS declaration in this package restates the node", + 'public-blocks.zod.ts#RecordLineItemsBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `record:line_items` literal + `properties`, which IS `ComponentPropsMap['record:line_items']`, + `dataSource`, which IS the spec's `ElementDataSourceSchema`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'public-blocks.zod.ts#ElementTextBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `element:text` literal + `properties`, which IS `ComponentPropsMap['element:text']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", // objectui#10872 batch 2 — the one arm with a node-level refinement: the diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 01ffab2976..6d1ea4d0dc 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -496,6 +496,7 @@ export { RecordQuickActionsBlockSchema, RecordReferenceRailBlockSchema, RecordAlertBlockSchema, + RecordLineItemsBlockSchema, ElementTextBlockSchema, ElementNumberBlockSchema, ElementButtonBlockSchema, diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index 676bc8189e..f42de7e8e8 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -21,7 +21,7 @@ * ## Why this module exists (objectui#10872) * * Every one of these types is REGISTERED (`@object-ui/components`, - * `@object-ui/plugin-detail`), CURATED as platform contract by ADR-0080 and + * `@object-ui/plugin-detail`, `@object-ui/plugin-form`), CURATED as platform contract by ADR-0080 and * DECLARED by the spec, while `AnyComponentSchema` carried no arm for any of * them — so `safeValidateSchema` and `objectui validate` refused every document * naming one with `invalid_union` at `type`, and a page built from the public @@ -51,8 +51,9 @@ * `handlerKeyRefusal` where it is a runtime slot (`page:tabs`'s `onTabChange`, * the `action:button` / `action:icon` `onClick`), or refused with an alias * refusal naming its bag member where the row declares it (the same two - * blocks' `onSuccess`, below); `element:number`'s `dataSource`, the spec's own - * binding schema by reference (below); the content-channel refusals (next + * blocks' `onSuccess`, below); the `dataSource` of `element:number` and + * `record:line_items`, the spec's own binding schema by reference (below); the + * content-channel refusals (next * section) — the tombstones, and `record:alert`'s flat-`body` alias refusal; * and the host feed slots of `record:activity` (`items`) and `record:history` * (`entries`), each with the `loading` flag paired with it, refused by name as @@ -167,14 +168,13 @@ * spec's `ElementDataSourceSchema` read by reference — the same schema * `PageComponentSchema.dataSource` is. * - * ## The public blocks NOT armed here, and why + * ## The public blocks once held, and why they are armed now * - * Held by objectui#10872 with the evidence on that card — each is a reading, - * not an oversight: - * - * - `record:line_items` — the spec carries no row, on purpose: its - * `STRING_ARM_REGISTERED_TYPES` ledger records the row as still to be - * measured from the renderer's read points. + * `record:line_items` was held until `@objectstack/spec` 17.6.0 carried its + * row: before that the spec's `STRING_ARM_REGISTERED_TYPES` ledger recorded the + * row as still to be measured from the renderer's read points. It is armed + * below from that row (objectui#10872), with the node's `dataSource` binding + * beside it; its arm's docblock says why. * * The six held until `@objectstack/spec` 17.5.0 — `action:button`, * `action:group`, `action:menu`, `action:icon`, `element:definition-list` and @@ -210,6 +210,7 @@ import { RecordQuickActionsProps as SpecRecordQuickActionsProps, RecordReferenceRailProps as SpecRecordReferenceRailProps, RecordAlertProps as SpecRecordAlertProps, + RecordLineItemsProps as SpecRecordLineItemsProps, ElementTextPropsSchema as SpecElementTextPropsSchema, ElementNumberPropsSchema as SpecElementNumberPropsSchema, ElementButtonPropsSchema as SpecElementButtonPropsSchema, @@ -297,8 +298,8 @@ export function propsBag(type: string, row: T, description? * * ⛔ The other five stay undeclared (no producer; release condition "additive, * when a producer needs them", objectui#10872). `dataSource` is declared per - * arm where a renderer reads the binding (`element:number`, and the ObjectQL - * blocks), not here. ⛔ Not on `BaseSchema`: that widens every arm of + * arm where a renderer reads the binding (`element:number`, + * `record:line_items`, and the ObjectQL blocks), not here. ⛔ Not on `BaseSchema`: that widens every arm of * `AnyComponentSchema`, a different accept-set change from the arms above, * each of which has a measured producer. * @@ -1012,6 +1013,103 @@ export const RecordAlertBlockSchema = BaseSchema.extend({ children: retirementTombstone(RECORD_ALERT_NO_CHILD_LIST), }); +/** objectui#10872 (the objectui#9256 method): ONE refusal string for both content channels of `record:line_items`. */ +const RECORD_LINE_ITEMS_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'record:line_items', + 'its registration (`record:line_items`, `@object-ui/plugin-form`) hands the node to `LineItemsPanelRenderer`, ' + + 'which passes it through `ElementDataSourceGate` (the node\'s `dataSource` binding) to `LineItemsPanel`, ' + + 'which reads the line-items keys (`childObject`, `relationshipField`, `columns`, `parentObject`, ' + + '`parentId`, `recordId`, `amountField`, `totalField`, `title`, `readonly`, `minRows`, `maxRows`, ' + + '`filter`, `sort`, `limit`)', + 'an editable grid of the `childObject` records that point at the parent record through ' + + '`relationshipField`, drawn in `columns`', +); + +/** + * `record:line_items` — `ComponentPropsMap['record:line_items']` + * (objectui#10872), plus the node's `dataSource` binding. + * + * `@objectstack/spec` 17.6.0 carries the row, measured at `LineItemsPanel`'s + * reads, and its `STRING_ARM_REGISTERED_TYPES` ledger no longer holds the + * block. The row requires `relationshipField` and `columns`, because nothing + * derives them, and leaves `childObject` optional, because the node's + * `dataSource` binding can supply it: the registration is gate-wrapped, and + * `ElementDataSourceGate` lands `dataSource.object` on `childObject` before the + * panel reads the node. So the arm declares `dataSource` as the spec's + * `ElementDataSourceSchema`, by reference, as `element:number` and the + * gate-wrapped ObjectQL blocks do. Left undeclared, it would be refused by the + * strict face as an unrecognized key, which is the binding the row makes + * `childObject` optional for. + * + * No refinement puts `childObject`'s requiredness back. The row leaves it + * optional outright, and the spec's props gate waives no member of this row + * (its one `dataSource` waiver is `element:number`'s `object`), so a bag with + * neither `childObject` nor a binding is the spec's verdict to change, not this + * arm's. + * + * Its content is the grid of child records; the node's own `children` and + * `body` are refused (the objectui#9256 method). + * + * The arm's TYPE is named (`RecordLineItemsBlockSchemaType`, below) so + * declaration emit prints it by name; the arm itself is built here. + */ +const RecordLineItemsBlockArm = BaseSchema.extend({ + type: z.literal('record:line_items'), + ...NODE_ENVELOPE, + // objectui#10872 batch 10: a row member written flat on the node is refused by name, toward `properties.KEY`. + ...flatPropRefusals('record:line_items', stripImportedDefaults(SpecRecordLineItemsProps)), + properties: propsBag('record:line_items', stripImportedDefaults(SpecRecordLineItemsProps)), + dataSource: stripImportedDefaults(SpecElementDataSourceSchema) + .optional() + .describe( + 'Per-element data binding — `@objectstack/spec` `ElementDataSourceSchema`, the schema ' + + '`PageComponentSchema.dataSource` declares, by reference. `ElementDataSourceGate` lands its `object` ' + + 'on `childObject`, which is why the row leaves `childObject` optional.', + ), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER (see "The content channels" above). + body: retirementTombstone(RECORD_LINE_ITEMS_NEITHER_CHANNEL), + children: retirementTombstone(RECORD_LINE_ITEMS_NEITHER_CHANNEL), +}); + +/** The inferred type of the `record:line_items` arm: what `RecordLineItemsBlockSchemaType` extends. */ +type RecordLineItemsBlockArmType = typeof RecordLineItemsBlockArm; + +/** + * The type of `RecordLineItemsBlockSchema`, NAMED so that declaration emit + * prints a reference to it instead of the arm's whole inferred type. + * + * ## Why (objectui#10872) + * + * `tsc` prints an inferred type in full wherever a declaration uses it. So + * `AnyComponentSchema`'s declaration in `index.zod.d.ts` spells out every + * arm of every union it lists. That declaration sits close to the compiler's + * serialization ceiling. Measured on objectstack main 5c9138b, this arm's own + * serialized size is the same as against 17.6.0; what grew is the rest of + * `AnyComponentSchema`, which left too little room under the ceiling for the + * arm inline. Printed inline, it made `@object-ui/types` fail with TS7056 at + * `AnyComponentSchema` and emit no declarations; the Spec Main Shape Gate + * caught it. An interface is printed by name, so naming the arm's type takes + * its body out of the union's print: it is printed once, here. The family and + * the remaining headroom are tracked in objectui#11573. + * + * ## Why the TYPE is unchanged + * + * It extends the arm's own inferred type and adds no member. `shape` is + * restated as that type's own `shape`, read by reference, only because + * `@typescript-eslint/no-empty-object-type` refuses an empty interface. The + * value is the arm itself, so the runtime object, its parse results and the + * strict face derived from it are the arm's. + * `../__tests__/record-line-items-arm-10872.test.ts` pins the input and + * output types against the spec row and binding they are read from. + */ +export interface RecordLineItemsBlockSchemaType extends RecordLineItemsBlockArmType { + shape: RecordLineItemsBlockArmType['shape']; +} + +/** `record:line_items` — `ComponentPropsMap['record:line_items']`; see the arm's docblock above. */ +export const RecordLineItemsBlockSchema: RecordLineItemsBlockSchemaType = RecordLineItemsBlockArm; + /* ── element: — content blocks ──────────────────────────────────────────── */ /** objectui#9256 (public-block slice): ONE refusal string for both content channels of `element:text`. */ @@ -1474,6 +1572,7 @@ export const PublicBlockComponentSchema = z.discriminatedUnion('type', [ RecordQuickActionsBlockSchema, RecordReferenceRailBlockSchema, RecordAlertBlockSchema, + RecordLineItemsBlockSchema, ElementTextBlockSchema, ElementNumberBlockSchema, ElementButtonBlockSchema,