diff --git a/.changeset/8344-node-recursion-point-redirect.md b/.changeset/8344-node-recursion-point-redirect.md new file mode 100644 index 0000000000..2eb9c6aa56 --- /dev/null +++ b/.changeset/8344-node-recursion-point-redirect.md @@ -0,0 +1,105 @@ +--- +'@object-ui/types': minor +--- + +Redirect the node recursion point from `BaseSchemaCore` to `AnyComponentSchema` +(objectui#8344) — a nested node is now judged by its OWN component schema. + +**Behaviour change, deliberately, at every depth below the root.** Every child slot +(`body`, `children`, and every per-component redeclaration of them) is +`z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`, and `SchemaNodeSchema`'s +component arm was `BaseSchemaCore` — the ~21 base keys and nothing type-specific. So +per-type enforcement was ROOT-ONLY, for every component type: objectui#7869 measured +an off-spec `size` on a nested `icon` node being ACCEPTED while the same node standing +alone was refused. The arm is now the union of the registered component mirrors, so +the same node gets the same verdict at every depth. + +⛔ **Nothing here is `.strict()`.** `BaseSchemaCore` keeps its passthrough, no schema +gained a `catchall`, and no declaration was repaired. Measured over the catalog + +docs corpora on `c90395b2` (431 catalog files + the `json` fences under +`content/docs`, 554 node documents): **45 refused before, 54 after** — re-derived +unchanged after merging `main` `3f775eeb8`, same pair, same instrument — nine documents, +each one pre-existing debt this SURFACES rather than creates. Four have a child whose +`type` resolves in no arm; five carry a child already red under its own schema and +shielded until now by the recursion point. + +**What an author sees.** A document whose nested node is off-spec — a bad enum value, +a wrong-typed key, a `type` no component mirror declares — is refused now, where it +parsed green before. That is the point of the change, and it is why this ships behind +a contract review rather than as a patch. + +**Two mechanical notes for anyone editing the wiring.** `AnyComponentSchema` is built +in `zod/index.zod.ts` from all 13 category modules and 14 modules import +`zod/base.zod.ts`, so the arm cannot be an import — `z.lazy` defers evaluation, not +the module graph. It is a written option slot that `index.zod.ts` fills inside +`AnyComponentSchema`'s own initializer, and it is a `z.union` option rather than a +`z.lazy` holder because `z.lazy` memoises its getter: a holder would let whichever +module graph parsed first decide the accept set for the whole process. Both +constraints are measured, and the reasoning lives on `defineNodeComponentUnion` in +`zod/base.zod.ts`. + + +## Three more public-surface facts this ships + +**1. `DashboardWidgetSchema.component` narrows.** That legacy `{ id, component, layout }` +envelope names `BaseSchema` explicitly instead of following the redirect, so the widget +slot keeps admitting `metric-card`, objectui's closed widget-slot extension. One measured +delta on that slot and only one: a PRIMITIVE in it (`component: 'text'`) was accepted +through `SchemaNodeSchema` and is refused now. No corpus document, fixture or pin writes +one. + +**2. The `chatbot` record `body` is refused NESTED, and still accepted at the ROOT.** +`ChatbotSchema.body` mirrors the chat API's body params as +`z.record(z.string(), z.unknown())`, which is WIDER than `BaseSchemaCore.body` — the only +wider redeclaration among the 109 base-key redeclarations across the union's arms. Judging +a child by its own schema would therefore have ADMITTED, at every child slot, a document +the base arm refused. ⛔ That widening is eliminated rather than declared: the arm the +recursion point installs carries a check that a nested `chatbot` node's `body` still fits +the node slot. Measured, corpus-valid chatbot seed plus `body: { model, temperature }`: +accepted at the root before and after; inside `card.body[]` and `div.children[]` refused +before and refused now. ⇒ the redirect narrows at all 109 redeclarations and widens at +none. The published `ChatbotSchema` is untouched — whether its own `body` should carry the +chat API's params is a separate question, recorded on objectui#8572 and deliberately not +decided here. + +**3. ⚠️ KNOWN GAP, declared rather than papered over: a bundled consumer that never reads +`AnyComponentSchema` can tree-shake the redirect away.** This package declares +`"sideEffects": false` and the arm is filled by a statement in the `./zod` barrel's body, so a +bundler that honours the flag and sees no reference to `AnyComponentSchema` may drop the fill — +and then every child slot validates with the PRE-redirect arm, with no error and no warning. +Who is exposed, stated plainly: an external consumer whose bundler honours `sideEffects: false` +and never reads `AnyComponentSchema` keeps `main`'s accept set for NESTED nodes. Root-level +enforcement is unchanged by the gap, and every consumer whose import graph reads the union — +the `./zod` barrel under Node or vitest, `@object-ui/cli`'s `check` / `validate` (they call +`safeValidateSchema`, which references the union), any bundle that imports `AnyComponentSchema` +— gets the new set at every depth. + +Measured on the published `dist/zod` face of this head (Vite 8.2.1 lib build, `es`, +esbuild-minified, `zod` 4.4.3 and `@objectstack/spec` external, so the figures are this +package's own bytes; nested off-spec node = `{ type: 'icon', icon: 'check', size: 'huge' }` +inside `card.body[]`, parsed through `CardSchema`): + +| entry | nested off-spec node | bundle (raw / gzip) | fill in output | +| --- | --- | --: | --- | +| barrel, `CardSchema` and `AnyComponentSchema` imported | REFUSED | 750,542 / 206,815 B | present | +| barrel, `CardSchema` only | **ACCEPTED (inert)** | 212,567 / 61,025 B | absent | +| deep-link entry at `layout.zod.js` | **ACCEPTED (inert)** | 212,563 / 61,030 B | absent | + +⛔ It is NOT closed here, and the reason is measured rather than argued. The route that closes +it by binding the union inside `SchemaNodeSchema`'s `z.lazy` getter was implemented and pushed, +and CI refused it: `Build Docs` failed with `ReferenceError: Cannot access 'BaseSchema' before +initialization` out of `packages/types/dist/zod/app.zod.js`, because that import makes +`base.zod.ts` depend on the barrel and a bundler is free to evaluate the resulting cycle +category-module-first. Reproduced locally in one line — importing `dist/zod/app.zod.js` throws +with the binding in place and loads clean without it. The other three candidates were measured +too: a narrowed `sideEffects` array is not a legal declaration for this package (one gate +requires every entry form to be named, another refuses a named entry with no load-time effect, +and this package's entry forms are pure), a bare top-level call is dropped by the same flag, +and dropping the flag costs 16,078 gzipped bytes on the console `framework` chunk and moves a +workspace census a guard pins. + +⇒ **The card that closes this gap is objectui#8598**: build the `./zod` subpath as ONE bundled +module, so a consumer bundler has no internal graph to link past and every entry — one schema, +the barrel, or a deep link — gets the same accept set. Until it lands, a consumer that bundles +`@object-ui/types/zod` should keep `AnyComponentSchema` in its import graph, which is enough to +make the redirect apply. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index d02264fc61..9cd20de03d 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -178,7 +178,7 @@ A styled container with optional header, body, and footer regions. "variant": "outline", "hoverable": true, "header": [ - { "type": "badge", "label": "Live", "variant": "success" } + { "type": "badge", "label": "Live", "variant": "secondary" } ], "body": [ { "type": "statistic", "label": "Total Revenue", "value": "$12,400" } @@ -1281,8 +1281,8 @@ Schemas are designed to compose. Nest any `SchemaNode` inside another to build c "type": "dashboard", "columns": 2, "widgets": [ - { "id": "w1", "title": "Leads", "body": { "type": "statistic", "value": "142" } }, - { "id": "w2", "title": "Revenue", "body": { "type": "statistic", "value": "$24k" } } + { "type": "metric-card", "title": "Leads", "value": "142" }, + { "type": "metric-card", "title": "Revenue", "value": "$24k" } ] } }, diff --git a/content/docs/guide/schema-playground.md b/content/docs/guide/schema-playground.md index bfd109be55..bcef234383 100644 --- a/content/docs/guide/schema-playground.md +++ b/content/docs/guide/schema-playground.md @@ -103,9 +103,9 @@ A content card with a header, description, and body: "icon": "dollar-sign", "body": { "type": "stack", - "direction": "vertical", - "gap": "md", - "items": [ + "direction": "col", + "gap": 4, + "children": [ { "type": "text", "content": "$48,250", diff --git a/packages/types/src/__tests__/any-component-union-fanout.test.ts b/packages/types/src/__tests__/any-component-union-fanout.test.ts index fa9ef468f2..f5a2120138 100644 --- a/packages/types/src/__tests__/any-component-union-fanout.test.ts +++ b/packages/types/src/__tests__/any-component-union-fanout.test.ts @@ -28,11 +28,18 @@ * refused node 4 deep 19,311 -> 4,330 chars * * A bound that also passed on the flat union would assert nothing, which is the - * failure mode this card is most exposed to: `AnyComponentSchema` does not yet - * recurse into child slots (objectui#7869 / objectui#8344), so a nested document - * is simply ACCEPTED and a naive "does not throw at depth 4" test is green for - * the wrong reason. The depth case below is therefore built on `MenuItemSchema`, - * which ALREADY refuses at depth on this tree. + * failure mode this card was most exposed to. ⚠️ The reason it was exposed has + * since changed and this paragraph is corrected in place rather than deleted: + * when this file was written `AnyComponentSchema` did not recurse into child + * slots, so a nested document was simply ACCEPTED and a naive "does not throw at + * depth 4" test was green for the wrong reason — which is why the depth case + * below is built on `MenuItemSchema`, one of the few schemas that ALREADY refused + * at depth on that tree. objectui#8344 has since redirected the node recursion + * point, so a nested off-spec node IS refused now and the `MenuItemSchema` choice + * is no longer load-bearing. ⛔ It stays anyway: it is the case this card measured + * and re-pointing it would retire the measurement without replacing it. The + * redirected path gets its own depth pin in + * `node-recursion-point-8344.test.ts`, where the linear-growth reading lives. */ import { describe, it, expect } from 'vitest'; diff --git a/packages/types/src/__tests__/node-recursion-point-8344.test.ts b/packages/types/src/__tests__/node-recursion-point-8344.test.ts new file mode 100644 index 0000000000..38f185f5da --- /dev/null +++ b/packages/types/src/__tests__/node-recursion-point-8344.test.ts @@ -0,0 +1,275 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The node recursion point resolves per-type, at every depth (objectui#8344). + * + * ## What was wrong + * + * Every child slot (`body`, `children`, and every per-component redeclaration of + * them) is `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`, and + * `SchemaNodeSchema`'s component arm was `BaseSchemaCore` — the ~21 base keys and + * NOTHING type-specific. ⇒ per-type enforcement was ROOT-ONLY, at every depth, for + * every component type. objectui#7869 recorded it as an ASYMMETRY, measured there: + * an off-spec node was refused standing alone and ACCEPTED one slot down, inside + * any parent. #8344 points the arm at `AnyComponentSchema` instead. + * + * ## What this file pins, and why each leg is here + * + * The headline is one behaviour — "the same node gets the same verdict at every + * depth" — and a single assertion cannot state it, because BOTH halves of an + * asymmetry have to be read to say the asymmetry is gone. So #7869's reproduction + * is pinned in both directions (refused alone AND refused nested), against a + * NON-VACUITY leg (the legal twin of the same node, accepted at both depths) that + * would catch the way this could pass while being broken — a recursion point that + * refuses everything reads as a fixed asymmetry and is a dead contract. + * + * The fourth leg is what makes it a test of the REDIRECT rather than of `icon`: + * a node whose `type` resolves in no arm of `AnyComponentSchema`. `BaseSchemaCore` + * accepts any object with a string `type`, so that node is the one input the two + * candidate recursion points disagree about MOST — it separates "the arm is the + * component union" from "the arm is the base shape" without reading a single zod + * internal. + * + * ⚠️ Recognising the recursion point by IDENTITY is pinned on the EXPORTED WRAPPER, + * ⛔ never through `.unwrap()` or a re-invoked `z.lazy` getter. That is objectui#7918 + * consequence ①: the exported wrapper identity is stable and survives through a + * declared slot, and it is the ONE reading that holds for all ten recursive mirrors. + * + * ⚠️ ⛔ Do not read that as "`unwrap()` and the getter are unstable HERE". On `main` they are + * — measured on the built face, `S.unwrap() === S.unwrap()` and `getter() === getter()` are + * both FALSE. On THIS head both are TRUE for this one const, because the redirect builds the + * node union once below `BaseSchemaCore` and the getter returns it: the row moves to + * `MEMOISED` in `zod-lazy-getter-identity-7918.test.ts`, as a byproduct rather than a goal. + * The `fill is LIVE` leg below works BECAUSE of that. + * + * ⇒ the discipline stands unchanged and for an unchanged reason: it must hold for + * the seven mirrors that are still TDZ_BOUND, so a pin written through `.unwrap()` + * or a re-invoked getter would compare two fresh objects THERE and fail for a + * reason that has nothing to do with this contract. Pinning the wrapper is what + * makes this file portable to them; it is not a claim about this const's getter. + */ + +import { describe, it, expect } from 'vitest'; + +import { AnyComponentSchema, CardSchema, IconSchema, SchemaNodeSchema, safeValidateSchema } from '../zod/index.zod.js'; +import type { SchemaNode } from '../base.js'; +import type { z } from 'zod'; + +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/** + * #7869's own node, in the spelling `IconSchema` declares: `ui:icon` names its + * glyph with `icon` and sizes it with a NUMBER, so `size: 'huge'` is off-spec by + * value and `size: 24` is the legal twin of the same node. + */ +const OFF_SPEC_ICON = { type: 'icon', icon: 'check', size: 'huge' } as const; +const LEGAL_ICON = { type: 'icon', icon: 'check', size: 24 } as const; + +/** The same node one slot down — the depth #7869 measured as the shielded one. */ +const nested = (child: unknown) => ({ type: 'card', title: 'Parent', body: [child] }); + +describe('objectui#7869 — the off-spec node gets the same verdict at both depths', () => { + it('is refused STANDING ALONE (unchanged — this half was never the defect)', () => { + expect(AnyComponentSchema.safeParse(OFF_SPEC_ICON).success).toBe(false); + }); + + it('is refused NESTED — the half objectui#8344 moved', () => { + expect(AnyComponentSchema.safeParse(nested(OFF_SPEC_ICON)).success).toBe(false); + }); + + it('names the offending VALUE, not merely "some arm did not match"', () => { + // A recursion point that refused the child for the wrong reason — because the + // parent no longer matches any arm at all, say — would satisfy the two legs + // above while saying nothing about the child. Read the leaf issue. + const result = IconSchema.safeParse(OFF_SPEC_ICON); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues.some((i) => i.path.join('.') === 'size' && i.code === 'invalid_type')).toBe(true); + }); + + it('NON-VACUITY: the legal twin is accepted at BOTH depths', () => { + // Without this leg, a recursion point that refuses everything passes the two + // legs above. It is the assertion that says the redirect narrowed rather than + // closed the slot. + expect(AnyComponentSchema.safeParse(LEGAL_ICON).success).toBe(true); + expect(AnyComponentSchema.safeParse(nested(LEGAL_ICON)).success).toBe(true); + }); +}); + +describe('the arm IS the component union, and not the base shape', () => { + /** + * `h1` is a REGISTERED RENDERER (`components/src/renderers/basic/html-elements.tsx`) + * with no mirror in `AnyComponentSchema` — so it is the input the two candidate + * recursion points answer differently: `BaseSchemaCore` takes any object with a + * string `type`, the component union takes none it does not declare. ⛔ Do not + * "fix" this by adding an `h1` arm to make some other test green: that is the + * public-surface widening objectui#8344 routes into its own card. + */ + const UNMIRRORED = { type: 'h1', children: 'Sales Dashboard' } as const; + + it('refuses an unmirrored node nested in a declared child slot', () => { + expect(AnyComponentSchema.safeParse(nested(UNMIRRORED)).success).toBe(false); + }); + + it('refuses the same node standing alone (the control — this was already true)', () => { + expect(AnyComponentSchema.safeParse(UNMIRRORED).success).toBe(false); + }); +}); + +describe('the late-binding wiring, read by IDENTITY on the exported wrapper', () => { + it('the exported wrapper is one stable object', () => { + // objectui#7918 consequence ①: the EXPORTED wrapper is the stable handle, and it is the + // one reading that holds for all ten recursive mirrors. ⛔ Never write this pin through + // `.unwrap()` or a re-invoked getter — on the seven mirrors that are still `TDZ_BOUND` + // those return a fresh object per call, and a pin written through them would compare two + // fresh objects and fail for a reason that has nothing to do with this contract. + expect(SchemaNodeSchema).toBe(SchemaNodeSchema); + }); + + it('that identity survives through a declared child slot', () => { + const body = (CardSchema.shape.body as unknown as { _zod: { def: { innerType: { _zod: { def: { options: unknown[] } } } } } }); + expect(body._zod.def.innerType._zod.def.options).toContain(SchemaNodeSchema); + }); + + it('the holder is FILLED by importing the barrel — the module-cycle break works', () => { + // The behavioural read of the fill, and the only one that cannot pass vacuously: BEFORE + // the fill the arm is `BaseSchemaCore`, which accepts the unmirrored node below. This + // module imports the barrel and nothing else, so a break in `index.zod.ts`'s + // `defineNodeComponentUnion(...)` initializer lands here rather than in whichever suite + // happened to run second. + expect(AnyComponentSchema.safeParse(nested({ type: 'h1' })).success).toBe(false); + expect(AnyComponentSchema.safeParse(nested(LEGAL_ICON)).success).toBe(true); + }); + + it('the fill is LIVE, and slot 0 holds the WRAPPED union, not the bare one', () => { + // `z.union` re-reads its option array on every parse, so the recursion point is whatever + // slot 0 holds NOW — not whatever it held when some other file in this worker first + // parsed something (the unit project runs `isolate: false`, one module graph per worker). + // ⛔ Do not assert `toBe(AnyComponentSchema)` here: what is installed is deliberately the + // `superRefine` WRAPPER that keeps the `chatbot` arm from widening the node slot, and a + // pin on the bare union would go green the moment that narrowing was dropped. + const arm = (SchemaNodeSchema as unknown as { + _zod: { def: { getter: () => { _zod: { def: { options: readonly { _zod: { propValues?: Record< string, unknown >; def: { checks?: unknown[] } } }[] } } } } }; + })._zod.def.getter()._zod.def.options[0]; + expect(arm).not.toBe(AnyComponentSchema); + // it is still the discriminated union objectui#8498 built — the discrimination survives + // the wrapper, which is what keeps a nested refusal costing one arm instead of 106 — + expect(Object.keys(arm._zod.propValues ?? {})).toContain('type'); + // and it carries exactly the one check that narrowing adds. + expect(arm._zod.def.checks).toHaveLength(1); + }); + +}); + +/** + * The EXACT bound on the one assertion `base.zod.ts` needs to make. + * + * `SchemaNodeSchema` keeps its objectui#7760 annotation `z.ZodType< SchemaNode, + * SchemaNode >`, and `z.output< typeof AnyComponentSchema >` is not assignable to + * `SchemaNode` for exactly ONE of its 106 arms: `complex.zod.ts#ChatbotSchema` + * mirrors the chat API body params under the key `body`, which is `BaseSchema`'s + * CHILDREN slot. That collision is pre-existing (the parity ledger carries it under + * `KnownDrift`, the TS declaration renamed the key to `requestBody`, and + * `ChatbotSharedMirrorShape` says a ruling on `ChatbotSchema`'s own `body` arm is a + * separate question), and objectui#8344 does not decide it. + * + * ⇒ the fill site takes a loose bound and this states the real one instead. A SECOND + * arm drifting the same way turns this red — where a wide bound would have said + * nothing. ⛔ Do not repair a red here by adding the new name to the union below: + * that records a second declaration defect as if it were a contract. + */ +type ArmsNotAssignableToSchemaNode = + Exclude< z.output< typeof AnyComponentSchema >, SchemaNode > extends { type: infer K } ? K : never; + +export type NodeRecursionPointDeclarationDrift = [ + Expect< Equal< ArmsNotAssignableToSchemaNode, 'chatbot' > >, +]; + + +/** + * The one arm the redirect would have WIDENED, narrowed on the arm itself. + * + * `ChatbotSchema.body` mirrors the chat API's body params as a record — the only wider + * redeclaration among the 109 base-key redeclarations across the union's arms. Without the + * `superRefine` on the installed arm the redirect would narrow at 108 slots and widen at + * this one, which is what the card's appetite forbids in as many words. + * + * ⛔ Both directions are load-bearing, and a fix that only satisfies the first is the + * failure this pin exists to catch: narrowing the ROOT mirror would also refuse the nested + * node, and it would be a change to a published face this card does not own. + */ +describe('objectui#8344 — the `chatbot` record `body` is refused NESTED and still accepted at the ROOT', () => { + const CHATBOT = { + type: 'chatbot', + messages: [{ id: '1', role: 'assistant', content: 'hi' }], + } as const; + const withRecordBody = { ...CHATBOT, body: { model: 'gpt-4', temperature: 0.2 } }; + + it('is REFUSED one slot down, where the base arm refused it before this card', () => { + expect(AnyComponentSchema.safeParse(nested(withRecordBody)).success).toBe(false); + expect(AnyComponentSchema.safeParse({ type: 'div', children: [withRecordBody] }).success).toBe(false); + }); + + it('is still ACCEPTED at the ROOT — the published mirror is untouched', () => { + expect(AnyComponentSchema.safeParse(withRecordBody).success).toBe(true); + }); + + it('NON-VACUITY: the same node without `body` is accepted at both depths', () => { + expect(AnyComponentSchema.safeParse(CHATBOT).success).toBe(true); + expect(AnyComponentSchema.safeParse(nested(CHATBOT)).success).toBe(true); + }); + + it('names `body` in the refusal, so the author is told which key is wrong', () => { + const result = AnyComponentSchema.safeParse(nested(withRecordBody)); + expect(result.success).toBe(false); + if (result.success) return; + expect(JSON.stringify(result.error.issues)).toContain('"body"'); + }); +}); + +/** + * Depth on the REDIRECTED path, which objectui#8544 could not pin. + * + * That card's fan-out pin is built on `MenuItemSchema` because, on its tree, a nested + * document was simply ACCEPTED — the recursion point had not moved yet. Here it is refused, + * so this is the first pin that exercises a refusal at depth through the node union. + * + * ⛔ The number that matters is not the exact length, it is that the message stays LINEAR. + * Before objectui#8498 the refused subtree was re-embedded per level by a flat 106-arm + * union and grew about 25x per level, reaching `RangeError: Invalid string length` at depth + * 4; discriminating selects one arm, so each level adds a bounded frame. A ceiling well + * under the old growth is therefore the honest assertion: a regression that restores the + * fan-out blows through it, while ordinary wording changes do not. + */ +describe('objectui#8344 + objectui#8498 — a refusal at depth 4 stays bounded and never throws', () => { + const deep = (levels: number): unknown => + levels === 0 + ? { type: 'badge', variant: 'not-a-variant' } + : { type: 'card', title: 'p', body: [deep(levels - 1)] }; + + it('refuses at every depth 0 through 4 without throwing', () => { + for (const depth of [0, 1, 2, 3, 4]) { + const result = safeValidateSchema(deep(depth)); + expect(result.success).toBe(false); + } + }); + + it('keeps the depth-4 diagnostic linear, not exponential', () => { + const result = safeValidateSchema(deep(4)); + expect(result.success).toBe(false); + if (result.success) return; + // Measured on this head: 276 / 3,626 / 8,404 / 14,610 / 22,244 chars at depths 0-4. + // The pre-objectui#8498 shape reached 428,269,086 chars at depth 3 and threw at 4. + expect(result.error.message.length).toBeLessThan(200_000); + }); + + it('NON-VACUITY: the same shape with a LEGAL leaf is accepted at depth 4', () => { + const legal = (levels: number): unknown => + levels === 0 + ? { type: 'badge', variant: 'default' } + : { type: 'card', title: 'p', body: [legal(levels - 1)] }; + expect(safeValidateSchema(legal(4)).success).toBe(true); + }); +}); diff --git a/packages/types/src/__tests__/phase2-schemas.test.ts b/packages/types/src/__tests__/phase2-schemas.test.ts index 1305d8de35..d1f44a0ae1 100644 --- a/packages/types/src/__tests__/phase2-schemas.test.ts +++ b/packages/types/src/__tests__/phase2-schemas.test.ts @@ -634,6 +634,10 @@ describe('Phase 2: View Schemas Zod Validation', () => { content: { type: 'table', columns: [], + // `data` is a REQUIRED member of `TableSchema`. Until objectui#8344 this + // node sat in a child slot judged by the base shape, so the omission was + // invisible; the recursion point resolves per-type now and it is not. + data: [], }, }, ], diff --git a/packages/types/src/__tests__/zod-lazy-getter-identity-7918.test.ts b/packages/types/src/__tests__/zod-lazy-getter-identity-7918.test.ts index ef24f1f66a..442abf01b4 100644 --- a/packages/types/src/__tests__/zod-lazy-getter-identity-7918.test.ts +++ b/packages/types/src/__tests__/zod-lazy-getter-identity-7918.test.ts @@ -24,10 +24,24 @@ * TreeNodeSchema ReferenceError: Cannot access 'TreeNodeSchema' before initialization * * Seven name the very const being declared (`children: z.array(TreeNodeSchema)` - * sits inside `TreeNodeSchema`'s own initialiser); `SchemaNodeSchema` names - * `BaseSchemaCore`, which `base.zod.ts` declares BELOW it. For those eight the - * `z.lazy` is LOAD-BEARING — it is buying a TDZ dodge, not a style — and they - * keep the spelling they have. `mechanism` below reproduces the failure. + * sits inside `TreeNodeSchema`'s own initialiser); `SchemaNodeSchema` named + * `BaseSchemaCore`, which `base.zod.ts` declared BELOW it. For those eight the + * `z.lazy` was LOAD-BEARING — buying a TDZ dodge, not a style — and they keep + * the spelling they have. `mechanism` below reproduces the failure. + * + * ⚠️ SEVEN, not eight, since objectui#8344. That card redirected the node + * recursion point at `AnyComponentSchema` and had to build `SchemaNodeSchema`'s + * union ONCE, at module scope, immediately below `BaseSchemaCore` — because the + * component arm is a written option slot and there has to be an array to write + * into. Declaring it below `BaseSchemaCore` is what dissolves the TDZ, so the + * memoisation this file calls "worth doing where it is free" became free for this + * one const, and the row moved to {@link MEMOISED}. ⛔ It is a BYPRODUCT, not a + * goal: nobody memoised it to make `.unwrap()` honest, and ⛔ nothing here licenses + * moving the remaining seven — each still names the const being declared, and + * `mechanism` still reproduces their ReferenceError. + * + * ⇒ the eight-name list above is kept VERBATIM as the objectui#7918 reading it + * was. It is history, not the current ledger; the arrays below are the ledger. * * The two that loaded clean were memoised: `FilterBuilderConditionSchema` is not * recursive at all, and `NavigationItemSchema` already defers its self-reference @@ -115,6 +129,9 @@ const innerTypeStable = (S: unknown): boolean => (S as LazyInternals)._zod.inner const MEMOISED: ReadonlyArray = [ ['FilterBuilderConditionSchema', FilterBuilderConditionSchema], ['NavigationItemSchema', NavigationItemSchema], + // objectui#8344 — see the header. Its getter returns the ONE node union that + // `base.zod.ts` builds below `BaseSchemaCore`, so there is no TDZ left to dodge. + ['SchemaNodeSchema', SchemaNodeSchema], ]; /** ⛔ Do not "fix" these — each one's `z.lazy` dodges a real ReferenceError. */ const TDZ_BOUND: ReadonlyArray = [ @@ -124,7 +141,6 @@ const TDZ_BOUND: ReadonlyArray = [ ['MenuItemSchema', MenuItemSchema], ['NavLinkSchema', NavLinkSchema], ['NavigationMenuItemSchema', NavigationMenuItemSchema], - ['SchemaNodeSchema', SchemaNodeSchema], ['TreeNodeSchema', TreeNodeSchema], ]; diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 16c95033c6..52bf64ce75 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -129,8 +129,10 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **41 entries** in `KnownDrift`, **63 keys** across them — 42 / 64 until - * objectui#8338 RETIRED that same `feedback.zod.ts#ToastSchema` key on BOTH faces + * - **41 entries** in `KnownDrift`, **64 keys** across them — 41 / 63 until + * objectui#8344 added `component` to `complex.zod.ts#DashboardWidgetSchema`, an + * existing entry (so the entry count did not move). 42 / 64 until + * objectui#8338 RETIRED the `feedback.zod.ts#ToastSchema` key on BOTH faces * (ADR-0049 enforce-or-remove: `?: never` on the declaration, `retirementTombstone()` * on the mirror), the entry's whole content, so the entry went with it. ⭐ The first * entry this ledger has lost by RETIRING the key rather than by moving either face @@ -1136,8 +1138,31 @@ interface KnownDrift { * comparison, and this entry going stale is precisely what surfaced that. */ 'complex.zod.ts#DashboardComponentSchema': 'header' | 'widgets' | 'globalFilters'; - /** TS declares `unknown`; the mirror declares a structured options object. The mirror is the STRICTER side here — narrowing the check would be wrong, widening the TS declaration is the ADR-0049 question. */ - 'complex.zod.ts#DashboardWidgetSchema': 'options'; + /** + * `options` — TS declares `unknown`; the mirror declares a structured options object. + * The mirror is the STRICTER side here — narrowing the check would be wrong, widening + * the TS declaration is the ADR-0049 question. + * + * `component` — joined with objectui#8344, and the mirror is the stricter side here too. + * TS declares `SchemaNode` (`BaseSchema | string | number | boolean | null | undefined`); + * the mirror declares `BaseSchema` alone, so the five primitive arms are the drift. The + * key is the legacy `{ id, component, layout }` envelope's node slot, and it was spelled + * `SchemaNodeSchema` until #8344 redirected that const's component arm at + * `AnyComponentSchema`. This slot cannot follow it: `metric-card` is objectui's CLOSED + * widget-slot component extension (`DASHBOARD_COMPONENT_WIDGET_TYPES`), admitted by the + * 2026-08-14 ruling (objectstack#8593) and deliberately NOT an arm of the component + * union — `DashboardWidgetSlotComponentSchema` says the routing is "an internal property + * of the widget slot, not new authoring surface" — so following the redirect would have + * refused the very envelope this key exists for. ⇒ the slot names the passthrough the + * ruling assigns it, and the primitives it stops admitting land here. + * + * ⚠️ This entry is the reason objectui#8344 could not read its own type-check as green: + * the ledger is a TYPE MAP over the mirrors, so a `complex.zod.ts` edit moves a row + * INSIDE this file without editing it, and `tsc -p tsconfig.json` (the BUILD project) + * excludes every `.test.ts` under `src` and stays green while `tsconfig.test.json` + * reddens. + */ + 'complex.zod.ts#DashboardWidgetSchema': 'component' | 'options'; /** * `fields` — inherited from `FilterFieldSchema.operators` below; the element type is * the drifted one. `onChange` — RUNTIME SLOT (objectui#6124): the `filter-builder` renderer diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index c1c04e2c16..85483f321f 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -48,8 +48,153 @@ export const KeyedI18nLabelSchema = z.object({ params: z.record(z.string(), z.any()).optional().describe("Interpolation values for the key's placeholders"), }); + /** - * Schema Node - Can be a schema object or primitive value + * Fill the node recursion point with the component union, and hand it straight + * back — so the fill is part of `AnyComponentSchema`'s own initializer in + * `index.zod.ts` rather than a bare statement beside it (objectui#8344). + * + * ## ⚠️ Why a WRITE INTO the union's option list, and not a `z.lazy` holder + * + * The obvious spelling — a `let` the `z.lazy` getter reads — is WRONG here, and + * measurably so. `z.lazy` MEMOISES: zod 4.4.3 caches the resolved inner on first + * access, and merely parsing any component schema resolves it (the union arm walk + * reads every option to compute its own metadata, so a childless `detail-view` node + * is enough). ⇒ whatever the getter returned FIRST would be the accept set for the + * rest of the process, decided by whichever module graph parsed first — and this + * repo's `isolate: false` unit project shares one module graph across every file in + * a worker. Measured on this branch with that spelling in place: the #8344 pin + * PASSED run alone and FAILED in the full run, because + * `__tests__/handler-keys-string-any-mirrors-7344.test.ts` parses from a barrel-free + * import graph and froze the base shape in first. Refusing instead of falling back + * converges, but turns that same import order into dozens of red suites. + * + * ⭐ A `z.union` does NOT memoise its options: measured on zod 4.4.3, `z.union(opts)` + * keeps `opts` BY REFERENCE and re-reads it on every parse, so writing slot 0 takes + * effect immediately — including after parses have already run through it. That is + * what makes the window disappear rather than merely move: before the fill a child + * slot answers exactly as it did pre-#8344, after it every parse sees the component + * union, and no first-parse ever freezes the wrong answer in. + * + * ⚠️ That by-reference behaviour is the load-bearing assumption, so it is ASSERTED + * here rather than trusted: a zod that copied the array would leave this silently + * under-enforcing — the one failure direction that never announces itself. + * + * ⚠️ The parameter bound is `z.ZodType`, not `z.ZodType< SchemaNode, SchemaNode >`, + * and that too is measured. The tighter bound is the one this wiring wants — "the + * recursion point may only be filled with something a declared `SchemaNode` slot + * could already hold" — and `tsc` refuses it TODAY for exactly one arm out of 106: + * `complex.zod.ts#ChatbotSchema` mirrors the chat API body params under the key + * `body`, which is `BaseSchema`'s CHILDREN slot (`Record< string, unknown >` where + * the base says `SchemaNode | SchemaNode[]`). That collision is pre-existing and + * already recorded — the parity ledger carries it under `KnownDrift`, the TS + * declaration renamed the key to `requestBody`, and `ChatbotSharedMirrorShape` in + * `complex.zod.ts` says in as many words that a ruling on `ChatbotSchema`'s own + * `body` arm is a separate question. ⛔ #8344 does not decide it either. So the bound + * is loose HERE and the real check is kept EXACT one level out, as a type-level pin + * naming that single arm in `__tests__/node-recursion-point-8344.test.ts`. ⇒ a SECOND + * arm drifting the same way turns that pin red instead of passing unnoticed. + * + * @internal — the package's only zod entry point is the `./zod` barrel, which is + * `index.zod.ts`; this exists for that one call site and is not re-exported. + */ +export function defineNodeComponentUnion(union: T): T { + // ⭐ objectui#8344 F2 — what goes into the slot is the union WRAPPED, never the bare union. + // + // `ChatbotSchema.body` mirrors the chat API's body params as a record, which is WIDER than + // `BaseSchemaCore.body`. It is the only wider redeclaration among the 109 base-key + // redeclarations across the arms, so installing the bare union would narrow at 108 child + // slots and WIDEN at one: a `chatbot` node carrying a record `body` is refused at a child + // slot on `main` and would be accepted here. The card's appetite forbids widening in + // flight, so the arm carries the check and the PUBLISHED mirror is untouched — a root + // `chatbot` with a record `body` still parses, the same node one slot down does not. + // ⛔ Do not "simplify" this by narrowing `ChatbotSchema` itself: that is a change to a + // published face this card does not own, and it is recorded on objectui#8572. + const installed = union.superRefine((value, ctx) => { + const node = value as { type?: unknown; body?: unknown } | null | undefined; + if (!node || node.type !== 'chatbot' || node.body === undefined) return; + const asNodeSlot = BaseSchemaCore.shape.body.safeParse(node.body); + if (asNodeSlot.success) return; + for (const issue of asNodeSlot.error.issues) { + ctx.addIssue({ ...issue, path: ['body', ...issue.path] }); + } + }) as unknown as T; + nodeUnionOptions[0] = installed; + // The assertion the paragraph above exists for. ⛔ Do not delete it as noise: it is + // the only thing standing between a zod that copies its option array and a + // recursion point that silently reverts to the pre-#8344 base shape. + const readBack = (nodeUnion as unknown as { _zod: { def: { options: readonly unknown[] } } })._zod.def.options[0]; + if (readBack !== installed) { + throw new Error( + 'objectui#8344: `z.union` no longer keeps its option array by reference, so the node ' + + 'recursion point did not take. The redirect is INERT and every nested node is being ' + + 'judged by `BaseSchemaCore` again — see `defineNodeComponentUnion` in base.zod.ts.', + ); + } + return union; +} + +/** + * Schema Node — what a child slot holds: a COMPONENT document, or a primitive. + * + * ## The component arm is `AnyComponentSchema` (objectui#8344) + * + * Every child slot (`body`, `children`, and every per-component redeclaration of + * them) is `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`, so this const + * is where the whole node tree recurses. Until #8344 the component arm was + * `BaseSchemaCore` — the ~21 base keys and NOTHING type-specific — which made + * per-type enforcement ROOT-ONLY, at every depth, for every component type. That + * is objectui#7869, measured there: an off-spec `size` on a NESTED `icon` node was + * accepted, and the same node alone was refused. Pointing the arm at the union of + * the registered component mirrors is the whole of this change; ⛔ nothing here is + * `.strict()`, and `BaseSchemaCore` keeps its passthrough. + * + * Priced at 9 newly-refused corpus documents (objectui#8344's R3, 54 / 553 against + * R1's 45 / 553), each one pre-existing debt this SURFACES rather than creates: + * four whose child `type` resolves in no arm, five already red under their own + * schema and shielded until now by the recursion point. + * + * ## ⚠️ Why the arm is late-bound and not imported + * + * `AnyComponentSchema` is built in `index.zod.ts` out of all 13 category modules, + * and 14 modules import THIS one — so naming it here is a module cycle, and + * `z.lazy` defers the EVALUATION, not the module graph. With that import in place, + * entering the graph at `base.zod.js` evaluates `app.zod.ts`'s body while + * `BaseSchema` is still in its temporal dead zone and the package throws on import. + * ⇒ the break is deliberate: `index.zod.ts` fills the holder through + * {@link defineNodeComponentUnion} as it constructs the union, which is module + * evaluation and therefore strictly before anything can parse. + * + * ⚠️ BEFORE the fill — a module graph that reaches a parse without ever evaluating + * `index.zod.js` — the arm is `BaseSchemaCore`, i.e. exactly the pre-#8344 accept + * set, and it switches the moment the barrel loads. That is a property of the WRITE, + * not a tolerated fallback: `z.union` re-reads its option array on every parse, so + * nothing can freeze the pre-fill answer in ({@link defineNodeComponentUnion} carries + * the measurement, and why the obvious `z.lazy` holder is wrong). No published entry + * point can reach that window BY MODULE GRAPH: `./zod` is this package's only zod + * subpath and it IS `index.zod.js`. Pinned in + * `__tests__/node-recursion-point-8344.test.ts`. + * + * ⛔ ⚠️ THAT SENTENCE IS ABOUT MODULE GRAPHS, AND A BUNDLER IS NOT ONE. This package + * declares `"sideEffects": false` and the fill is a statement in this barrel's body, + * so a bundler that honours the flag and sees no reference to `AnyComponentSchema` + * may drop the whole const — fill included — and then every child slot validates + * with the PRE-#8344 arm. Measured on the published `dist/zod` face of this package + * (Vite 8.2.1 lib build, `es`, esbuild-minified, `zod` 4.4.3 and `@objectstack/spec` + * external, so the figures are this package's own bytes — the same instrument and + * the same figures the objectui#8344 changeset cites): a barrel entry importing only + * `CardSchema` ACCEPTS a nested off-spec node (212,567 bytes, fill absent), an entry + * that deep-links `layout.zod.js` ACCEPTS it too (212,563 bytes, fill absent), and + * the barrel entry with `AnyComponentSchema` also imported REFUSES it (750,542 + * bytes, fill present). The guard below cannot see this: it runs inside the code + * that was dropped. ⇒ this window is silent, it is NOT the pre-fill window this + * paragraph describes, and it ships DECLARED (objectui#8344 decision batch #98): an + * external consumer whose bundler honours the flag and never reads + * `AnyComponentSchema` keeps the pre-#8344 accept set for NESTED nodes; root-level + * enforcement and every consumer whose graph reads the union get the new set. + * Closing it is objectui#8598 — the `./zod` face built as ONE module — ⛔ not an + * import of the union from here: that spelling was measured to throw at load in a + * real consumer's bundle (the changeset carries the CI evidence). * * ## Both type arguments are filled, and that is the whole published input face * @@ -63,10 +208,12 @@ export const KeyedI18nLabelSchema = z.object({ * write, which is wider than every declaration BY DEFINITION and says nothing about * what this schema accepts at runtime. * - * ⛔ The runtime accept set did NOT move: the union below is untouched, and so is - * `SchemaNode` in `../base.ts`. This is a declaration-face change only. + * ⛔ `SchemaNode` in `../base.ts` did NOT move under #8344 either: the TS face still + * says `BaseSchema | primitive`, and `BaseSchema` carries an index signature, so the + * runtime accept set is now NARROWER than the declaration rather than wider. The + * declaration repair is its own worklist and ⛔ not this const's to make. * - * ⭐ What it bought: `__tests__/zod-mirror-parity.test.ts` can now compare the + * ⭐ What #7760 bought: `__tests__/zod-mirror-parity.test.ts` can now compare the * `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` single-or-list slots that * objectui#7069 called this repo's systematic producer and had to EXCLUDE — its * `Unconstrained` predicate was dropping every one of them. Three real widenings came @@ -81,16 +228,14 @@ export const KeyedI18nLabelSchema = z.object({ * a declaration or narrowing a mirror to make the annotation fit: either is a * contract change wearing a type-annotation's clothes, and both are ruled elsewhere. */ -export const SchemaNodeSchema: z.ZodType = z.lazy(() => - z.union([ - BaseSchemaCore, - z.string(), - z.number(), - z.boolean(), - z.null(), - z.undefined(), - ]) -); +export const SchemaNodeSchema: z.ZodType = z.lazy(() => { + // `z.lazy` memoises this getter, and that is FINE — because what it returns is the + // one live union, whose option slot 0 IS the recursion point and is written by + // {@link defineNodeComponentUnion}. ⛔ Do not move the union's CONSTRUCTION in here: + // a getter that builds the union is the memoising spelling objectui#8344 measured + // wrong, and it would put the accept set back at the mercy of import order. + return nodeUnion; +}); /** * Base Schema - Core validation schema that all components extend @@ -276,6 +421,41 @@ const BaseSchemaCore = z.object({ */ export const BaseSchema = BaseSchemaCore; +/** + * The one node union every child slot recurses through — built HERE, immediately + * below `BaseSchemaCore`, because slot 0 holds it (objectui#8344). + * + * Slot 0 is the RECURSION POINT and is the only slot that ever changes: + * `BaseSchemaCore` while `index.zod.ts` has not been evaluated, `AnyComponentSchema` + * from the moment it has. `z.union` re-reads this array on every parse, so the swap + * is live and no parse can freeze the pre-fill answer in — the whole reason the + * arm is a written slot rather than a `z.lazy` holder ({@link defineNodeComponentUnion} + * carries the measurement). + * + * ⛔ Never export this array or this union. `SchemaNodeSchema` is the public handle + * and identity on it is what objectui#7918 consequence ① says is stable; a second + * exported name for the same shape would give the parity census a row to compare + * that has no TS declaration behind it. + */ +/** + * ⚠️ Both of these are `const` DECLARATIONS, ⛔ never assignments to a `let` hoisted + * above `BaseSchemaCore`. `@object-ui/types` declares `"sideEffects": false`, and a + * bare top-level assignment is a load-time side effect a bundler is entitled to drop + * whole — `scripts/__tests__/side-effects-declaration-consistency.test.ts` fails on + * exactly that, and it caught this file mid-#8344. Everything above that names them + * does so from inside a function body, which runs long after this line. + */ +const nodeUnionOptions: [z.ZodType, ...z.ZodType[]] = [ + BaseSchemaCore, + z.string(), + z.number(), + z.boolean(), + z.null(), + z.undefined(), +]; + +const nodeUnion = z.union(nodeUnionOptions) as unknown as z.ZodType; + /** * A spec schema's fields, minus the keys objectui declares locally, as an * all-optional shape ready for `BaseSchema.extend(…)` (objectstack#4115). diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index aed0cb61dd..2e429ee821 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -815,7 +815,27 @@ export const DashboardWidgetSchema = specFieldsExcept(SpecDashboardWidgetSchema. id: z.string().optional().describe('Widget ID'), type: DashboardWidgetTypeSchema.optional() .describe('Widget visualization type — the spec families plus objectui\'s closed `list`/`custom` and `metric-card` extensions'), - component: SchemaNodeSchema.optional().describe('Widget Component (legacy format)'), + // ⚠️ `BaseSchema`, ⛔ NOT `SchemaNodeSchema` (objectui#8344). The two were the + // same accept set until #8344 redirected the node recursion point at + // `AnyComponentSchema`, and this slot is the one place in the package where they + // must not be: `metric-card` is objectui's CLOSED widget-slot component + // extension (`DASHBOARD_COMPONENT_WIDGET_TYPES`), admitted by the 2026-08-14 + // ruling (objectstack#8593) and DELIBERATELY not an arm of `AnyComponentSchema` — + // {@link DashboardWidgetSlotComponentSchema} says so in as many words: the + // routing is an internal property of the widget slot, "not new authoring + // surface". So the redirect would refuse `{ id, component: { type: + // 'metric-card', … }, layout }` — the legacy envelope this key exists FOR — and + // the only repair the card leaves open (a new arm) is the widening that ruling + // declined. ⇒ the slot names the passthrough the ruling assigns it instead of + // inheriting whatever the recursion point currently means. Pinned by + // `__tests__/dashboard-widget-strict-6002.test.ts`'s legacy-envelope case and by + // `__tests__/dashboard-widget-slot-component-arm-7952.test.ts`. + // + // ⚠️ One measured delta from the old spelling, and it is the only one: a PRIMITIVE + // in this slot (`component: "text"`) was accepted through `SchemaNodeSchema` and is + // refused now. No corpus document, fixture or pin writes one, and the key is + // declared "Widget Component (legacy format)" — a node, never a scalar. + component: BaseSchema.optional().describe('Widget Component (legacy format)'), }).strict(); /** diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 8f1f0762e5..22c76ca2bc 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -350,6 +350,7 @@ export { // ============================================================================ import { z } from 'zod'; +import { defineNodeComponentUnion } from './base.zod.js'; import { AppComponentSchema } from './app.zod.js'; import { LayoutSchema } from './layout.zod.js'; import { FormComponentSchema } from './form.zod.js'; @@ -368,6 +369,19 @@ import { ViewComponentSchema } from './views.zod.js'; * Union of all component schemas. * Use this for generic component rendering where the type is determined at runtime. * + * ⭐ It is ALSO the node recursion point (objectui#8344): every child slot is + * `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`, and `SchemaNodeSchema` + * resolves its component arm to THIS union, so a nested node is judged by its own + * component schema at every depth instead of by the ~21 base keys. The wiring is a + * late-binding holder rather than an import because 14 modules import `base.zod.js` + * and this module is built from all 13 category modules — the full reasoning, and + * what the UNFILLED holder answers, live on `SchemaNodeSchema` in `base.zod.ts`. + * + * ⚠️ The fill is written as this const's own initializer, not as a statement beside + * it, so no bundler can keep the union and drop the wiring, and no future edit can + * reorder the two. ⛔ Do not "simplify" it back into a bare + * `defineNodeComponentUnion(AnyComponentSchema)` call underneath. + * * ## Why this is discriminated (objectui#8498) * * A flat `z.union` reports EVERY arm's issues under one `invalid_union`, and @@ -388,8 +402,15 @@ import { ViewComponentSchema } from './views.zod.js'; * option at index "9"`), which is why `objectql.zod.ts` and `crud.zod.ts` are * discriminated too. `__tests__/any-component-union-fanout.test.ts` pins all of * it. + * + * ⚠️ BOTH of the above are live here, and the composition is the whole resolution: + * objectui#8498 changed WHICH arm reports, objectui#8344 changed WHERE this union is + * consulted. The discriminated union is what gets written into the node option slot, + * so `defineNodeComponentUnion` wraps it rather than replacing it. The slot itself is + * still a plain `z.union` in `base.zod.ts` — that is what keeps its option array by + * reference, and it is untouched by the discrimination. */ -export const AnyComponentSchema = z.discriminatedUnion('type', [ +export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion('type', [ AppComponentSchema, LayoutSchema, FormComponentSchema, @@ -415,7 +436,7 @@ export const AnyComponentSchema = z.discriminatedUnion('type', [ // schema, so it also rewrote this union's `invalid_type` and a non-object root // lost "expected object, received number". `undefined` declines to the locale. error: (issue) => (issue.code === 'invalid_union' ? 'Invalid input' : undefined), -}); +})); /** * Validate a schema against the AnyComponentSchema