From 9a77f0a5cc5817ed53cb00a97dea2cd72e3bcdef Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:28:13 +0000 Subject: [PATCH 1/5] feat(types): derive object-form/object-calendar spec keys, rename the legacy zod ActionSchema Work item 4 of #25923. ObjectFormSchema gains contentLayout and confirmOnDiscard, ObjectCalendarSchema gains locale and loading, each the ComponentPropsMap row member by reference on both faces. The zod ActionSchema (legacy type: 'action' node) is renamed CRUDActionSchema so @object-ui/types/zod no longer publishes the spec's ActionSchema name for an unrelated contract. zod-mirror-parity.test.ts gains the spec-only census over the derived spec pairs. Claude-Session: https://claude.ai/code/session_01WGufzhgTn8yFwqXxanJd4c Co-authored-by: Claude --- ui/content/docs/core/enhanced-actions.mdx | 4 +- ui/content/docs/guide/schema-overview.md | 2 +- .../action-callback-retired-7068.test.ts | 22 +-- .../disabled-twin-symmetry-7087.test.ts | 4 +- .../duration-unit-key-renames-25762.test.ts | 6 +- ...ndler-keys-string-any-mirrors-7344.test.ts | 4 +- .../src/__tests__/phase2-schemas.test.ts | 24 +-- .../zod-lazy-getter-identity-7918.test.ts | 4 +- .../src/__tests__/zod-mirror-parity.test.ts | 177 +++++++++++++++++- ui/packages/types/src/objectql.ts | 33 ++++ ui/packages/types/src/zod/crud.zod.ts | 27 ++- ui/packages/types/src/zod/index.zod.ts | 2 +- ui/packages/types/src/zod/objectql.zod.ts | 16 ++ 13 files changed, 277 insertions(+), 48 deletions(-) diff --git a/ui/content/docs/core/enhanced-actions.mdx b/ui/content/docs/core/enhanced-actions.mdx index 039ce15183d..41fffa688ec 100644 --- a/ui/content/docs/core/enhanced-actions.mdx +++ b/ui/content/docs/core/enhanced-actions.mdx @@ -483,7 +483,7 @@ const complexAction: ActionSchema = { ## Runtime Validation ```ts -import { ActionSchema } from '@object-ui/types/zod'; +import { CRUDActionSchema } from '@object-ui/types/zod'; const myAction = { type: 'action', @@ -492,7 +492,7 @@ const myAction = { confirmText: 'Are you sure?', }; -const result = ActionSchema.safeParse(myAction); +const result = CRUDActionSchema.safeParse(myAction); if (result.success) { console.log('Valid action configuration'); diff --git a/ui/content/docs/guide/schema-overview.md b/ui/content/docs/guide/schema-overview.md index 78936a1d496..efe58815871 100644 --- a/ui/content/docs/guide/schema-overview.md +++ b/ui/content/docs/guide/schema-overview.md @@ -187,7 +187,7 @@ For runtime validation, use the included Zod schemas: ```typescript import { AppComponentSchema, - ActionSchema, + CRUDActionSchema, ReportComponentSchema } from '@object-ui/types/zod'; diff --git a/ui/packages/types/src/__tests__/action-callback-retired-7068.test.ts b/ui/packages/types/src/__tests__/action-callback-retired-7068.test.ts index d79fe034d16..c417f2647d9 100644 --- a/ui/packages/types/src/__tests__/action-callback-retired-7068.test.ts +++ b/ui/packages/types/src/__tests__/action-callback-retired-7068.test.ts @@ -59,7 +59,7 @@ import ts from 'typescript'; import { ActionSchema as SpecActionSchema } from '@objectstack/spec/ui'; import * as crudZod from '../zod/crud.zod.js'; import * as zodBarrel from '../zod/index.zod.js'; -import { ActionSchema, DetailSchema } from '../zod/crud.zod.js'; +import { CRUDActionSchema, DetailSchema } from '../zod/crud.zod.js'; import { BaseSchema } from '../zod/base.zod.js'; import { safeValidateSchema } from '../zod/index.zod.js'; import type { ActionSchema as ActionSchemaTS } from '../crud.js'; @@ -105,9 +105,9 @@ type Issue = { code: string; path: PropertyKey[]; message: string; expected?: st const flatIssues = (issues: Issue[]): Issue[] => issues.flatMap((i) => (i.code === 'invalid_union' && i.errors ? i.errors.flat().flatMap((e) => flatIssues([e])) : [i])); -/** The lazy mirror's object shape — `ActionSchema` is `z.lazy(() => BaseSchema.extend(…))`. */ +/** The lazy mirror's object shape — `CRUDActionSchema` is `z.lazy(() => BaseSchema.extend(…))`. */ const actionShape = (): Record => - (ActionSchema as unknown as { _def: { getter: () => { shape: Record } } })._def.getter().shape; + (CRUDActionSchema as unknown as { _def: { getter: () => { shape: Record } } })._def.getter().shape; /* ── the Zod half: refused BY NAME, with the guidance in the message ─────── */ @@ -120,7 +120,7 @@ describe.each([ // .optional()`). Asserting the ENVELOPE — code, path, expected and the exact // message — so the pin cannot be satisfied by an unrelated rejection, and a // WELL-FORMED callback so it is the KEY that is refused, not its members. - const result = ActionSchema.safeParse({ ...LEGACY_ACTION, [key]: value }); + const result = CRUDActionSchema.safeParse({ ...LEGACY_ACTION, [key]: value }); expect(result.success, `an authored \`${key}\` was ACCEPTED`).toBe(false); if (result.success) return; @@ -157,7 +157,7 @@ describe.each([ // exactly this acceptance, kept the callback unvalidated, and stayed green. const doc = { ...LEGACY_ACTION, [key]: CALLBACKS[0][1] }; expect(BaseSchema.safeParse(doc).success).toBe(true); - expect(ActionSchema.safeParse(doc).success).toBe(false); + expect(CRUDActionSchema.safeParse(doc).success).toBe(false); }); it('keeps the key DECLARED, with the same guidance on `.describe()` — one string, both channels', () => { @@ -171,14 +171,14 @@ describe.each([ describe('the retirement narrows exactly `onSuccess` / `onFailure` (objectui#7068)', () => { it('a legacy action WITHOUT the two keys still parses, and its values survive', () => { - const result = ActionSchema.safeParse(LEGACY_ACTION); + const result = CRUDActionSchema.safeParse(LEGACY_ACTION); expect(result.success).toBe(true); if (!result.success) return; expect(result.data.api).toBe('/api/data'); }); it('control: the adjacent `successMessage` / `errorMessage` — NOT retired — still parse and survive', () => { - const result = ActionSchema.safeParse({ ...LEGACY_ACTION, successMessage: 'Loaded', errorMessage: 'Failed' }); + const result = CRUDActionSchema.safeParse({ ...LEGACY_ACTION, successMessage: 'Loaded', errorMessage: 'Failed' }); expect(result.success).toBe(true); if (!result.success) return; expect(result.data.successMessage).toBe('Loaded'); @@ -186,19 +186,19 @@ describe('the retirement narrows exactly `onSuccess` / `onFailure` (objectui#706 }); it('control: the mirror did not stop validating — a wrong-typed `successMessage` is still refused', () => { - const result = ActionSchema.safeParse({ ...LEGACY_ACTION, successMessage: 42 }); + const result = CRUDActionSchema.safeParse({ ...LEGACY_ACTION, successMessage: 42 }); expect(result.success).toBe(false); if (result.success) return; expect(result.error.issues.map((i) => i.path.join('.'))).toContain('successMessage'); }); it('control: `chain` and `redirect` — the declared follow-up and post-action keys — still parse', () => { - const result = ActionSchema.safeParse({ ...LEGACY_ACTION, chain: [{ type: 'action', label: 'Next' }], redirect: '/done' }); + const result = CRUDActionSchema.safeParse({ ...LEGACY_ACTION, chain: [{ type: 'action', label: 'Next' }], redirect: '/done' }); expect(result.success).toBe(true); }); it('an UNDECLARED key still rides `.passthrough()` — the contrast the tombstones exist for, measured live', () => { - const result = ActionSchema.safeParse({ ...LEGACY_ACTION, notAKeyAtAll: 'anything' }); + const result = CRUDActionSchema.safeParse({ ...LEGACY_ACTION, notAKeyAtAll: 'anything' }); expect(result.success).toBe(true); if (!result.success) return; expect((result.data as unknown as Record).notAKeyAtAll).toBe('anything'); @@ -212,7 +212,7 @@ describe('`ActionCallback` / `ActionCallbackSchema` are DELETED, not tombstoned expect('ActionCallbackSchema' in crudZod).toBe(false); expect('ActionCallbackSchema' in zodBarrel).toBe(false); // Non-vacuity: the neighbours the barrel still carries. - expect('ActionSchema' in crudZod).toBe(true); + expect('CRUDActionSchema' in crudZod).toBe(true); expect('ActionExecutionModeSchema' in zodBarrel).toBe(true); }); diff --git a/ui/packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts b/ui/packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts index 153a874241e..ceca0e9158c 100644 --- a/ui/packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts +++ b/ui/packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts @@ -95,7 +95,7 @@ import type { ToggleSchema, } from '../form'; -import { ActionSchema as ActionMirror } from '../zod/crud.zod'; +import { CRUDActionSchema as ActionMirror } from '../zod/crud.zod'; import { AccordionItemSchema, CollapsibleSchema as CollapsibleMirror, @@ -199,7 +199,7 @@ const IN_SCOPE: readonly MirrorCase[] = [ { name: 'form.zod.ts#InputOTPSchema', mirror: FormMirrors.InputOTPSchema, fixture: { type: 'input-otp' } }, { name: 'form.zod.ts#FormSchema', mirror: FormMirrors.FormSchema, fixture: { type: 'form', fields: [{ name: 'title', type: 'text' }] } }, { name: 'form.zod.ts#ComboboxSchema', mirror: FormMirrors.ComboboxSchema, fixture: { type: 'combobox', options: [{ label: 'A', value: 'a' }] } }, - { name: 'crud.zod.ts#ActionSchema', mirror: ActionMirror, fixture: { type: 'action', label: 'Save' } }, + { name: 'crud.zod.ts#CRUDActionSchema', mirror: ActionMirror, fixture: { type: 'action', label: 'Save' } }, { name: 'disclosure.zod.ts#CollapsibleSchema', mirror: CollapsibleMirror, diff --git a/ui/packages/types/src/__tests__/duration-unit-key-renames-25762.test.ts b/ui/packages/types/src/__tests__/duration-unit-key-renames-25762.test.ts index 111ee0b3c63..9f039c24dec 100644 --- a/ui/packages/types/src/__tests__/duration-unit-key-renames-25762.test.ts +++ b/ui/packages/types/src/__tests__/duration-unit-key-renames-25762.test.ts @@ -37,7 +37,7 @@ import type { ToastSchema as ToastInterface } from '../feedback'; import type { HoverCardSchema as HoverCardInterface, TooltipSchema as TooltipInterface } from '../overlay'; import type { ReportComponentSchema as ReportInterface } from '../reports'; import { - ActionSchema, + CRUDActionSchema, CarouselSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, @@ -70,8 +70,8 @@ const CASES: readonly RenameCase[] = [ { schema: ChatbotSchema, base: { type: 'chatbot', messages: [] }, at: [], retired: 'autoResponseDelay', renamed: 'autoResponseDelayMs', unit: 'milliseconds' }, { schema: ChatbotEnhancedSchema, base: { type: 'chatbot-enhanced', messages: [] }, at: [], retired: 'autoResponseDelay', renamed: 'autoResponseDelayMs', unit: 'milliseconds' }, { schema: ChatbotFloatingSchema, base: { type: 'chatbot-floating', messages: [] }, at: [], retired: 'autoResponseDelay', renamed: 'autoResponseDelayMs', unit: 'milliseconds' }, - { schema: ActionSchema, base: { type: 'action', label: 'Go' }, at: [], retired: 'timeout', renamed: 'timeoutMs', unit: 'milliseconds' }, - { schema: ActionSchema, base: { type: 'action', label: 'Go', retry: { maxAttempts: 2 } }, at: ['retry'], retired: 'delay', renamed: 'delayMs', unit: 'milliseconds' }, + { schema: CRUDActionSchema, base: { type: 'action', label: 'Go' }, at: [], retired: 'timeout', renamed: 'timeoutMs', unit: 'milliseconds' }, + { schema: CRUDActionSchema, base: { type: 'action', label: 'Go', retry: { maxAttempts: 2 } }, at: ['retry'], retired: 'delay', renamed: 'delayMs', unit: 'milliseconds' }, { schema: ToastSchema, base: { type: 'toast' }, at: [], retired: 'duration', renamed: 'durationMs', unit: 'milliseconds' }, { schema: TooltipSchema, base: { type: 'tooltip', content: 'Hi' }, at: [], retired: 'delayDuration', renamed: 'delayDurationMs', unit: 'milliseconds' }, { schema: HoverCardSchema, base: { type: 'hover-card', ...node }, at: [], retired: 'openDelay', renamed: 'openDelayMs', unit: 'milliseconds' }, diff --git a/ui/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts b/ui/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts index ea3138ee1f4..9c5ba739121 100644 --- a/ui/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts +++ b/ui/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts @@ -157,7 +157,7 @@ import { z } from 'zod'; import { CalendarViewSchema as CalendarViewZod } from '../zod/complex.zod'; import { - ActionSchema as ActionZod, + CRUDActionSchema as ActionZod, CRUDDialogSchema as CRUDDialogZod, DetailSchema as DetailZod, } from '../zod/crud.zod'; @@ -191,7 +191,7 @@ const objectOf = (mirror: z.ZodType, key: string): z.ZodObject => /** The six keys whose function value REACHES a renderer (channels above). */ const RUNTIME_SLOT: readonly Site[] = [ ['views.zod.ts', 'DetailViewSchema', 'onBack', DetailViewZod], - ['crud.zod.ts', 'ActionSchema', 'onClick', ActionZod], + ['crud.zod.ts', 'CRUDActionSchema', 'onClick', ActionZod], ['crud.zod.ts', 'DetailSchema', 'onBack', DetailZod], // objectui#7804 — the two keys `DetailView` reads off a `'detail'` document // that its arm never declared. They arrive by a DIFFERENT route from the four diff --git a/ui/packages/types/src/__tests__/phase2-schemas.test.ts b/ui/packages/types/src/__tests__/phase2-schemas.test.ts index e632308e3e2..028723b129b 100644 --- a/ui/packages/types/src/__tests__/phase2-schemas.test.ts +++ b/ui/packages/types/src/__tests__/phase2-schemas.test.ts @@ -8,7 +8,7 @@ import { AppComponentSchema, ReportComponentSchema, ReportBuilderSchema, - ActionSchema, + CRUDActionSchema, DetailViewSchema, ViewSwitcherSchema, FilterUISchema, @@ -364,7 +364,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => { errorMessage: 'Failed to load data', }; - const result = ActionSchema.safeParse(ajaxAction); + const result = CRUDActionSchema.safeParse(ajaxAction); expect(result.success).toBe(true); }); @@ -378,7 +378,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => { method: 'DELETE', }; - const result = ActionSchema.safeParse(confirmAction); + const result = CRUDActionSchema.safeParse(confirmAction); expect(result.success).toBe(true); }); @@ -394,7 +394,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => { // value is a LOUD parse rejection — never a silent strip, which would let // the author believe the dialog they configured exists. Absent stays // valid (accept case above). - const result = ActionSchema.safeParse(structured); + const result = CRUDActionSchema.safeParse(structured); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues.map((i) => i.path.join('.'))).toContain('confirm'); @@ -408,7 +408,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => { // nothing else. The author now reads the remedy (`confirmText`) in the // message itself, which is what `packages/cli`'s `validate` / `check` // print verbatim beside the path and code. - const result = ActionSchema.safeParse({ + const result = CRUDActionSchema.safeParse({ type: 'action', label: 'Delete Record', actionType: 'confirm', @@ -427,7 +427,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => { // Non-vacuity, IN THIS TEST: the canonical spelling still parses green. expect( - ActionSchema.safeParse({ type: 'action', label: 'Delete Record', confirmText: 'Sure?' }).success, + CRUDActionSchema.safeParse({ type: 'action', label: 'Delete Record', confirmText: 'Sure?' }).success, ).toBe(true); }); @@ -462,7 +462,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => { }, }; - const result = ActionSchema.safeParse(dialogAction); + const result = CRUDActionSchema.safeParse(dialogAction); expect(result.success).toBe(true); }); @@ -492,7 +492,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => { chainMode: 'sequential', }; - const result = ActionSchema.safeParse(chainedAction); + const result = CRUDActionSchema.safeParse(chainedAction); expect(result.success).toBe(true); }); @@ -516,7 +516,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => { condition, }; - const result = ActionSchema.safeParse(gatedAction); + const result = CRUDActionSchema.safeParse(gatedAction); expect(result.success).toBe(true); }); @@ -549,7 +549,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => { }, }; - const result = ActionSchema.safeParse(branchAction); + const result = CRUDActionSchema.safeParse(branchAction); expect(result.success).toBe(false); if (result.success) return; const issues = result.error.issues.filter((i) => i.path[0] === 'condition'); @@ -573,7 +573,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => { }, }; - const result = ActionSchema.safeParse(trackedAction); + const result = CRUDActionSchema.safeParse(trackedAction); expect(result.success).toBe(true); }); @@ -590,7 +590,7 @@ describe('Phase 2: Enhanced ActionSchema Zod Validation', () => { }, }; - const result = ActionSchema.safeParse(retryAction); + const result = CRUDActionSchema.safeParse(retryAction); expect(result.success).toBe(true); }); }); diff --git a/ui/packages/types/src/__tests__/zod-lazy-getter-identity-7918.test.ts b/ui/packages/types/src/__tests__/zod-lazy-getter-identity-7918.test.ts index 3b42577be27..c9c87ccd2d2 100644 --- a/ui/packages/types/src/__tests__/zod-lazy-getter-identity-7918.test.ts +++ b/ui/packages/types/src/__tests__/zod-lazy-getter-identity-7918.test.ts @@ -104,7 +104,7 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; import { - ActionSchema, + CRUDActionSchema, AppMenuItemSchema, FilterBuilderConditionSchema, FilterGroupSchema, @@ -143,7 +143,7 @@ const MEMOISED: ReadonlyArray = [ ]; /** ⛔ Do not "fix" these — each one's `z.lazy` dodges a real ReferenceError. */ const TDZ_BOUND: ReadonlyArray = [ - ['ActionSchema', ActionSchema], + ['CRUDActionSchema', CRUDActionSchema], ['AppMenuItemSchema', AppMenuItemSchema], ['MenuItemSchema', MenuItemSchema], ['NavLinkSchema', NavLinkSchema], diff --git a/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts b/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts index 0fd3d590b24..7af68bd4b72 100644 --- a/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -958,6 +958,9 @@ import { dirname, join } from 'node:path'; import ts from 'typescript'; import type { z } from 'zod'; import type { WithoutCatchall } from './zod-catchall-type'; +import * as SpecUi from '@objectstack/spec/ui'; +import * as SpecData from '@objectstack/spec/data'; +import * as ZodBarrel from '../zod/index.zod.js'; import { AIConfigSchema, AIFieldSuggestionSchema, AIFormAssistSchema, AIRecommendationItemSchema, AIRecommendationsSchema, NLQueryResultSchema, NLQuerySchema } from '../zod/ai.zod.js'; import { AppComponentSchema, AppSchemaRendererNodeSchema, MenuItemSchema as AppMenuItemSchema, NavigationAreaSchema, NavigationItemSchema } from '../zod/app.zod.js'; @@ -967,7 +970,7 @@ import { ChecklistBlockItemSchema, ChecklistBlockSchema, HeroBlockSchema, StatBl import { RecordApprovalDecisionBlockSchema } from '../zod/record-approval-decision.zod.js'; import { RecordApprovalsBlockSchema, RecordAttachmentsBlockSchema } from '../zod/record-panels.zod.js'; import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, KanbanCardSchema, KanbanColumnSchema, CardTemplateSchema, FilterGroupSchema } from '../zod/complex.zod.js'; -import { ActionSchema, CRUDDialogSchema, DetailSchema } from '../zod/crud.zod.js'; +import { CRUDActionSchema, CRUDDialogSchema, DetailSchema } from '../zod/crud.zod.js'; import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, DrillDownConfigSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, PivotTableSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineFeedItemSchema, TimelineGanttItemBarSchema, TimelineGanttItemSchema, TimelineSchema, TreeNodeSchema, TreeViewSchema } from '../zod/data-display.zod.js'; import { BPMNEdgeSchema, BPMNLaneSchema, BPMNNodeSchema, DataModelDesignerSchema, DataModelEntitySchema, DataModelFieldSchema, DataModelRelationshipSchema, DesignerCanvasConfigSchema, DesignerComponentSchema, DesignerFieldDefinitionSchema, DesignerFieldOptionSchema, DesignerPaletteCategorySchema, DesignerPaletteItemSchema, DesignerPositionSchema, FieldDesignerSchema, ObjectDefinitionSchema, ObjectManagerSchema, PageDesignerSchema, ProcessDesignerSchema, ReportDesignerElementSchema, ReportDesignerSchema, ReportDesignerSectionSchema } from '../zod/designer.zod.js'; import { AccordionItemSchema, AccordionSchema, CollapsibleSchema, ToggleGroupItemSchema, ToggleGroupSchema } from '../zod/disclosure.zod.js'; @@ -5046,7 +5049,7 @@ const EXCLUSIONS: Readonly> = { "a union OVER the mirrors, not an object of its own — its members are checked individually above", 'crud.zod.ts#ActionExecutionModeSchema': "a bare vocabulary with no `.shape`; it is checked where a mirrored KEY declares it", - 'crud.zod.ts#ActionSchema': + 'crud.zod.ts#CRUDActionSchema': "recursive; a `z.lazy` exposes no `.shape` to read, so there is no key set for the per-key comparison. Since objectui#7760 it carries its TS declaration as BOTH type arguments, so the pair IS compared — as a whole type, by `tsc`, at the annotation itself", 'crud.zod.ts#CRUDComponentSchema': "a union OVER the mirrors, not an object of its own — its members are checked individually above", @@ -5671,6 +5674,174 @@ the strict face refuses it), or \`.strict()\` where refusing is the contract.`). }); }); +/* ── The spec-only direction: keys the SPEC declares that no mirror carries ──── */ + +/** + * objectstack#25923, work item 4. Every direction above compares a mirror with the + * TypeScript declaration it restates. This one compares it with the `@objectstack/spec` + * schema it stands beside, from the other side: a key the spec declares on that schema + * and the mirror neither declares nor refuses is, on the tolerant face, a silent pass + * (`.passthrough()` keeps it unexamined), and on the strict face an `unrecognized_keys` + * report the spec would not give. + * + * The PAIRS are derived, never listed: (1) a barrel export and a `@objectstack/spec/ui` + * or `/data` export under the SAME name, both objects; (2) a barrel export whose `type` + * is one literal with a `ComponentPropsMap` row, the row being the spec side (a + * `properties` bag's members count as the mirror's own). Each key that pairing finds + * on the spec side only must be DERIVED (declared by reference, and then it is not + * spec-only any more), REFUSED by name, or carry a row below saying why objectui + * leaves it out. ⛔ No row for a key a mirror could declare by reference and a + * renderer reads: derive it instead. + * + * The rows are the `FormField` name collision only. objectui's `FormFieldSchema` + * (`form.zod.ts`) is the RUNTIME widget config (`name` = form data path); the spec's + * is the authored form-VIEW entry (`field` = object-field reference). The two layers + * meet in `@object-ui/plugin-form`'s `normalizeSectionField`, which + * `sectionFields.spec-parity.test.ts` covers key by key (ruling objectui#3090; the + * `@object-ui/types:FormField` entry in `ui/scripts/check-spec-symbol-derivation.mjs`). + * So a row here says what that translation does with the key on the runtime layer. + */ +type SpecOnlyDisposition = + /** `normalizeSectionField` writes it onto the runtime field under another name, which renders. */ + | 'translated' + /** `normalizeSectionField` copies it onto the runtime field and no renderer reads it there. */ + | 'carried, not rendered' + /** The spec refuses it itself; `normalizeSectionField` has no destination for it. */ + | 'spec-retired'; + +const SPEC_ONLY_KEY_ROWS: Record = { + 'FormFieldSchema.field': { + disposition: 'translated', + why: "the spec entry's object-field reference becomes the runtime field's `name`; the runtime `FormField.field` is the resolved field-metadata stash, kept off this mirror on purpose (objectui#6609)", + }, + 'FormFieldSchema.helpText': { + disposition: 'translated', + why: '`normalizeSectionField` writes it as the runtime `description`, which the form renders under the control', + }, + 'FormFieldSchema.immutable': { + disposition: 'carried, not rendered', + why: '`normalizeSectionField` copies it onto the runtime field; no form renderer reads `immutable`, so an edit-mode form leaves the control editable', + }, + 'FormFieldSchema.keyField': { + disposition: 'carried, not rendered', + why: "`normalizeSectionField` copies it through for the record widget (ADR-0007); no widget reads it from the runtime field yet (the mapper's own comment)", + }, + 'FormFieldSchema.disclosure': { + disposition: 'carried, not rendered', + why: "`normalizeSectionField` copies it through for the composite widgets (ADR-0007); no widget reads it from the runtime field yet (the mapper's own comment)", + }, + 'FormFieldSchema.precision': { + disposition: 'carried, not rendered', + why: "`normalizeSectionField` copies it onto the runtime field; the number widgets read `scale` and the object field's own metadata (`FormField.field`), never the runtime field's `precision`", + }, + 'FormFieldSchema.publicPicker': { + disposition: 'spec-retired', + why: 'a `retiredKey()` tombstone in the spec since 17.6.0, which refuses it with its own remedy; it gated a server route that no longer exists, and the runtime layer never had it (exemption ruled on objectui#4648)', + }, +}; + +type ShapeHolder = { _zod?: { def?: { type?: string; shape?: Record; getter?: () => unknown; innerType?: unknown; in?: unknown; schema?: unknown; values?: unknown[]; value?: unknown } } }; + +/** The object a schema stands for, through the wrappers a mirror or a spec export carries. */ +function objectOf(schema: unknown): Record | undefined { + let cur = schema as ShapeHolder | undefined; + for (let i = 0; i < 30 && cur?._zod?.def; i++) { + const def = cur._zod.def; + if (def.type === 'object') return def.shape; + const next = def.type === 'lazy' && def.getter ? def.getter() : def.innerType ?? def.in ?? def.schema; + cur = next as ShapeHolder | undefined; + } + return undefined; +} + +/** The one `type` literal an object declares, or `undefined`. */ +function typeLiteralOf(shape: Record): string | undefined { + const def = (shape.type as ShapeHolder | undefined)?._zod?.def; + if (def?.type !== 'literal') return undefined; + const values = def.values ?? (def.value === undefined ? [] : [def.value]); + return values.length === 1 && typeof values[0] === 'string' ? values[0] : undefined; +} + +/** Every derived spec pair, with the keys its spec side declares and the mirror side does not. */ +function measureSpecOnlyKeys(): { pairs: string[]; specOnly: string[] } { + const spec: Record = { ...SpecData, ...SpecUi }; + const rows = (SpecUi as { ComponentPropsMap: Record }).ComponentPropsMap; + const pairs: string[] = []; + const specOnly: string[] = []; + for (const [name, mirror] of Object.entries(ZodBarrel)) { + if (!name.endsWith('Schema')) continue; + const mirrorShape = objectOf(mirror); + if (!mirrorShape) continue; + const literal = typeLiteralOf(mirrorShape); + const sides: [string, unknown][] = []; + if (name in spec) sides.push([`spec#${name}`, spec[name]]); + if (literal !== undefined && literal in rows) sides.push([`ComponentPropsMap['${literal}']`, rows[literal]]); + const bag = objectOf(mirrorShape.properties); + const mirrored = new Set([...Object.keys(mirrorShape), ...(bag ? Object.keys(bag) : [])]); + for (const [side, specSchema] of sides) { + const specShape = objectOf(specSchema); + if (!specShape) continue; + pairs.push(`${name} ↔ ${side}`); + for (const key of Object.keys(specShape)) { + if (!mirrored.has(key)) specOnly.push(`${name}.${key}`); + } + } + } + return { pairs, specOnly: [...new Set(specOnly)].sort() }; +} + +describe('every spec-only key over the spec pairs is derived, refused or recorded (objectstack#25923)', () => { + it('no key the spec declares on a paired schema is left out without a row', () => { + const missing = measureSpecOnlyKeys().specOnly.filter((key) => !(key in SPEC_ONLY_KEY_ROWS)); + expect(missing, ` +A key \`@objectstack/spec\` declares on a schema a mirror in packages/types/src/zod/ pairs +with, which the mirror neither declares nor refuses. The tolerant face keeps it +unexamined and the strict face refuses a key the spec accepts. Derive it by reference +(\`stripImportedDefaults(SpecX).shape.KEY\`, and the TS twin \`SpecProps['KEY']\`) when a +renderer reads it; refuse it by name when objectui deliberately does not support it; +otherwise add a SPEC_ONLY_KEY_ROWS row saying what happens to it.`).toEqual([]); + }); + + it('every row names a key that is still spec-only — no dead rows', () => { + const measured = new Set(measureSpecOnlyKeys().specOnly); + const dead = Object.keys(SPEC_ONLY_KEY_ROWS).filter((key) => !measured.has(key)); + expect(dead, 'a row for a key the mirror now declares, or the spec no longer does — delete it').toEqual([]); + const bare = Object.entries(SPEC_ONLY_KEY_ROWS).filter(([, row]) => row.why.trim().length < 40); + expect(bare.map(([key]) => key), 'a row without its reason is an oversight').toEqual([]); + }); + + it('the pairing can see both kinds of pair (non-vacuity)', () => { + // A broken reader finds no pair and the first case passes while checking nothing. + const { pairs } = measureSpecOnlyKeys(); + expect(pairs).toContain('FormFieldSchema ↔ spec#FormFieldSchema'); + expect(pairs).toContain('ListViewSchema ↔ spec#ListViewSchema'); + expect(pairs).toContain("ObjectFormSchema ↔ ComponentPropsMap['object-form']"); + expect(pairs).toContain("ObjectCalendarSchema ↔ ComponentPropsMap['object-calendar']"); + }); + + it('the four keys this card derived are the spec row members by reference', () => { + const form = (SpecUi.ObjectFormPropsSchema as unknown as { shape: Record }).shape; + const calendar = (SpecUi.ObjectCalendarPropsSchema as unknown as { shape: Record }).shape; + expect(ObjectFormSchema.shape.contentLayout).toBe(form.contentLayout); + expect(ObjectFormSchema.shape.confirmOnDiscard).toBe(form.confirmOnDiscard); + const calendarShape = objectOf(ObjectCalendarSchema); + expect(calendarShape?.locale).toBe(calendar.locale); + expect(calendarShape?.loading).toBe(calendar.loading); + // The spec's verdict, now the mirror's: a misspelled layout is refused at its own path. + const bad = ObjectFormSchema.safeParse({ type: 'object-form', objectName: 'account', mode: 'create', contentLayout: 'tabs' }); + expect(bad.success).toBe(false); + expect(bad.error?.issues.map((issue) => issue.path.join('.'))).toContain('contentLayout'); + }); + + it('`@object-ui/types/zod` publishes no `ActionSchema` — the legacy action node is `CRUDActionSchema`', () => { + // The spec's `ActionSchema` is the action metadata type; objectui's legacy + // `type: 'action'` node shared none of its vocabulary under the same name. + expect('ActionSchema' in ZodBarrel).toBe(false); + expect('CRUDActionSchema' in ZodBarrel).toBe(true); + expect('ActionSchema' in SpecUi).toBe(true); + }); +}); + describe('the population and its two differences are derived, not prose (objectui#7433)', () => { it('the registry population matches EXPECTED_MIRROR_PAIRS', () => { const actual = Object.keys(MIRRORS).length; @@ -6473,7 +6644,7 @@ const RECURSION_BREAKING_MIRRORS: readonly (readonly [string, unknown])[] = [ ['app.zod.ts#NavigationItemSchema', NavigationItemSchema], ['app.zod.ts#MenuItemSchema', AppMenuItemSchema], ['complex.zod.ts#FilterGroupSchema', FilterGroupSchema], - ['crud.zod.ts#ActionSchema', ActionSchema], + ['crud.zod.ts#CRUDActionSchema', CRUDActionSchema], ['data-display.zod.ts#TreeNodeSchema', TreeNodeSchema], ['designer.zod.ts#DesignerComponentSchema', DesignerComponentSchema], ['navigation.zod.ts#NavigationMenuItemSchema', NavigationMenuItemSchema], diff --git a/ui/packages/types/src/objectql.ts b/ui/packages/types/src/objectql.ts index 2dd3f8defdf..1b1d9b16bb8 100644 --- a/ui/packages/types/src/objectql.ts +++ b/ui/packages/types/src/objectql.ts @@ -2279,6 +2279,22 @@ export interface ObjectFormSchema extends BaseSchema { */ modalCloseButton?: boolean; + /** + * How the modal presentation lays out its sections — the + * `ComponentPropsMap['object-form']` member, by reference (objectstack#25923, + * work item 4). `ModalForm` puts each section on its own tab only for + * `'tabbed'` with more than one section to show; any other value stacks them. + */ + contentLayout?: SpecObjectFormProps['contentLayout']; + + /** + * Ask before discarding unsaved edits when a drawer or modal form is + * dismissed — the `ComponentPropsMap['object-form']` member, by reference + * (objectstack#25923, work item 4). `ModalForm` and `DrawerForm` ask unless + * it is `false`. + */ + confirmOnDiscard?: SpecObjectFormProps['confirmOnDiscard']; + // ─── Mobile UX (round 3) ──────────────────────────────── /** @@ -2486,6 +2502,8 @@ type ObjectFormSlotKey = | 'children' | 'className' | 'columns' + | 'confirmOnDiscard' + | 'contentLayout' | 'customFields' | 'data' // objectui#11070 — joins with `ObjectFormSchema.dataSource`. This slot @@ -4294,6 +4312,21 @@ export interface ObjectCalendarSchema extends BaseSchema { * reads them, in `plugin-calendar`'s `calendarNavigationMembers-8652` test. */ navigation?: ViewNavigationConfig; + /** + * BCP-47 tag the calendar formats its dates and times with — the + * `ComponentPropsMap['object-calendar']` member, by reference + * (objectstack#25923, work item 4). `plugin-calendar`'s registration + * boundary reads it off the node and drops a tag `Intl` refuses, the same + * answer as an absent key. + */ + locale?: SpecObjectCalendarProps['locale']; + /** + * External loading state — the `ComponentPropsMap['object-calendar']` + * member, by reference (objectstack#25923, work item 4). Honoured only + * alongside an array `data`; a calendar fed by `staticData` or `objectName` + * owns its loading state and drops it. + */ + loading?: SpecObjectCalendarProps['loading']; /** * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-calendar` reads * NEITHER content channel; see `children` below for the measurement. diff --git a/ui/packages/types/src/zod/crud.zod.ts b/ui/packages/types/src/zod/crud.zod.ts index 2a367e66b38..1398a9b93ed 100644 --- a/ui/packages/types/src/zod/crud.zod.ts +++ b/ui/packages/types/src/zod/crud.zod.ts @@ -62,6 +62,15 @@ const ActionConditionPredicateSchema = z.union([ /** * Action Schema - Enhanced with Phase 2 features * + * RENAMED from `ActionSchema` (objectstack#25923, work item 4). That name is + * `@objectstack/spec/ui`'s action metadata type, and this legacy `type: 'action'` + * node shares none of its vocabulary, so `import { ActionSchema } from + * '@object-ui/types/zod'` validated a different contract from the one the name + * promises. The accept set is unchanged; only the export name moved, to the + * `CRUD…` prefix its union (`CRUDComponentSchema`) and sibling + * (`CRUDDialogSchema`) already carry. Its TypeScript twin keeps the @deprecated + * `ActionSchema` name in `../crud.ts`. + * * INPUT FACE: both type arguments carry this mirror's existing TypeScript * declaration (objectui#7760, maintainer ruling, decision batch #69) — the annotation * still breaks the recursion in the initializer below, but it no longer publishes @@ -69,7 +78,7 @@ const ActionConditionPredicateSchema = z.union([ * declaration unchanged. The reasoning lives once, on `SchemaNodeSchema` in * `base.zod.ts` — read it there before changing this line. */ -export const ActionSchema: z.ZodType = z.lazy(() => BaseSchema.extend({ +export const CRUDActionSchema: z.ZodType = z.lazy(() => BaseSchema.extend({ type: z.literal('action'), label: z.string().describe('Action label'), level: z.enum(['primary', 'secondary', 'success', 'warning', 'danger', 'info', 'default']).optional().describe('Action type/level'), @@ -93,7 +102,7 @@ export const ActionSchema: z.ZodType = z.l title: z.string().optional().describe('Dialog title'), content: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Dialog content'), size: z.enum(['sm', 'default', 'lg', 'xl', 'full']).optional().describe('Dialog size'), - actions: z.array(ActionSchema).optional().describe('Dialog actions'), + actions: z.array(CRUDActionSchema).optional().describe('Dialog actions'), }).optional().describe('Dialog configuration (for dialog actions)'), successMessage: z.string().optional().describe('Success message after execution'), errorMessage: z.string().optional().describe('Error message on failure'), @@ -123,7 +132,7 @@ export const ActionSchema: z.ZodType = z.l + 'publish as an unrecognized key). A failure notice is `errorMessage`. Retired under ADR-0049 ' + 'enforce-or-remove with no deprecation window (maintainer ruling option 1, 2026-09-05).', ), - chain: z.array(ActionSchema).optional().describe('Action chaining - actions to execute after this one'), + chain: z.array(CRUDActionSchema).optional().describe('Action chaining - actions to execute after this one'), chainMode: ActionExecutionModeSchema.optional().describe('Chain execution mode'), condition: ActionConditionPredicateSchema.optional().describe('Execution gate — the action runs only while this predicate holds'), reload: z.boolean().optional().describe('Whether to reload data after action'), @@ -193,7 +202,7 @@ export const DetailSchema = BaseSchema.extend({ render: SchemaNodeSchema.optional(), })), })).optional().describe('Field groups for organized display'), - actions: z.array(ActionSchema).optional().describe('Actions available in detail view'), + actions: z.array(CRUDActionSchema).optional().describe('Actions available in detail view'), tabs: z.array(z.looseObject({ key: z.string(), label: z.string(), @@ -264,7 +273,7 @@ export const CRUDDialogSchema = BaseSchema.extend({ description: z.string().optional().describe('Dialog description'), content: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Dialog content'), size: z.enum(['sm', 'default', 'lg', 'xl', 'full']).optional().describe('Dialog size'), - actions: z.array(ActionSchema).optional().describe('Dialog actions/buttons'), + actions: z.array(CRUDActionSchema).optional().describe('Dialog actions/buttons'), open: z.boolean().optional().describe('Whether dialog is open'), // RETIRED (objectui#7344): no renderer is registered for `crud-dialog`; // nothing reads the key. Was `z.any()`. @@ -285,16 +294,16 @@ export const CRUDDialogSchema = BaseSchema.extend({ * distinct `z.literal` (`action` · `detail` · `crud-dialog`). */ const CRUDComponentSchemaInferred = z.discriminatedUnion('type', [ - // `ActionSchema`'s `z.ZodType` annotation + // `CRUDActionSchema`'s `z.ZodType` annotation // is a maintainer ruling (objectui#7760, decision batch #69) and ⛔ does not // move here. That type declares `_zod.propValues` as `PropValues | undefined`, // so `tsc` cannot see through the `z.lazy` to the `type: z.literal('action')` // the body really declares — the RUNTIME can, measured: - // `ActionSchema._zod.propValues.type` is `Set { 'action' }`. The intersection + // `CRUDActionSchema._zod.propValues.type` is `Set { 'action' }`. The intersection // asserts that one fact and nothing else, leaving the output type intact so // this union infers what it always did; `__tests__/any-component-union-fanout // .test.ts` pins the fact at runtime so the cast cannot rot into a lie. - ActionSchema as typeof ActionSchema & z.core.$ZodTypeDiscriminable<'type'>, + CRUDActionSchema as typeof CRUDActionSchema & z.core.$ZodTypeDiscriminable<'type'>, DetailSchema, CRUDDialogSchema, ]); @@ -316,6 +325,6 @@ export const CRUDComponentSchema: CRUDComponentZodType = CRUDComponentSchemaInfe * Export type inference helpers */ export type ActionExecutionModeSchemaType = z.infer; -export type ActionSchemaType = z.infer; +export type CRUDActionSchemaType = z.infer; export type DetailSchemaType = z.infer; export type CRUDDialogSchemaType = z.infer; diff --git a/ui/packages/types/src/zod/index.zod.ts b/ui/packages/types/src/zod/index.zod.ts index 3f618a2080c..1c159e8741e 100644 --- a/ui/packages/types/src/zod/index.zod.ts +++ b/ui/packages/types/src/zod/index.zod.ts @@ -375,7 +375,7 @@ export { // ============================================================================ export { ActionExecutionModeSchema, - ActionSchema, + CRUDActionSchema, DetailSchema, CRUDDialogSchema, CRUDComponentSchema, diff --git a/ui/packages/types/src/zod/objectql.zod.ts b/ui/packages/types/src/zod/objectql.zod.ts index 4f40b548a6c..f1534c87827 100644 --- a/ui/packages/types/src/zod/objectql.zod.ts +++ b/ui/packages/types/src/zod/objectql.zod.ts @@ -1033,6 +1033,14 @@ export const ObjectFormSchema = BaseSchema.extend({ drawerWidth: z.string().optional().describe('Drawer width as a CSS value; drawer forms only'), modalSize: z.enum(['sm', 'default', 'lg', 'xl', 'full']).optional().describe('Modal dialog size; modal forms only'), modalCloseButton: z.boolean().optional().describe('Show the modal header close button; modal forms only'), + // objectstack#25923 (work item 4) — two members `ComponentPropsMap['object-form']` + // declares and the drawer / modal presentations read (`ModalForm` tests + // `schema.contentLayout === 'tabbed'`; `ModalForm` and `DrawerForm` read + // `schema.confirmOnDiscard !== false`), which neither face of this package + // named. The row's own members BY REFERENCE, so a misspelled `'tabs'` is + // refused here as the spec refuses it. The TS twin takes the same members. + contentLayout: stripImportedDefaults(SpecObjectFormPropsSchema).shape.contentLayout, + confirmOnDiscard: stripImportedDefaults(SpecObjectFormPropsSchema).shape.confirmOnDiscard, mobile: z.looseObject({ stickyActions: z.boolean().optional().describe('Pin Submit/Cancel to the bottom of the viewport on mobile'), stepper: z.union([z.boolean(), z.literal('auto')]).optional().describe('One-field-at-a-time stepper on small screens: true, false or auto'), @@ -3165,6 +3173,14 @@ export const ObjectCalendarSchema = BaseSchema.extend({ // requiredness as `../objectql.ts` (optional), so the zod-mirror-parity // ratchet stays at zero drift for this pair. navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Event-click navigation behaviour (drawer/modal/split/popover/page/new_window/none), the same block ListViewSchema.navigation declares; the renderer defaults an absent key to a drawer'), + // objectstack#25923 (work item 4) — two NODE members `ComponentPropsMap['object-calendar']` + // declares and `plugin-calendar`'s registration boundary reads off the node + // (`resolveAuthoredLocale(rest.locale)`, `resolveExternalLoading(rest.loading)`), + // which neither face of this package named. The row's own members BY + // REFERENCE. Neither is one of the five calendar field-name keys item 1 moved + // into the `calendar` block; the spec authors both on the node itself. + locale: stripImportedDefaults(SpecObjectCalendarPropsSchema).shape.locale, + loading: stripImportedDefaults(SpecObjectCalendarPropsSchema).shape.loading, // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. body: retirementTombstone(OBJECT_CALENDAR_NEITHER_CHANNEL), From 7a58354af92383502625909b2cd6abaa634223dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:31:47 +0000 Subject: [PATCH 2/5] chore(changeset): types spec-only keys and CRUDActionSchema rename Claude-Session: https://claude.ai/code/session_01WGufzhgTn8yFwqXxanJd4c Co-authored-by: Claude --- ...25923-spec-only-keys-crud-action-schema.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/25923-spec-only-keys-crud-action-schema.md diff --git a/.changeset/25923-spec-only-keys-crud-action-schema.md b/.changeset/25923-spec-only-keys-crud-action-schema.md new file mode 100644 index 00000000000..6fcd360c956 --- /dev/null +++ b/.changeset/25923-spec-only-keys-crud-action-schema.md @@ -0,0 +1,25 @@ +--- +'@object-ui/types': minor +--- + +refactor(types)!: `object-form` and `object-calendar` declare the four spec keys their renderers read, and `@object-ui/types/zod` renames its legacy `ActionSchema` to `CRUDActionSchema` (#25923) + +Clause-②: yes (narrowing) +Metadata-protocol: narrowed + + + +`@object-ui/types` mirrors `@objectstack/spec` schemas so that `objectui validate` and `os validate` give one verdict. Over every schema a mirror pairs with (same-name exports, and the `ComponentPropsMap` row of each node type), eleven spec keys were neither declared nor refused on objectui's side. Four of them are read by a renderer, and those are now declared: + +- **`ObjectFormSchema`** (TypeScript and zod) declares `contentLayout` and `confirmOnDiscard`, the `ComponentPropsMap['object-form']` members by reference. `ModalForm` reads `contentLayout`; `ModalForm` and `DrawerForm` read `confirmOnDiscard`. +- **`ObjectCalendarSchema`** (TypeScript and zod) declares `locale` and `loading`, the `ComponentPropsMap['object-calendar']` members by reference. `plugin-calendar` reads both off the node. They are node keys, not the calendar field-name keys that live in the `calendar` block. + +The other seven are the spec form-view entry's keys on `FormFieldSchema`, which is objectui's runtime form field, a different layer under the same name. `normalizeSectionField` in `@object-ui/plugin-form` translates between the two layers, and each key's handling is now recorded beside the mirrors. + +The legacy `type: 'action'` node's zod schema was exported as `ActionSchema`, the name of `@objectstack/spec/ui`'s action metadata schema, with which it shares no vocabulary. It is now `CRUDActionSchema`, beside `CRUDDialogSchema` and `CRUDComponentSchema`; what it accepts is unchanged. Its TypeScript twin keeps the deprecated `ActionSchema` name in `@object-ui/types`. + +**BREAKING** for code that imports the zod const by its old name, and for nodes that carry a value the spec refuses on the four keys. + +- **Before.** `import { ActionSchema } from '@object-ui/types/zod'` resolved to the legacy action node's schema. `ObjectFormSchema.safeParse({ type: 'object-form', objectName: 'account', mode: 'create', contentLayout: 'tabs' })` succeeded and kept the value, which stacked the sections. +- **Now.** The old import name is gone. The zod mirrors refuse `contentLayout: 'tabs'`, a non-boolean `confirmOnDiscard` or `loading`, and a non-string `locale`, at the key's own path, as the spec does. +- **Fix.** FROM `import { ActionSchema } from '@object-ui/types/zod'` TO `import { CRUDActionSchema } from '@object-ui/types/zod'`. Write `contentLayout` as `'simple'` or `'tabbed'`. From 012d9d0c0feb0f10ac12ea381b013cd44e010463 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:00:51 +0000 Subject: [PATCH 3/5] test(types): ObjectFormSchema declares 71 members (#25923) The member-count pin in object-view-slot-key-lists moves from 69 to 71: `contentLayout` and `confirmOnDiscard` are now declared by reference to `ComponentPropsMap['object-form']`, and the `form` slot carries both. Claude-Session: https://claude.ai/code/session_01WGufzhgTn8yFwqXxanJd4c Co-authored-by: Claude --- .../types/src/__tests__/object-view-slot-key-lists.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/packages/types/src/__tests__/object-view-slot-key-lists.test.ts b/ui/packages/types/src/__tests__/object-view-slot-key-lists.test.ts index 0ff97ddcf9d..38292452fc3 100644 --- a/ui/packages/types/src/__tests__/object-view-slot-key-lists.test.ts +++ b/ui/packages/types/src/__tests__/object-view-slot-key-lists.test.ts @@ -292,9 +292,12 @@ describe('the source schemas still declare their full member sets', () => { expect(declaresStringIndex(grid)).toBe(false); }); - it('ObjectFormSchema declares 69 members and no longer carries the #5155 index signature', () => { + // 71 since objectstack#25923 (work item 4) declared `contentLayout` and + // `confirmOnDiscard` by reference to `ComponentPropsMap['object-form']`; the + // `form` slot carries both. + it('ObjectFormSchema declares 71 members and no longer carries the #5155 index signature', () => { const form = exportedType('ObjectFormSchema'); - expect(memberNames(form)).toHaveLength(69); + expect(memberNames(form)).toHaveLength(71); expect(memberNames(form)).toEqual(expect.arrayContaining(['fields', 'sections', 'submitText'])); expect(declaresStringIndex(form)).toBe(false); }); From d18ba32ea2b1d3cc677a06ecd21aa32a8e550494 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:54:10 +0000 Subject: [PATCH 4/5] test(types): mark the spec-only-key census a sanctioned spec/ui namespace importer (#25923) The census walks the whole `@objectstack/spec/ui` surface to pair each mirror with its same-name spec schema, so the namespace import trips the #3090 `no-restricted-imports` entry for the spec `FormFieldSchema`. It reads the schemas at runtime only; the disable carries that reason, as the other sanctioned namespace importers do. Claude-Session: https://claude.ai/code/session_01WGufzhgTn8yFwqXxanJd4c Co-authored-by: Claude --- ui/packages/types/src/__tests__/zod-mirror-parity.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts b/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts index 7af68bd4b72..4e094a77087 100644 --- a/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/ui/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -958,6 +958,10 @@ import { dirname, join } from 'node:path'; import ts from 'typescript'; import type { z } from 'zod'; import type { WithoutCatchall } from './zod-catchall-type'; +// The spec-only-key census (objectstack#25923) walks the WHOLE spec/ui surface to +// pair each mirror with its same-name spec schema — the namespace import is its +// instrument, read at runtime only, a sanctioned importer (#3090 tripwire). +// eslint-disable-next-line no-restricted-imports import * as SpecUi from '@objectstack/spec/ui'; import * as SpecData from '@objectstack/spec/data'; import * as ZodBarrel from '../zod/index.zod.js'; From 38c2841aebc947ce9f3abab447b64faefb8e58a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:10:45 +0000 Subject: [PATCH 5/5] docs(types): the retired condition shape is refused by CRUDActionSchema (#25923) The enhanced-actions page still named the zod schema `ActionSchema`; the const is `CRUDActionSchema` on `@object-ui/types/zod` since the rename. Claude-Session: https://claude.ai/code/session_01WGufzhgTn8yFwqXxanJd4c Co-authored-by: Claude --- ui/content/docs/core/enhanced-actions.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/content/docs/core/enhanced-actions.mdx b/ui/content/docs/core/enhanced-actions.mdx index 41fffa688ec..c2de567049b 100644 --- a/ui/content/docs/core/enhanced-actions.mdx +++ b/ui/content/docs/core/enhanced-actions.mdx @@ -301,8 +301,9 @@ for an either/or. > `else`. The runner has always read this key as the predicate above, and an > object without a `source` reads as "no gate declared", so an action written > that way ran **unconditionally**, with no error at author time and no -> diagnostic at runtime. The shape is now refused by `ActionSchema`'s zod schema -> instead of being accepted and ignored. +> diagnostic at runtime. The shape is now refused by the zod schema +> (`CRUDActionSchema` in `@object-ui/types/zod`) instead of being accepted and +> ignored. ## Post-success behaviour