From 9fe676e31cc761ee1910b26969cfc30d7af26db7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 17:13:27 +0000 Subject: [PATCH 1/2] feat(types): arm `object-timeline` by reference to its spec row (objectui#10859, batch 3) `safeValidateSchema` refused every `object-timeline` document at `type`: the block is registered and curated, and `@objectstack/spec` 17.5.0 declares its `ComponentPropsMap` row, but `AnyComponentSchema` carried no arm. `ObjectTimelineBlockSchema` is built the way batch 2 built `object-metric`: `BaseSchema` + the literal + `properties` = the row by reference through the import boundary (its spec defaults stripped), plus the node's `dataSource` binding as the spec's `ElementDataSourceSchema`, and the objectui#9256 refusals of both content channels. It joins `ObjectQLPublicBlockComponentSchema`. The bare-key ratchet falls 73 -> 72; the census rows the new export and the new spec import force are added. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- .changeset/10859-object-timeline-zod-arm.md | 15 ++ ...tered-types-validate-ratchet-10859.test.ts | 12 +- .../__tests__/imported-defaults-8317.test.ts | 5 + .../object-timeline-arm-10859-b3.test.ts | 244 ++++++++++++++++++ .../registered-type-arms-10859-b2.test.ts | 7 +- .../src/__tests__/zod-mirror-parity.test.ts | 5 +- packages/types/src/zod/README.md | 6 +- packages/types/src/zod/index.zod.ts | 3 +- packages/types/src/zod/objectql.zod.ts | 89 ++++++- 9 files changed, 371 insertions(+), 15 deletions(-) create mode 100644 .changeset/10859-object-timeline-zod-arm.md create mode 100644 packages/types/src/__tests__/object-timeline-arm-10859-b3.test.ts diff --git a/.changeset/10859-object-timeline-zod-arm.md b/.changeset/10859-object-timeline-zod-arm.md new file mode 100644 index 0000000000..9e062ed289 --- /dev/null +++ b/.changeset/10859-object-timeline-zod-arm.md @@ -0,0 +1,15 @@ +--- +'@object-ui/types': minor +--- + +`safeValidateSchema` — and so `objectui validate` — accepts one more registered node type: `object-timeline` (objectui#10859, batch 3). + +**Clause-②: yes** — the accept set of `AnyComponentSchema` widens by one `type` literal, and `@object-ui/types/zod` exports one new schema. Nothing that parsed before is refused now: every document naming `object-timeline` was refused at `type`, at the root and at every child slot. + +**What it was.** `object-timeline` is an ADR-0080 public block registered by `@object-ui/plugin-timeline`, and since `@objectstack/spec` 17.5.0 the spec declares its props as the `ComponentPropsMap['object-timeline']` row. `AnyComponentSchema` carried no arm for it, so every document naming it was refused with `invalid_union` at `type`. + +**What changed, in observable terms.** + +- `@object-ui/types/zod` exports `ObjectTimelineBlockSchema`, built the way `ObjectMetricBlockSchema` is: `BaseSchema`, the `type` literal, and `properties`, which is the spec row by reference, so the spec's members, value types and strictness apply to the bag unchanged. The row's defaults (`timeline.scale`, the `navigation` members) are removed at the import boundary, so a parse writes nothing into the document (objectui#8317). +- The node also declares `dataSource`, the spec's `ElementDataSourceSchema` by reference — the per-element binding the renderer reads through `ElementDataSourceGate`, declared the same way on the other gate-wrapped arms (objectui#11070). `body` and `children` are refused by name: the renderer reads neither content channel (objectui#9256). +- It reaches `AnyComponentSchema` through `ObjectQLPublicBlockComponentSchema`, beside `object-metric` and `object-master-detail-form`. The strict authoring face (`StrictAnyComponentSchema`) derives from it and accepts the same documents, closed to undeclared keys. As on those two blocks, a prop written flat on the node instead of inside `properties` is not judged against the spec row: a key `BaseSchema` does not declare passes the tolerant face unjudged and is refused by name on the strict face. 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 c761845423..c8fa47ba07 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 @@ -66,10 +66,11 @@ import { validate } from '../commands/validate.js'; /** * The head's refused count (objectui#10859 batch 1: 79 on `main` before it, * minus the three `@object-ui/plugin-ai` arms; batch 2: minus `pivot`, - * `object-metric` and `object-master-detail-form`). LOWER it when a batch arms - * more keys; never raise it. + * `object-metric` and `object-master-detail-form`; batch 3: minus + * `object-timeline`, armed from its `@objectstack/spec` 17.5.0 row). LOWER it + * when a batch arms more keys; never raise it. */ -const REFUSED_AT_TYPE = 73; +const REFUSED_AT_TYPE = 72; /** * The head's refused count over the NAMESPACED keys (objectui#10872 batch 1: @@ -162,6 +163,11 @@ describe('registered component types refused at `type` — a ratchet (objectui#1 expect(refusedAtType(key), key).toBe(false); } }); + + it('counts the key batch 3 armed (objectui#10859 batch 3)', () => { + expect(BARE_KEYS).toContain('object-timeline'); + expect(refusedAtType('object-timeline')).toBe(false); + }); }); describe('registered NAMESPACED component types refused at `type` — a ratchet (objectui#10872)', () => { diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index d1f26daa99..a0ad2725d2 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -84,6 +84,7 @@ import { // blocks `objectql.zod.ts` arms. ObjectMetricPropsSchema as SpecObjectMetricPropsSchema, ObjectMasterDetailFormPropsSchema as SpecObjectMasterDetailFormPropsSchema, + ObjectTimelinePropsSchema as SpecObjectTimelinePropsSchema, // objectui#10872 — the `ComponentPropsMap` rows the public-block arms read. PageHeaderProps as SpecPageHeaderProps, PageTabsProps as SpecPageTabsProps, @@ -249,6 +250,10 @@ const IMPORTED: Array = [ // boundary, so both rows are measured here like every other crossing. ['ObjectMetricPropsSchema', SpecObjectMetricPropsSchema], ['ObjectMasterDetailFormPropsSchema', SpecObjectMasterDetailFormPropsSchema], + // objectui#10859 batch 3: the `object-timeline` arm's `properties` is its + // row, crossed the same way — and unlike the two above it carries spec + // defaults (`timeline.scale`, the `navigation` members) for the strip to remove. + ['ObjectTimelinePropsSchema', SpecObjectTimelinePropsSchema], // objectui#10872: each ADR-0080 public-block arm's `properties` is the // block's `ComponentPropsMap` row, crossed through this boundary, so every // row is measured here like every other crossing (`page:section`, diff --git a/packages/types/src/__tests__/object-timeline-arm-10859-b3.test.ts b/packages/types/src/__tests__/object-timeline-arm-10859-b3.test.ts new file mode 100644 index 0000000000..8d2df9f645 --- /dev/null +++ b/packages/types/src/__tests__/object-timeline-arm-10859-b3.test.ts @@ -0,0 +1,244 @@ +/** + * 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. + */ + +/** + * `object-timeline` has a zod arm in `AnyComponentSchema` (objectui#10859, + * batch 3). + * + * ## The defect this pins + * + * `object-timeline` is registered (`@object-ui/plugin-timeline`), curated by + * ADR-0080 as a public block (`PUBLIC_BLOCKS` in `@object-ui/core`), and since + * `@objectstack/spec` 17.5.0 declared by the spec's + * `ComponentPropsMap['object-timeline']` row — and `AnyComponentSchema` carried + * no arm for it, so `safeValidateSchema`, and `objectui validate` with it, + * refused every document naming it with `invalid_union` at `type`. + * + * The arm is built the way batch 2 built `object-metric` and + * `object-master-detail-form` (route A: a spec `ComponentPropsMap` row counts + * as a published declaration, and `properties` is judged by reference), so this + * file holds the same things `registered-type-arms-10859-b2.test.ts` holds for + * those two, plus what differs here: the node's `dataSource` binding, and a row + * that carries spec defaults the import boundary has to strip. + * + * ## Two instruments, and which half each one reads + * + * The `Expect` / `Equal` block below is TYPE-level: `tsc -p tsconfig.test.json` + * (the third leg of this package's `type-check` script) reads it, and vitest — + * which strips types — does not. The `describe` blocks are RUNTIME and vitest + * reads them. A green run of either one alone says nothing about the other. + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + ElementDataSourceSchema as SpecElementDataSourceSchema, + ObjectTimelinePropsSchema as SpecObjectTimelinePropsSchema, + type ObjectTimelineProps as SpecObjectTimelineProps, +} from '@objectstack/spec/ui'; + +import { + ObjectQLPublicBlockComponentSchema, + ObjectTimelineBlockSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; + +/* ── Type-level parity: the `tsc` channel ────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** The arm's own shape. */ +type ShapeOf = M extends { shape: infer S } ? S : never; +/** What a shape entry ACCEPTS (input side, so `.optional()` shows). */ +type InputOf = T extends z.ZodType ? z.input : never; + +/** + * The bag accepts exactly the spec's published props type (absent allowed), + * the node's binding accepts exactly the spec's element binding, and neither + * content channel accepts anything. + */ +export type assertionTimelineArmIsTheRow = [ + Expect['properties']>, SpecObjectTimelineProps | undefined>>, + Expect< + Equal< + InputOf['dataSource']>, + z.input | undefined + > + >, + Expect['children']>, undefined>>, + Expect['body']>, undefined>>, +]; + +/** Non-vacuity: a bag narrower than the spec's props type is not Equal to it. */ +export type assertionInstrumentFires = [ + Expect, false>>, +]; + +/* ── Runtime: the documents that were refused ────────────────────────────── */ + +/** + * A spec-shaped document using most of the row: the object, the nested + * `timeline` config, a filter, a sort, a window, a layout and a navigation + * mode. Its lit control is the spec's own row, below. + */ +const SPEC_SHAPED = { + type: 'object-timeline', + properties: { + objectName: 'project_task', + timeline: { startDateField: 'start_date', endDateField: 'due_date', titleField: 'name', groupByField: 'status' }, + filter: [{ field: 'status', operator: 'not_equals', value: 'done' }], + sort: [{ field: 'start_date', order: 'asc' }], + limit: 50, + variant: 'horizontal', + dateFormat: 'short', + descriptionField: 'summary', + navigation: { mode: 'drawer' }, + }, +}; + +/** The same block bound through the node's `dataSource`, the spec's per-element binding. */ +const BOUND = { + type: 'object-timeline', + dataSource: { object: 'project_task', filter: [{ field: 'owner', operator: 'equals', value: 'ada' }], limit: 20 }, + properties: { timeline: { startDateField: 'start_date', titleField: 'name' } }, +}; + +/** The first issue of a refused parse, or a message that says it was not refused. */ +function firstIssue(result: ReturnType) { + if (result.success) throw new Error('expected a refusal, the document parsed'); + return result.error.issues[0]; +} + +describe('object-timeline validates (objectui#10859 batch 3)', () => { + it.each([ + ['the bare node', { type: 'object-timeline' }], + ['a spec-shaped bag', SPEC_SHAPED], + ['a node bound through `dataSource`', BOUND], + ] as const)('%s is accepted by safeValidateSchema and by the strict authoring face', (_label, doc) => { + const tolerant = safeValidateSchema(doc); + expect(tolerant.success, JSON.stringify(tolerant.success ? null : tolerant.error.issues)).toBe(true); + const strict = StrictAnyComponentSchema.safeParse(doc); + expect(strict.success, JSON.stringify(strict.success ? null : strict.error.issues)).toBe(true); + }); + + it('the spec-shaped fixture is spec-valid by the spec\'s own row (lit control)', () => { + expect(SpecObjectTimelinePropsSchema.safeParse(SPEC_SHAPED.properties).success).toBe(true); + expect(SpecElementDataSourceSchema.safeParse(BOUND.dataSource).success).toBe(true); + }); + + it('reaches the arm at a child slot too — a page holding the block', () => { + const page = { type: 'page', children: [SPEC_SHAPED, BOUND] }; + expect(safeValidateSchema(page).success).toBe(true); + }); + + it('authors no default into the document: the row\'s `timeline.scale` default is stripped (objectui#8317)', () => { + const doc = { type: 'object-timeline', properties: { timeline: { startDateField: 's', titleField: 't' } } }; + // Control: the spec's own row writes a default `scale` into its output. + const specOut = SpecObjectTimelinePropsSchema.parse(doc.properties) as { timeline: Record }; + expect(specOut.timeline.scale).toBeDefined(); + // This face validates and writes nothing. + const result = safeValidateSchema(doc); + expect(result.success).toBe(true); + if (!result.success) return; + const out = result.data as unknown as { properties: { timeline: Record } }; + expect(Object.keys(out.properties.timeline).sort()).toEqual(['startDateField', 'titleField']); + }); +}); + +describe('the object-timeline arm is closed where its declaration is (objectui#10859 batch 3)', () => { + it('an undeclared node key is refused on the strict face, by name — the strictness control', () => { + const doc = { type: 'object-timeline', inventedKey10859b3: true }; + const strict = StrictAnyComponentSchema.safeParse(doc); + expect(strict.success).toBe(false); + if (strict.success) return; + const issue = strict.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue, JSON.stringify(strict.error.issues)).toBeDefined(); + expect((issue as { keys?: string[] }).keys).toEqual(['inventedKey10859b3']); + // The rendering face keeps its `.passthrough()`, as on every other arm. + expect(safeValidateSchema(doc).success).toBe(true); + }); + + it.each([ + ['an invented key', 'inventedKey10859b3'], + // The row leaves the flat field spellings beside `timeline` out on purpose + // (the spec's record for the row: the runtime handoff `ListView` composes, + // not a second authoring spelling), so the bag refuses one by name. + ['a flat field spelling the row leaves out', 'titleField'], + ] as const)('%s inside the bag is refused on the TOLERANT face, by name', (_label, key) => { + const doc = { type: 'object-timeline', properties: { objectName: 'task', [key]: 'name' } }; + const issue = firstIssue(safeValidateSchema(doc)); + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.path).toEqual(['properties']); + expect((issue as { keys?: string[] }).keys).toEqual([key]); + }); + + it.each([ + ['variant', { variant: 'grid' }, ['properties', 'variant'], 'invalid_value'], + ['dateFormat', { dateFormat: 'relative' }, ['properties', 'dateFormat'], 'invalid_value'], + ['limit', { limit: '50' }, ['properties', 'limit'], 'invalid_type'], + ['timeline.titleField', { timeline: { startDateField: 's' } }, ['properties', 'timeline', 'titleField'], 'invalid_type'], + ] as const)('a spec-invalid `%s` is refused at that member', (_key, bag, path, code) => { + const issue = firstIssue(safeValidateSchema({ type: 'object-timeline', properties: bag })); + expect(issue.code).toBe(code); + expect(issue.path).toEqual(path); + }); + + it('`dataSource` is judged as the spec binding on both faces, and a misspelling beside it is refused by name', () => { + const adapterShaped = { type: 'object-timeline', dataSource: 'objectstack' }; + expect(safeValidateSchema(adapterShaped).success).toBe(false); + expect(StrictAnyComponentSchema.safeParse(adapterShaped).success).toBe(false); + const misspelt = { ...BOUND, dataSourc: BOUND.dataSource }; + const strict = StrictAnyComponentSchema.safeParse(misspelt); + expect(strict.success).toBe(false); + if (strict.success) return; + const issue = strict.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual(['dataSourc']); + }); + + it.each(['body', 'children'] as const)('refuses the `%s` content channel by name (objectui#9256)', (key) => { + const issue = firstIssue(safeValidateSchema({ type: 'object-timeline', [key]: [{ type: 'text', content: 'x' }] })); + expect(issue.path).toEqual([key]); + expect(issue.message).toContain('objectui#9256'); + expect(issue.message).toContain('`object-timeline`'); + }); +}); + +describe('the object-timeline arm reads the row by reference (objectui#10859 batch 3)', () => { + it('the bag is the spec row, through the import boundary', () => { + const bag = ObjectTimelineBlockSchema.shape.properties.unwrap(); + const keysOf = (schema: unknown) => Object.keys((schema as { shape: Record }).shape).sort(); + // The same members, read off the installed spec on every run — not a + // transcribed list that could drift from it. + expect(keysOf(bag)).toEqual(keysOf(SpecObjectTimelinePropsSchema)); + expect(keysOf(bag).length).toBeGreaterThan(10); + // The row carries spec defaults, so the boundary hands back a rebuilt copy + // rather than the export itself — and that copy accepts exactly what the + // spec's row accepts. + for (const probe of [ + {}, + { objectName: 'order' }, + { inventedKey10859b3: 1 }, + { objectName: 7 }, + { variant: 'gantt', items: [] }, + { timeline: { startDateField: 's', titleField: 't', scale: 'week' } }, + { timeline: { startDateField: 's', titleField: 't', scale: 'decade' } }, + { navigation: { mode: 'drawer', width: 480 } }, + ]) { + expect(bag.safeParse(probe).success, JSON.stringify(probe)).toBe(SpecObjectTimelinePropsSchema.safeParse(probe).success); + } + }); + + it('is reachable from AnyComponentSchema through its category union', () => { + const literals = ObjectQLPublicBlockComponentSchema.options.map((arm) => arm.shape.type.value); + expect(literals).toContain('object-timeline'); + }); +}); diff --git a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts index 84c216e3b2..1a7e05fb15 100644 --- a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts +++ b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts @@ -316,7 +316,12 @@ describe('the spec-row arms read the row by reference (objectui#10859)', () => { it('each arm is reachable from AnyComponentSchema through its category union', () => { const literals = (union: { options: readonly { shape: { type: z.ZodLiteral } }[] }) => union.options.map((arm) => arm.shape.type.value); - expect(literals(ObjectQLPublicBlockComponentSchema)).toEqual(['object-metric', 'object-master-detail-form']); + // `object-timeline` joined the union in batch 3 (`object-timeline-arm-10859-b3.test.ts`). + expect(literals(ObjectQLPublicBlockComponentSchema)).toEqual([ + 'object-metric', + 'object-master-detail-form', + 'object-timeline', + ]); expect(literals(DataDisplaySchema)).toContain('pivot'); }); }); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 1c52f794ba..95d5341910 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4431,8 +4431,11 @@ const EXCLUSIONS: Readonly> = { "spec-owned BY REFERENCE — `BaseSchema` + the `object-metric` literal + `properties`, which IS `ComponentPropsMap['object-metric']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'objectql.zod.ts#ObjectMasterDetailFormBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `object-master-detail-form` literal + `properties`, which IS `ComponentPropsMap['object-master-detail-form']`, + three `handlerKeyRefusal` runtime slots, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node (`@object-ui/plugin-form`'s `MasterDetailFormSchema` is the type of `MasterDetailForm`'s `schema` prop, the renderer's post-hoist reading)", + // objectui#10859 batch 3 — the third, built the same way from its 17.5.0 row. + 'objectql.zod.ts#ObjectTimelineBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `object-timeline` literal + `properties`, which IS `ComponentPropsMap['object-timeline']`, + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema` (`PageComponentSchema.dataSource`), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node (`@object-ui/plugin-timeline`'s `ObjectTimelineProps` are the component's React props)", 'objectql.zod.ts#ObjectQLPublicBlockComponentSchema': - "a union OVER the two public-block arms above, not an object of its own — its members are checked individually", + "a union OVER the public-block arms above, not an object of its own — its members are checked individually", 'overlay.zod.ts#MenuItemSchema': "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", 'overlay.zod.ts#OverlaySchema': diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index d1ce278e33..1bdf1f0f1e 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -231,9 +231,9 @@ function validateComponent(config: unknown) { - `KanbanSchema`, `CalendarViewSchema` - `FilterBuilderSchema`, `CarouselSchema`, `ChatbotSchema` -### ObjectQL Public Blocks (2) -The ADR-0080 public blocks `object-metric` and `object-master-detail-form`: each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. Neither renderer reads a content channel, so both arms refuse `children` and `body` by name (objectui#9256). -- `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema` +### ObjectQL Public Blocks (3) +The ADR-0080 public blocks `object-metric`, `object-master-detail-form` and `object-timeline`: each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. `ObjectTimelineBlockSchema` also declares the node's `dataSource` binding, the spec's `ElementDataSourceSchema`. None of the three renderers reads a content channel, so every arm refuses `children` and `body` by name (objectui#9256). +- `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema`, `ObjectTimelineBlockSchema` ### AI Components (3) - `AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema` diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 8b8275848f..9cbf93ea47 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -326,10 +326,11 @@ export { ObjectDataTableSchema, ListViewSchema, ObjectQLComponentSchema, - // objectui#10859 batch 2 — two ADR-0080 public blocks armed from their + // objectui#10859 batches 2 and 3 — ADR-0080 public blocks armed from their // `ComponentPropsMap` rows, and the union they reach `AnyComponentSchema` by. ObjectMetricBlockSchema, ObjectMasterDetailFormBlockSchema, + ObjectTimelineBlockSchema, ObjectQLPublicBlockComponentSchema, } from './objectql.zod.js'; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 13cd033f88..71b873d0a0 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -45,11 +45,12 @@ import { // objectui#7928 — the spec's view CONTAINER, read for ONE slot: its // `listViews` record (`ObjectViewSchema.listViews` below, by reference). ViewSchema as SpecViewSchema, - // objectui#10859 (batch 2) — the `ComponentPropsMap` rows of the two + // objectui#10859 (batches 2 and 3) — the `ComponentPropsMap` rows of the // ADR-0080 public blocks this module arms, each read as its arm's // `properties` bag, by reference (`ObjectQLPublicBlockComponentSchema` below). ObjectMetricPropsSchema as SpecObjectMetricPropsSchema, ObjectMasterDetailFormPropsSchema as SpecObjectMasterDetailFormPropsSchema, + ObjectTimelinePropsSchema as SpecObjectTimelinePropsSchema, // objectui#11070 — the per-element data binding (`PageComponentSchema.dataSource`) // the object-bound arms below declare as `dataSource`, by reference. ElementDataSourceSchema as SpecElementDataSourceSchema, @@ -3342,7 +3343,7 @@ export const ObjectQLComponentSchema = z.discriminatedUnion('type', [ /* ── ADR-0080 public blocks of this family, armed from their spec rows ───── */ -// The `properties` member of each of the two blocks below is `propsBag` from +// The `properties` member of each block below is `propsBag` from // `./public-blocks.zod.ts` — the one helper every public-block arm uses // (objectui#10872), imported rather than restated. @@ -3461,16 +3462,92 @@ export const ObjectMasterDetailFormBlockSchema = BaseSchema.extend({ children: retirementTombstone(OBJECT_MASTER_DETAIL_FORM_NEITHER_CHANNEL), }); +/** objectui#9256 (public-block slice): ONE refusal string for both content channels of `object-timeline`. */ +const OBJECT_TIMELINE_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'object-timeline', + 'its registration (`plugin-timeline:object-timeline`) hands the node through `ElementDataSourceGate` to ' + + '`ObjectTimeline`, which fetches or takes its rows and draws them with `TimelineRenderer`; neither reads a ' + + 'child list', + 'one rail of records from `objectName` (or the node\'s `dataSource`), laid out by `variant` from the fields ' + + 'the nested `timeline` config names', +); + +/** + * `object-timeline` — `ComponentPropsMap['object-timeline']`, plus the node's + * `dataSource` binding (objectui#10859, batch 3). + * + * ## Why it is armed here, and from what + * + * `object-timeline` is registered by `@object-ui/plugin-timeline` + * (`ObjectTimelineRenderer`, which renders `ObjectTimeline`), curated by + * ADR-0080 as a public block (`PUBLIC_BLOCKS` in `@object-ui/core`), and + * declared by the spec since `@objectstack/spec` 17.5.0 — and until this arm + * `AnyComponentSchema` carried none for it, so `safeValidateSchema` and + * `objectui validate` refused every document naming it with `invalid_union` + * at `type`. + * + * `@object-ui/types` has no TypeScript declaration of this node (the plugin's + * `ObjectTimelineProps` are the component's React props, host callbacks + * among them, not a document shape), so the arm is built exactly as + * `ObjectMetricBlockSchema` above is: `BaseSchema` + the `type` literal + + * `properties`, which IS the row, by reference through the objectui#8317 + * import boundary. Unlike the two rows above, this one carries spec defaults + * (`timeline.scale`, the `navigation` members), so the boundary hands back a + * rebuilt copy with every default removed and every key still omissible: + * this face judges the bag and writes nothing into it. + * + * ## Where the props live + * + * In the bag, as on its neighbours above: `SchemaRenderer` hoists + * `properties` onto the node before the renderer runs, so the spec's + * `{ type, properties }` document renders what the flat one does. ⚠️ A key + * written FLAT on the node is not judged against the row: it passes the + * tolerant face unjudged and is refused by the strict authoring face. Inside + * the bag, the row itself leaves out the flat field spellings beside + * `timeline` (`titleField`, `startDateField` and their siblings) and `scale`: + * the spec's record for the row calls them the runtime handoff `ListView` + * composes, not a second authoring spelling, so the bag refuses them by name. + * + * ## `dataSource` (the node's binding) + * + * The registration is wrapped in `elementDataSourceBlock`, so the node reaches + * `ObjectTimeline` through `ElementDataSourceGate`, which reads the node's + * `dataSource` and maps its `object`, `filter`, `sort` and `limit` onto the + * fetch. That key is not a props-row member: it is the spec's + * `PageComponentSchema.dataSource`, so it is declared as the spec's + * `ElementDataSourceSchema`, by reference — as `object-grid` and the other + * gate-wrapped arms of this module declare it (objectui#11070), and as + * `element:number` does beside its own row. + * + * ## The content channels (objectui#9256) + * + * Neither `ObjectTimeline` nor the renderer it composes reads a content + * channel, so the arm refuses `children` by name and restates `body` with the + * same guidance, both kept MEMBERS — as the two arms above do. + */ +export const ObjectTimelineBlockSchema = BaseSchema.extend({ + type: z.literal('object-timeline'), + properties: propsBag('object-timeline', stripImportedDefaults(SpecObjectTimelinePropsSchema)), + dataSource: stripImportedDefaults(SpecElementDataSourceSchema) + .optional() + .describe(ELEMENT_DATA_SOURCE_BINDING_DESCRIPTION), + // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each + // kept a MEMBER. + body: retirementTombstone(OBJECT_TIMELINE_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_TIMELINE_NEITHER_CHANNEL), +}); + /** - * The two public blocks above, as one arm of `AnyComponentSchema` - * (objectui#10859, batch 2). + * The public blocks above, as one arm of `AnyComponentSchema` + * (objectui#10859, batches 2 and 3). * - * A union of its own rather than two more members of `ObjectQLComponentSchema`, + * A union of its own rather than more members of `ObjectQLComponentSchema`, * deliberately: that union mirrors the TypeScript union in `../objectql.ts` - * member for member, and neither block has a declaration there — their + * member for member, and none of these blocks has a declaration there — their * declaration is the spec row each `properties` member reads. */ export const ObjectQLPublicBlockComponentSchema = z.discriminatedUnion('type', [ ObjectMetricBlockSchema, ObjectMasterDetailFormBlockSchema, + ObjectTimelineBlockSchema, ]); From 26f8120ca26ed22e9e5e703e7779f28e231dce76 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:05:35 +0000 Subject: [PATCH 2/2] feat(types): declare `dataSource` on the batch-2 public-block arms; de-count the propsBag docblock (objectui#10859, batch 3 patch 1) `object-metric` and `object-master-detail-form` are elementDataSourceBlock- wrapped and read the node's `dataSource` through ElementDataSourceGate, but their arms did not declare it, so the strict authoring face refused a spec-valid bound node by name. Both now declare it with the construct the `object-timeline` arm uses: the spec's ElementDataSourceSchema by reference, described by the module's ELEMENT_DATA_SOURCE_BINDING_DESCRIPTION. One strict-face row per arm in the batch-2 pin. The propsBag docblock no longer counts its importers ("two" was false once `object-timeline` joined). Existing parity rows, the README and the pending changeset name the new member; no census row added. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- .changeset/10859-object-timeline-zod-arm.md | 3 +- .../registered-type-arms-10859-b2.test.ts | 36 ++++++++++++++++++- .../src/__tests__/zod-mirror-parity.test.ts | 4 +-- packages/types/src/zod/README.md | 2 +- packages/types/src/zod/objectql.zod.ts | 20 +++++++++++ packages/types/src/zod/public-blocks.zod.ts | 5 ++- 6 files changed, 62 insertions(+), 8 deletions(-) diff --git a/.changeset/10859-object-timeline-zod-arm.md b/.changeset/10859-object-timeline-zod-arm.md index 9e062ed289..e06f32c359 100644 --- a/.changeset/10859-object-timeline-zod-arm.md +++ b/.changeset/10859-object-timeline-zod-arm.md @@ -12,4 +12,5 @@ - `@object-ui/types/zod` exports `ObjectTimelineBlockSchema`, built the way `ObjectMetricBlockSchema` is: `BaseSchema`, the `type` literal, and `properties`, which is the spec row by reference, so the spec's members, value types and strictness apply to the bag unchanged. The row's defaults (`timeline.scale`, the `navigation` members) are removed at the import boundary, so a parse writes nothing into the document (objectui#8317). - The node also declares `dataSource`, the spec's `ElementDataSourceSchema` by reference — the per-element binding the renderer reads through `ElementDataSourceGate`, declared the same way on the other gate-wrapped arms (objectui#11070). `body` and `children` are refused by name: the renderer reads neither content channel (objectui#9256). -- It reaches `AnyComponentSchema` through `ObjectQLPublicBlockComponentSchema`, beside `object-metric` and `object-master-detail-form`. The strict authoring face (`StrictAnyComponentSchema`) derives from it and accepts the same documents, closed to undeclared keys. As on those two blocks, a prop written flat on the node instead of inside `properties` is not judged against the spec row: a key `BaseSchema` does not declare passes the tolerant face unjudged and is refused by name on the strict face. +- `ObjectMetricBlockSchema` and `ObjectMasterDetailFormBlockSchema` declare the same `dataSource` binding, by the same construct: both registrations are gate-wrapped and read it. Both arms are new in this release (`.changeset/10859-pivot-object-block-zod-arms.md`), so this only shapes what they accept on arrival: a bound node parses on both faces, and the key is judged as the binding, so an adapter name (`dataSource: 'objectstack'`) is refused. +- `ObjectTimelineBlockSchema` reaches `AnyComponentSchema` through `ObjectQLPublicBlockComponentSchema`, beside `object-metric` and `object-master-detail-form`. The strict authoring face (`StrictAnyComponentSchema`) derives from it and accepts the same documents, closed to undeclared keys. As on those two blocks, a prop written flat on the node instead of inside `properties` is not judged against the spec row: a key `BaseSchema` does not declare passes the tolerant face unjudged and is refused by name on the strict face. diff --git a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts index 1a7e05fb15..90ac17a2f7 100644 --- a/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts +++ b/packages/types/src/__tests__/registered-type-arms-10859-b2.test.ts @@ -41,6 +41,7 @@ import { describe, expect, it } from 'vitest'; import { z } from 'zod'; import { + ElementDataSourceSchema as SpecElementDataSourceSchema, ObjectMasterDetailFormPropsSchema as SpecObjectMasterDetailFormPropsSchema, ObjectMetricPropsSchema as SpecObjectMetricPropsSchema, type ObjectMasterDetailFormProps as SpecObjectMasterDetailFormProps, @@ -92,7 +93,8 @@ export type assertionPivotMirrorsItsDeclaration = [ /** * The two spec-row arms: the bag accepts exactly the spec's published props * type (absent allowed), and the node adds nothing to `BaseSchema` but `type`, - * the bag and — on the master-detail form — its three runtime slots. + * the bag, the `dataSource` binding (objectui#10859 batch 3, pinned at runtime + * below) and — on the master-detail form — its three runtime slots. */ export type assertionSpecRowArmsAreTheRow = [ Expect['properties']>, SpecObjectMetricProps | undefined>>, @@ -187,6 +189,38 @@ describe('the registered types armed in batch 2 validate (objectui#10859)', () = expect(StrictAnyComponentSchema.safeParse(doc).success).toBe(true); }); + it.each([ + [ + 'object-metric', + { + type: 'object-metric', + dataSource: { object: 'showcase_project', filter: [{ field: 'status', operator: 'equals', value: 'active' }] }, + properties: { label: 'Projects', aggregate: { field: 'id', function: 'count' } }, + }, + ], + [ + 'object-master-detail-form', + { + type: 'object-master-detail-form', + dataSource: { object: 'showcase_project' }, + properties: { mode: 'create', details: [{ title: 'Tasks', childObject: 'showcase_task' }] }, + }, + ], + ] as const)('%s bound through the node\'s `dataSource` is accepted by the strict face (objectui#10859 batch 3)', (type, doc) => { + // The registration is `elementDataSourceBlock`-wrapped, so the node's + // `dataSource` is read through `ElementDataSourceGate`; the key is the + // spec's `PageComponentSchema.dataSource`. Lit control: the binding is + // spec-valid by the spec's own schema. + expect(SpecElementDataSourceSchema.safeParse(doc.dataSource).success).toBe(true); + const strict = StrictAnyComponentSchema.safeParse(doc); + expect(strict.success, JSON.stringify(strict.success ? null : strict.error.issues)).toBe(true); + expect(safeValidateSchema(doc).success).toBe(true); + // Judged as the binding, not merely admitted: an adapter name is refused on both faces. + const adapterShaped = { type, dataSource: 'objectstack' }; + expect(StrictAnyComponentSchema.safeParse(adapterShaped).success).toBe(false); + expect(safeValidateSchema(adapterShaped).success).toBe(false); + }); + it('accepts a fully populated `pivot` document on both faces', () => { // Every member the arm still accepts. `drillDown` is not one of them since // objectui#10932 retired it on this node; its refusal is pinned in diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 95d5341910..14545e92fb 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4428,9 +4428,9 @@ const EXCLUSIONS: Readonly> = { // built the objectui#10872 way: `BaseSchema` + a `type` literal + // `properties`, which is the block's `ComponentPropsMap` row by reference. 'objectql.zod.ts#ObjectMetricBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `object-metric` literal + `properties`, which IS `ComponentPropsMap['object-metric']`, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `object-metric` literal + `properties`, which IS `ComponentPropsMap['object-metric']`, + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema` (objectui#10859 batch 3), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node", 'objectql.zod.ts#ObjectMasterDetailFormBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `object-master-detail-form` literal + `properties`, which IS `ComponentPropsMap['object-master-detail-form']`, + three `handlerKeyRefusal` runtime slots, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node (`@object-ui/plugin-form`'s `MasterDetailFormSchema` is the type of `MasterDetailForm`'s `schema` prop, the renderer's post-hoist reading)", + "spec-owned BY REFERENCE — `BaseSchema` + the `object-master-detail-form` literal + `properties`, which IS `ComponentPropsMap['object-master-detail-form']`, + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema` (objectui#10859 batch 3), + three `handlerKeyRefusal` runtime slots, + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node (`@object-ui/plugin-form`'s `MasterDetailFormSchema` is the type of `MasterDetailForm`'s `schema` prop, the renderer's post-hoist reading)", // objectui#10859 batch 3 — the third, built the same way from its 17.5.0 row. 'objectql.zod.ts#ObjectTimelineBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `object-timeline` literal + `properties`, which IS `ComponentPropsMap['object-timeline']`, + the node's `dataSource` binding, which IS the spec's `ElementDataSourceSchema` (`PageComponentSchema.dataSource`), + the objectui#9256 `body` / `children` refusals (its renderer reads neither channel); no TS declaration in this package restates the node (`@object-ui/plugin-timeline`'s `ObjectTimelineProps` are the component's React props)", diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 1bdf1f0f1e..87e70de1d1 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -232,7 +232,7 @@ function validateComponent(config: unknown) { - `FilterBuilderSchema`, `CarouselSchema`, `ChatbotSchema` ### ObjectQL Public Blocks (3) -The ADR-0080 public blocks `object-metric`, `object-master-detail-form` and `object-timeline`: each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. `ObjectTimelineBlockSchema` also declares the node's `dataSource` binding, the spec's `ElementDataSourceSchema`. None of the three renderers reads a content channel, so every arm refuses `children` and `body` by name (objectui#9256). +The ADR-0080 public blocks `object-metric`, `object-master-detail-form` and `object-timeline`: each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. Each arm also declares the node's `dataSource` binding, the spec's `ElementDataSourceSchema`, which all three registrations read through `ElementDataSourceGate`. None of the three renderers reads a content channel, so every arm refuses `children` and `body` by name (objectui#9256). - `ObjectMetricBlockSchema`, `ObjectMasterDetailFormBlockSchema`, `ObjectTimelineBlockSchema` ### AI Components (3) diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 71b873d0a0..ccd0f908c2 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -3391,6 +3391,16 @@ const OBJECT_METRIC_NEITHER_CHANNEL = neitherContentChannelGuidance( * authoring channel for these blocks is the question objectui#10872 left open * for the whole family; declaring it later is additive. * + * ## `dataSource` (the node's binding) + * + * `ObjectMetricBlock` is `elementDataSourceBlock`-wrapped and reads the node's + * `dataSource` through `ElementDataSourceGate`. That key is the spec's + * `PageComponentSchema.dataSource`, not a props-row member, so it is declared + * as the spec's `ElementDataSourceSchema`, by reference — the construct the + * module's other gate-wrapped arms use (objectui#11070). Without it the strict + * authoring face refused a spec-valid bound node by name (objectui#10859, + * batch 3). `object-master-detail-form` below declares it the same way. + * * ## The content channels (objectui#9256) * * The renderer reads NEITHER content channel, so the arm declares `children` @@ -3402,6 +3412,9 @@ const OBJECT_METRIC_NEITHER_CHANNEL = neitherContentChannelGuidance( export const ObjectMetricBlockSchema = BaseSchema.extend({ type: z.literal('object-metric'), properties: propsBag('object-metric', stripImportedDefaults(SpecObjectMetricPropsSchema)), + dataSource: stripImportedDefaults(SpecElementDataSourceSchema) + .optional() + .describe(ELEMENT_DATA_SOURCE_BINDING_DESCRIPTION), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER. body: retirementTombstone(OBJECT_METRIC_NEITHER_CHANNEL), @@ -3446,6 +3459,10 @@ const OBJECT_MASTER_DETAIL_FORM_NEITHER_CHANNEL = neitherContentChannelGuidance( * SLOT (objectui#6124): refused by name when authored, because JSON has no * function value, rather than left to `.passthrough()` to keep an authored * value and hand it to a call site. + * + * `dataSource` is declared as on `ObjectMetricBlockSchema` above: + * `MasterDetailFormRenderer` is `elementDataSourceBlock`-wrapped and reads the + * node's binding through `ElementDataSourceGate`. */ export const ObjectMasterDetailFormBlockSchema = BaseSchema.extend({ type: z.literal('object-master-detail-form'), @@ -3453,6 +3470,9 @@ export const ObjectMasterDetailFormBlockSchema = BaseSchema.extend({ 'object-master-detail-form', stripImportedDefaults(SpecObjectMasterDetailFormPropsSchema), ), + dataSource: stripImportedDefaults(SpecElementDataSourceSchema) + .optional() + .describe(ELEMENT_DATA_SOURCE_BINDING_DESCRIPTION), onSuccess: handlerKeyRefusal('onSuccess', 'runtime-slot', 'Called with the saved parent record after a successful save'), onError: handlerKeyRefusal('onError', 'runtime-slot', 'Called after a refused save, for bookkeeping only'), onCancel: handlerKeyRefusal('onCancel', 'runtime-slot', 'Cancel button callback'), diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index b6d53f2bba..933f48f3ec 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -222,9 +222,8 @@ import { * the provenance spelled into its description. The row is passed in already * through the import boundary, so this helper never touches a spec binding. * - * The ONE copy: `./objectql.zod.ts`'s two public-block arms (`object-metric`, - * `object-master-detail-form`) import it rather than restate it - * (objectui#10872). Internal to this package's zod modules — deliberately NOT + * The ONE copy: `./objectql.zod.ts`'s public-block arms import it rather than + * restate it (objectui#10872). Internal to this package's zod modules — deliberately NOT * re-exported from `index.zod.ts`, like the helpers in `./tombstone.zod.ts`. */ export function propsBag(type: string, row: T) {