From b9a8a4019bbb2c03a00a87aad84f5e63d1f9bf9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 17:58:57 +0000 Subject: [PATCH 1/4] feat(types): arm record:line_items from its @objectstack/spec 17.6.0 ComponentPropsMap row The last ADR-0080 public block AnyComponentSchema refused at `type`. The arm reads ComponentPropsMap['record:line_items'] by reference as its properties bag, spreads the flat-props refusals and the node envelope, declares the node's dataSource binding as the spec's ElementDataSourceSchema (the registration is gate-wrapped; the row leaves childObject optional because the binding supplies it), and refuses node-level children / body by name. Pins: the batch-1 / batch-10 / batch-8 population rows gain the block, the import-boundary list gains the row, the namespaced refused-at-type ratchet falls 361 -> 360, and a new pin file carries the block's own readings. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .changeset/10872-held-public-block-arms.md | 2 + .changeset/10872-public-block-zod-arms.md | 2 + .changeset/10872-record-line-items-arm.md | 17 ++ ...tered-types-validate-ratchet-10859.test.ts | 19 +- .../flat-props-refusal-10872.test.ts | 8 + .../__tests__/imported-defaults-8317.test.ts | 5 + .../__tests__/public-block-arms-10872.test.ts | 3 + ...blic-block-responsive-styles-10872.test.ts | 4 + .../record-line-items-arm-10872.test.ts | 178 ++++++++++++++++++ packages/types/src/zod/index.zod.ts | 1 + packages/types/src/zod/public-blocks.zod.ts | 82 ++++++-- 11 files changed, 307 insertions(+), 14 deletions(-) create mode 100644 .changeset/10872-record-line-items-arm.md create mode 100644 packages/types/src/__tests__/record-line-items-arm-10872.test.ts 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__/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..20e76dcfbb --- /dev/null +++ b/packages/types/src/__tests__/record-line-items-arm-10872.test.ts @@ -0,0 +1,178 @@ +/** + * 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 } from '@objectstack/spec/ui'; + +import { RecordLineItemsBlockSchema, StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.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'); + }); +}); 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..060da1ed48 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,62 @@ 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). + */ +export const RecordLineItemsBlockSchema = 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), +}); + /* ── element: — content blocks ──────────────────────────────────────────── */ /** objectui#9256 (public-block slice): ONE refusal string for both content channels of `element:text`. */ @@ -1474,6 +1531,7 @@ export const PublicBlockComponentSchema = z.discriminatedUnion('type', [ RecordQuickActionsBlockSchema, RecordReferenceRailBlockSchema, RecordAlertBlockSchema, + RecordLineItemsBlockSchema, ElementTextBlockSchema, ElementNumberBlockSchema, ElementButtonBlockSchema, From efa2ef2fd970515ae0f350507c141a10ef6ecb25 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:16:19 +0000 Subject: [PATCH 2/4] test(types): book record:line_items in the parity census; bound the unknown-type message less its options list zod-mirror-parity: one EXCLUSIONS row, directly after RecordAlertBlockSchema, recording that RecordLineItemsBlockSchema is spec-owned by reference. any-component-union-fanout: the unknown-type refusal's message carries the discriminator's options list, which grows by one literal per arm; at 204 literals the whole message read 4,007 against a 4,000 bound. The bound now reads the message less that list, and the docblock gains the reading (flat union 14,126 -> discriminated 193) beside the original one. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .../any-component-union-fanout.test.ts | 41 ++++++++++++++++++- .../src/__tests__/zod-mirror-parity.test.ts | 2 + 2 files changed, 42 insertions(+), 1 deletion(-) 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__/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 From c9e205dfe390b80f711c087a2ee687a0b184bd76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:54:31 +0000 Subject: [PATCH 3/4] fix(types): name the record:line_items arm's type so AnyComponentSchema's declaration stays under TS's serialization ceiling Against @objectstack/spec built from objectstack main, the record:line_items row is larger, and with the arm printed inline `AnyComponentSchema` failed declaration emit with TS7056, so @object-ui/types emitted no .d.ts (Spec Main Shape Gate). The arm is now built as a private const and exported as RecordLineItemsBlockSchema typed by an exported interface that extends the arm's own inferred type and adds no member, so declaration emit prints the arm once and refers to it by name. Runtime object, parse results and the strict face are the arm's; compile-time rows pin the arm's input and output types against the spec row and binding. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- .../record-line-items-arm-10872.test.ts | 54 +++++++++++++++++-- packages/types/src/zod/public-blocks.zod.ts | 42 ++++++++++++++- 2 files changed, 92 insertions(+), 4 deletions(-) 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 index 20e76dcfbb..4005ebf48a 100644 --- a/packages/types/src/__tests__/record-line-items-arm-10872.test.ts +++ b/packages/types/src/__tests__/record-line-items-arm-10872.test.ts @@ -33,9 +33,21 @@ import { describe, expect, it } from 'vitest'; import type { z } from 'zod'; -import { ComponentPropsMap, ElementDataSourceSchema, PageComponentSchema } from '@objectstack/spec/ui'; - -import { RecordLineItemsBlockSchema, StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js'; +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[] }; @@ -176,3 +188,39 @@ describe('`record:line_items` refuses both content channels, by name (objectui#1 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/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index 060da1ed48..b57b624d76 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -1049,8 +1049,11 @@ const RECORD_LINE_ITEMS_NEITHER_CHANNEL = neitherContentChannelGuidance( * * 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. */ -export const RecordLineItemsBlockSchema = BaseSchema.extend({ +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`. @@ -1069,6 +1072,43 @@ export const RecordLineItemsBlockSchema = BaseSchema.extend({ 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. Against `@objectstack/spec` built from objectstack + * `main`, this block's row is larger (the `columns[].readonlyWhen` / + * `requiredWhen` expression types carry their `ast`). With this arm printed + * inline, `@object-ui/types` stopped compiling with TS7056 at + * `AnyComponentSchema` and emitted no declarations; the Spec Main Shape Gate + * caught it. An interface is printed by name, so the arm's body is printed + * once, here, and `AnyComponentSchema` refers to it. + * + * ## 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`. */ From 6217cea60a023cbbaf049a7a4569e583596e1b99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:13:02 +0000 Subject: [PATCH 4/4] docs(types): correct the RecordLineItemsBlockSchemaType docblock's cause to the measured one The docblock said objectstack main's spec makes the record:line_items row larger. Measured on objectstack main 5c9138b, the arm's own serialized size is the same as against 17.6.0; the rest of AnyComponentSchema grew. Comment only; the family is tracked in objectui#11573. Claude-Session: https://claude.ai/code/session_01FjqrwXPfSMkSfkKYDSRkN2 Co-authored-by: Claude --- packages/types/src/zod/public-blocks.zod.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index b57b624d76..f42de7e8e8 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -1084,13 +1084,14 @@ type RecordLineItemsBlockArmType = typeof RecordLineItemsBlockArm; * `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. Against `@objectstack/spec` built from objectstack - * `main`, this block's row is larger (the `columns[].readonlyWhen` / - * `requiredWhen` expression types carry their `ast`). With this arm printed - * inline, `@object-ui/types` stopped compiling with TS7056 at - * `AnyComponentSchema` and emitted no declarations; the Spec Main Shape Gate - * caught it. An interface is printed by name, so the arm's body is printed - * once, here, and `AnyComponentSchema` refers to it. + * 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 *