diff --git a/.changeset/10287-navigation-designer-doc-type.md b/.changeset/10287-navigation-designer-doc-type.md new file mode 100644 index 0000000000..457e6b739a --- /dev/null +++ b/.changeset/10287-navigation-designer-doc-type.md @@ -0,0 +1,21 @@ +--- +'@object-ui/plugin-designer': patch +'@object-ui/i18n': patch +--- + +fix(plugin-designer): the Navigation Designer has an entry for the spec's `doc` navigation item type + +objectstack#19789 added a `doc` member to the spec's navigation item union (an +item that targets a book and/or a doc). `NAV_TYPE_META` in `NavigationDesigner` +is keyed by that spec-derived union, so objectui stopped compiling against +`@objectstack/spec` built from objectstack `main`, and every row reads its badge, +colour and icon from that map. The map now has a `doc` entry (a `BookOpen` icon, +its own colour and the `appDesigner.navTypeDoc` label key). The key has an +English fallback in the designer's defaults and a translation in all ten locale +packs. + +The map is typed `Record` so it compiles both +against the pinned `@objectstack/spec`, which predates `doc`, and against +objectstack `main`. The `| 'doc'` goes away at the pin bump that ships `doc`. +`doc` is not added to the quick-add buttons: an empty `doc` item fails the spec's +book-or-doc requirement, and authoring one is objectui#10188. diff --git a/.changeset/9736-twins-spec-by-reference.md b/.changeset/9736-twins-spec-by-reference.md new file mode 100644 index 0000000000..e2e64d2b0a --- /dev/null +++ b/.changeset/9736-twins-spec-by-reference.md @@ -0,0 +1,40 @@ +--- +'@object-ui/types': minor +--- + +feat(types): `AppComponentSchema`, `DashboardComponentSchema` and `PageNodeSchema` take the spec by reference, like their zod mirrors + +The three published zod mirrors have long taken `@objectstack/spec`'s `App` / `Dashboard` / +`Page` surface by reference (`BaseSchema.extend(SpecXFields.shape)`), while their hand-written +TypeScript twins restated only the members the renderers read. So the published validator +admitted keys the published type did not declare: the package-lock envelope (`_lock`, +`_lockReason`, `_lockSource`, `_provenance`, `_packageId`, `_packageVersion`, `_lockDocsUrl`, +written by the packaging pipeline), `protection`, the app's `isDefault` / `_unpublished` / +`defaultAgent` / `contextSelectors`, and the page's `source` / `interfaceConfig` / `requires`. +The spec's retirement tombstones reached the type only as `any`, through `BaseSchema`'s index +signature. + +Each twin now extends `Omit` over the spec's exported input type, and that `Omit` reads the same +`as const` exclusion array the mirror's `specFieldsExcept` call reads. The two faces therefore +project one spec surface, and a later spec bump moves both of them together: + +- **Admitted keys are declared** with the spec's own member types. A reader of a served document + can read `app.protection`, `page.requires` or the envelope without a cast. +- **Tombstones refuse at compile time.** `version`, `homePageId`, `objects`, `apis`, `sharing`, + `embed`, `mobileNavigation` and `aria` on an app, and `aria`, `refreshInterval` and + `performance` on a dashboard, are now optional members typed `undefined`. Authoring a value is + a TypeScript error, which is the verdict the validator already gave at parse. ⚠️ This is the + one breaking edge: code that wrote one of these keys used to compile against the index + signature and was refused at parse. It is now refused by `tsc`, before it runs. +- `DashboardComponentSchema.header` and `PageNodeSchema.slots` keep their own hand-written types. + Both are withheld from the spec projection on the type side only, because neither type is + assignable to the spec's member. The drift is already recorded in the parity ledgers. + `PageNodeSchema.assignedProfiles` is withheld the same way, but for forward compatibility: + objectstack `main` retires the key, and the hand-written `string[]` member (unchanged) would + otherwise stop compiling at the next spec bump. Its retirement is tracked separately. + +Why this is a minor bump: the published type surface gains members, and one authoring +spelling moves from a parse-time refusal to a compile-time refusal. This repo marks breaking +semantics `minor` rather than `major`. + +This change affects types only. It changes no runtime code and narrows no mirror. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index c419304a7e..62350bdddf 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1405,6 +1405,7 @@ const ar = { navTypeSeparator: "فاصل", navTypeAction: "إجراء", navTypeComponent: "مكوّن", + navTypeDoc: "مستند", navEditIcon: "تعديل الأيقونة", navToggleVisible: "تبديل الرؤية", navHidden: "مخفي", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index e5baca0494..f2ae6e0d7f 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1398,6 +1398,7 @@ const de = { navTypeSeparator: "Trenner", navTypeAction: "Aktion", navTypeComponent: "Komponente", + navTypeDoc: "Dokument", navEditIcon: "Symbol bearbeiten", navToggleVisible: "Sichtbarkeit umschalten", navHidden: "Ausgeblendet", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index b0772d1c52..df400a3bad 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1647,6 +1647,7 @@ const en = { navTypeSeparator: 'Separator', navTypeAction: 'Action', navTypeComponent: 'Component', + navTypeDoc: 'Doc', navEditIcon: 'Edit icon', navToggleVisible: 'Toggle visibility', navHidden: 'Hidden', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index abe8f946b7..408c88ecb6 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1402,6 +1402,7 @@ const es = { navTypeSeparator: "Separador", navTypeAction: "Acción", navTypeComponent: "Componente", + navTypeDoc: "Documento", navEditIcon: "Editar icono", navToggleVisible: "Alternar visibilidad", navHidden: "Oculto", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 21949ab87c..e71ca35695 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1400,6 +1400,7 @@ const fr = { navTypeSeparator: "Séparateur", navTypeAction: "Action", navTypeComponent: "Composant", + navTypeDoc: "Document", navEditIcon: "Modifier l'icône", navToggleVisible: "Basculer la visibilité", navHidden: "Masqué", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 6706238a5b..3dcbb9d28c 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1398,6 +1398,7 @@ const ja = { navTypeSeparator: "区切り", navTypeAction: "アクション", navTypeComponent: "コンポーネント", + navTypeDoc: "ドキュメント", navEditIcon: "アイコンを編集", navToggleVisible: "表示を切り替え", navHidden: "非表示", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 645e690ca5..019f9f10ea 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1398,6 +1398,7 @@ const ko = { navTypeSeparator: "구분선", navTypeAction: "작업", navTypeComponent: "컴포넌트", + navTypeDoc: "문서", navEditIcon: "아이콘 편집", navToggleVisible: "가시성 토글", navHidden: "숨김", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 92d0bee665..b6b50b8b5f 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1397,6 +1397,7 @@ const pt = { navTypeSeparator: "Separador", navTypeAction: "Ação", navTypeComponent: "Componente", + navTypeDoc: "Documento", navEditIcon: "Editar ícone", navToggleVisible: "Alternar visibilidade", navHidden: "Oculto", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 682ec698dd..f30ac8280b 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1408,6 +1408,7 @@ const ru = { navTypeSeparator: "Разделитель", navTypeAction: "Действие", navTypeComponent: "Компонент", + navTypeDoc: "Документ", navEditIcon: "Редактировать значок", navToggleVisible: "Переключить видимость", navHidden: "Скрыто", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 6285b0ea38..703ed32126 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1463,6 +1463,7 @@ const zh = { navTypeSeparator: '分隔线', navTypeAction: '操作', navTypeComponent: '组件', + navTypeDoc: '文档', navEditIcon: '编辑图标', navToggleVisible: '切换可见性', navHidden: '已隐藏', diff --git a/packages/plugin-designer/src/NavigationDesigner.tsx b/packages/plugin-designer/src/NavigationDesigner.tsx index 677ae3f20f..dc6cc9b5f0 100644 --- a/packages/plugin-designer/src/NavigationDesigner.tsx +++ b/packages/plugin-designer/src/NavigationDesigner.tsx @@ -17,6 +17,7 @@ import React, { useState, useCallback, useRef } from 'react'; import type { NavigationItem, NavigationItemType } from '@object-ui/types'; import { + BookOpen, ChevronDown, ChevronRight, ChevronUp, @@ -79,7 +80,16 @@ function createId(prefix: string): string { return `${prefix}_${Date.now()}_${ndCounter}`; } -const NAV_TYPE_META: Record }> = { +// Keyed by the spec-derived union, so a nav type the spec adds stops this file +// compiling until it has an entry -- keep it a `Record`, never `Partial` or +// `Record`. +// +// `| 'doc'` is the published pin lagging the spec: objectstack#19789 added the +// `doc` nav item, and the pinned `@objectstack/spec` predates it, so without +// the extra key a `doc:` entry is an excess property against the pin while its +// absence fails the compile against objectstack `main` (Spec Main Shape Gate). +// Drop `| 'doc'` at the pin bump that ships `doc`; the entry itself stays. +const NAV_TYPE_META: Record }> = { object: { labelKey: 'appDesigner.navTypeObject', color: 'bg-green-100 text-green-700', Icon: Database }, dashboard: { labelKey: 'appDesigner.navTypeDashboard', color: 'bg-amber-100 text-amber-700', Icon: LayoutDashboard }, page: { labelKey: 'appDesigner.navTypePage', color: 'bg-teal-100 text-teal-700', Icon: FileText }, @@ -89,6 +99,7 @@ const NAV_TYPE_META: Record = [ diff --git a/packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx b/packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx new file mode 100644 index 0000000000..6b747efac7 --- /dev/null +++ b/packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx @@ -0,0 +1,93 @@ +/** + * 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. + */ + +/** + * objectui#10287 — every navigation item type the spec declares gets a row + * the Navigation Designer can draw. + * + * `NAV_TYPE_META` is a `Record` keyed by the spec-derived `NavigationItemType`, + * so the compiler already refuses a missing entry. This pin is the runtime + * half, and it reads the vocabulary from the INSTALLED `@objectstack/spec`'s + * `NavigationItemSchema` discriminants rather than from a list written here, + * so it follows whatever spec it is run against: + * + * - against the pinned release it covers that release's members; + * - against a spec built from objectstack `main` (the Spec Main Shape Gate's + * injected install) it also covers members the pin does not have yet — + * objectstack#19789's `doc` was the one that went unhandled. + * + * The map may carry MORE keys than the spec it is run against (`doc` ahead of + * the pin bump), so this asserts spec ⊆ map, never equality. + * + * A missing entry fails as a render error: every row reads `meta.Icon` from + * the map. An entry whose label key has no English fallback fails the + * raw-key assertion. + */ + +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { NavigationItemSchema } from '@objectstack/spec/ui'; +import type { NavigationItem } from '@object-ui/types'; +import { NavigationDesigner } from '../NavigationDesigner'; + +/** + * Walk `.unwrap()` (the spec wraps its schemas lazily) until the node carries + * `key`. Bounded, and answers `undefined` rather than looping on a shape it + * does not recognise. + */ +function unwrapUntil(node: unknown, key: string): Record | undefined { + let current = node as Record | undefined; + for (let depth = 0; depth < 8 && current; depth += 1) { + if (key in current) return current; + const unwrap = current.unwrap; + if (typeof unwrap !== 'function') return undefined; + current = unwrap.call(current) as Record | undefined; + } + return current && key in current ? current : undefined; +} + +/** The discriminant values of the spec's nav-item union. Throws when unreadable. */ +function specNavItemTypes(): string[] { + const union = unwrapUntil(NavigationItemSchema, 'options'); + const options = union?.options; + if (!Array.isArray(options) || options.length === 0) { + throw new Error('could not read NavigationItemSchema options from @objectstack/spec'); + } + return options.flatMap((option, index) => { + const shape = unwrapUntil(option, 'shape')?.shape as Record | undefined; + const literal = shape?.type as { values?: unknown } | undefined; + if (!(literal?.values instanceof Set) || literal.values.size === 0) { + throw new Error(`could not read the \`type\` literal of NavigationItemSchema option ${index}`); + } + return [...literal.values].map(String); + }); +} + +const SPEC_NAV_ITEM_TYPES = specNavItemTypes(); + +describe('NavigationDesigner — a row for every spec navigation item type (#10287)', () => { + it('reads a non-empty vocabulary from the installed spec', () => { + // Non-vacuity: an empty or mis-read list would make every case below pass. + expect(SPEC_NAV_ITEM_TYPES).toContain('object'); + expect(SPEC_NAV_ITEM_TYPES).toContain('group'); + expect(new Set(SPEC_NAV_ITEM_TYPES).size).toBe(SPEC_NAV_ITEM_TYPES.length); + }); + + it.each(SPEC_NAV_ITEM_TYPES)('draws the `%s` type in the tree and the live preview', (type) => { + // Cast at the fixture boundary only: the type comes from the installed + // spec at runtime, which can name a member the pinned types do not have. + const item = { id: `probe_${type}`, type, label: 'Probe item' } as unknown as NavigationItem; + + render( {}} showPreview />); + + const row = screen.getByTestId(`nav-designer-item-probe_${type}`); + // The type badge resolves to a label, not to its raw translation key. + expect(row.textContent ?? '').not.toMatch(/appDesigner\./); + }); +}); diff --git a/packages/plugin-designer/src/hooks/useDesignerTranslation.ts b/packages/plugin-designer/src/hooks/useDesignerTranslation.ts index f540bc504e..3cecda4251 100644 --- a/packages/plugin-designer/src/hooks/useDesignerTranslation.ts +++ b/packages/plugin-designer/src/hooks/useDesignerTranslation.ts @@ -104,6 +104,7 @@ export const DESIGNER_DEFAULT_TRANSLATIONS: Record = { 'appDesigner.navTypeSeparator': 'Separator', 'appDesigner.navTypeAction': 'Action', 'appDesigner.navTypeComponent': 'Component', + 'appDesigner.navTypeDoc': 'Doc', 'appDesigner.navEditIcon': 'Edit icon', 'appDesigner.navToggleVisible': 'Toggle visibility', 'appDesigner.navHidden': 'Hidden', diff --git a/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts b/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts index 8c14cb7f43..52f33dafb1 100644 --- a/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts +++ b/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts @@ -21,17 +21,19 @@ * `AriaPropsSchema`, the opposite of the contract: the member-level instance * of #4631's "declared surfaces disagree". * - * What the deletion changes at the type level, stated honestly: `BaseSchema` - * carries `[key: string]: any`, so an authored `aria:` on a dashboard literal - * still COMPILES after the removal — it falls to the index signature. A - * `@ts-expect-error` pin on an authored literal therefore cannot stick here - * (unlike `default-children-retired-contract-twins.test.ts`, whose interface - * has no index signature). The pinnable effect is that `aria` stops being a - * DECLARED member: the probe below extracts the interface's literal key set — - * the index signature is filtered out by `string extends K` — and asserts - * `aria` is out while its former neighbours stay in. Real enforcement because - * `packages/types/tsconfig.test.json` is chained from this package's - * `type-check` script (#3009). + * What the deletion changed at the type level, stated honestly: `BaseSchema` + * carries `[key: string]: any`, so after the removal an authored `aria:` on a + * dashboard literal still COMPILED — it fell to the index signature, and a + * `@ts-expect-error` pin on an authored literal could not stick. + * + * objectui#9736 closed that half. The interface now extends the spec's own + * `Dashboard` input type (`Omit< Dashboard, … >` over the exclusion list its + * mirror reads), so it inherits the spec's tombstone as a DECLARED member typed + * `undefined` — `aria` is back in the declared key set, but only as a refusal, + * and an authored value is a compile error: the same verdict the Zod twin gives + * at parse. The pins below assert exactly that, with the former neighbours as + * the control. Real enforcement because `packages/types/tsconfig.test.json` is + * chained from this package's `type-check` script (#3009). */ import { describe, it, expect } from 'vitest'; @@ -43,18 +45,24 @@ import { DashboardComponentSchema as DashboardComponentZodSchema } from '../zod/ type DeclaredKeys = { [K in keyof T as string extends K ? never : K]: T[K] }; type Declared = keyof DeclaredKeys; -describe('the TS interface no longer declares `aria` (objectui#5830)', () => { - it('`aria` is not a declared member; the neighbours it stood beside still are', () => { - // Type-level pin, erased at runtime: if the member came back, the first - // annotation would collapse to `false` and this file would fail - // `type-check`. (Reverse-verified at the PR: with the member restored, - // `tsc -p tsconfig.test.json` goes red on exactly this line.) - const ariaNotDeclared: 'aria' extends Declared ? false : true = true; +describe('the TS interface declares `aria` only as the spec tombstone (objectui#5830, objectui#9736)', () => { + it('`aria` is declared, typed `undefined`; the neighbours it stood beside still are', () => { + // Type-level pins, erased at runtime. `aria` is a DECLARED key again — the + // spec projection carries the tombstone — and its type admits no value. + const ariaDeclared: 'aria' extends Declared ? true : false = true; + const ariaAdmitsNoValue: [DashboardComponentSchema['aria']] extends [undefined] ? true : false = true; // Positive controls through the same extraction: a probe that saw no // members at all would also report `aria` absent. const widgetsDeclared: 'widgets' extends Declared ? true : false = true; const dateRangeDeclared: 'dateRange' extends Declared ? true : false = true; - expect(ariaNotDeclared && widgetsDeclared && dateRangeDeclared).toBe(true); + expect(ariaDeclared && ariaAdmitsNoValue && widgetsDeclared && dateRangeDeclared).toBe(true); + }); + + it('an authored `aria` value is a compile error — no longer absorbed by the index signature', () => { + const legal: DashboardComponentSchema = { type: 'dashboard', widgets: [] }; + // @ts-expect-error — `aria` is the spec's retirement tombstone (objectui#9736). + const authored: DashboardComponentSchema = { type: 'dashboard', widgets: [], aria: { ariaLabel: 'Ops' } }; + expect([legal.type, authored.type]).toEqual(['dashboard', 'dashboard']); }); }); diff --git a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts new file mode 100644 index 0000000000..72865568a4 --- /dev/null +++ b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts @@ -0,0 +1,146 @@ +/** + * 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. + */ + +/** + * objectui#9736 — the three hand-written TypeScript twins take the spec BY + * REFERENCE, the way their zod mirrors do (ruling batch #167 item 4, letter 甲). + * + * `AppComponentSchema` / `DashboardComponentSchema` / `PageNodeSchema` each + * extend `Omit< App | Dashboard | Page, … >` over the SAME `as const` exclusion + * array their mirror's `specFieldsExcept` call reads (`APP_SPEC_EXCLUDED`, + * `DASHBOARD_SPEC_EXCLUDED`, `PAGE_SPEC_EXCLUDED`), plus — on the TypeScript + * face only — the twin members whose hand-written type is not assignable to + * the spec's (`header` on the dashboard, `slots` on the page, both already + * ledgered as drift in `zod-mirror-parity.test.ts`), plus the page's + * `assignedProfiles`, which objectstack `main` retires (a forward-compat + * omission, objectui#9409). + * + * ## Why the positive pins are TYPE equalities, not assignments + * + * `BaseSchema` carries `[key: string]: any`, so an object literal carrying ANY + * key compiles against these interfaces whether or not the key is declared — + * an "it compiles" pin would be green on the tree before this change. So: + * - declared-ness is read through `DeclaredKeys`, which filters the index + * signature out (`string extends K`); + * - member types are compared with `Equal`, against the spec's own member; + * - the tombstone half is a `@ts-expect-error` on an authored value, which + * only sticks BECAUSE the key is now declared (the index signature would + * have absorbed it as `any` — `dashboard-aria-retired-contract-twins` is the + * record of that pin not sticking before). + * + * Real enforcement because `packages/types/tsconfig.test.json` is chained from + * this package's `type-check` script; the runtime half asserts the mirror + * gives the same verdict on the same literal. + */ + +import { describe, it, expect } from 'vitest'; +import type { App, Dashboard, Page } from '@objectstack/spec/ui'; +import type { AppComponentSchema } from '../app'; +import type { DashboardComponentSchema } from '../complex'; +import type { PageNodeSchema } from '../layout'; +import { + AppComponentSchema as AppMirror, + DashboardComponentSchema as DashboardMirror, + PageNodeSchema as PageMirror, +} from '../zod/index.zod'; + +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type DeclaredKeys = keyof { [K in keyof T as string extends K ? never : K]: T[K] }; + +/** Keys the spec's document type declares that the twin does NOT declare. */ +type SpecKeysUndeclaredOn = Exclude>; + +describe('each twin declares every key its spec document type declares', () => { + it('App / Dashboard / Page: the spec-minus-declared key set is empty', () => { + // Before objectui#9736 these read as the MirroredUndeclared rows (20 / 11 / 10 + // keys). ⭐ Control: `DeclaredKeys` must see real members, or `never` above is + // vacuous — `protection` / `requires` are keys ONLY the spec projection brings. + const app: Equal, never> = true; + const dashboard: Equal, never> = true; + const page: Equal, never> = true; + const controlApp: 'protection' extends DeclaredKeys ? true : false = true; + const controlPage: 'requires' extends DeclaredKeys ? true : false = true; + const controlNegative: 'notASpecKey9736' extends DeclaredKeys ? true : false = false; + expect([app, dashboard, page, controlApp, controlPage, controlNegative]).toEqual([true, true, true, true, true, false]); + }); +}); + +describe('admitted spec keys land on the twin with the SPEC\'S type, not `any`', () => { + it('the package-lock envelope and document keys equal the spec member', () => { + const lock: Equal = true; + const appProtection: Equal = true; + const defaultAgent: Equal = true; + const dashProtection: Equal = true; + const dashPackageId: Equal = true; + const pageSource: Equal = true; + const pageRequires: Equal = true; + const pageInterfaceConfig: Equal = true; + // Control: an undeclared key reads as `any` through the index signature, and + // `Equal` tells `any` apart from a real member type. + const anyControl: Equal = false; + expect([lock, appProtection, defaultAgent, dashProtection, dashPackageId, pageSource, pageRequires, pageInterfaceConfig, anyControl]) + .toEqual([true, true, true, true, true, true, true, true, false]); + }); + + it('`contextSelectors` — withheld from the projection — is declared over the mirror\'s own element', () => { + const declared: 'contextSelectors' extends DeclaredKeys ? true : false = true; + const selectors: AppComponentSchema['contextSelectors'] = [ + { id: 'pkg', label: 'Package', optionsSource: { endpoint: '/api/v1/packages' } }, + ]; + expect(declared).toBe(true); + expect(AppMirror.safeParse({ type: 'app', contextSelectors: selectors }).success).toBe(true); + }); +}); + +describe('spec tombstones surface on the twin as a refusal — the verdict the mirror gives', () => { + it('App `version` / Dashboard `refreshInterval`: authoring a value is a compile error AND a parse failure', () => { + // @ts-expect-error — `version` is the spec's `retiredKey()` tombstone. + const app: AppComponentSchema = { type: 'app', version: '1.0.0' }; + // @ts-expect-error — `refreshInterval` is the #15680 rename's tombstone. + const dashboard: DashboardComponentSchema = { type: 'dashboard', widgets: [], refreshInterval: 30 }; + expect(AppMirror.safeParse(app).success).toBe(false); + expect(DashboardMirror.safeParse(dashboard).success).toBe(false); + }); + + it('the controls: the same documents without the tombstone key pass both faces', () => { + const app: AppComponentSchema = { type: 'app' }; + const dashboard: DashboardComponentSchema = { type: 'dashboard', widgets: [], refreshIntervalSeconds: 30 }; + expect(AppMirror.safeParse(app).success).toBe(true); + expect(DashboardMirror.safeParse(dashboard).success).toBe(true); + }); + + it('`Page` carries no tombstone on the installed pin — its admitted keys pass both faces', () => { + // ⚠️ Recorded rather than assumed: the spec's `PageSchema` has no + // `retiredKey()` member on @objectstack/spec 17.4.0, so the page twin's pin + // is the admitted half only. + const page: PageNodeSchema = { type: 'page', source: 'pages/home.tsx', requires: ['crm'] }; + expect(PageMirror.safeParse(page).success).toBe(true); + }); +}); + +describe('the twin-only omissions keep the twin\'s own member, unwidened', () => { + it('Dashboard `header` and Page `slots` are the hand-written types, not the spec\'s', () => { + type HeaderAction = NonNullable['actions']>[number]; + const headerLabel: Equal = true; + const headerNotSpec: Equal = false; + const slotsNotSpec: Equal = false; + expect([headerLabel, headerNotSpec, slotsNotSpec]).toEqual([true, false, false]); + }); + + it('Page `assignedProfiles` keeps the hand-written `string[]` whatever the spec pin declares', () => { + // A FORWARD-COMPAT omission: objectstack `main` retires the key (its input + // type becomes `undefined`), and the hand-written member would then stop + // compiling in the `extends` clause. Spelling it beside the shared list keeps + // the twin compiling against both the installed pin and `main` (the + // `Spec Main Shape Gate`). Retiring it is objectui#9409's decision, ⛔ not + // this file's, so the member must stay exactly what it was. + const assignedProfiles: Equal = true; + const declared: 'assignedProfiles' extends DeclaredKeys ? true : false = true; + expect([assignedProfiles, declared]).toEqual([true, true]); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 11e4c55a5b..c04bf9add7 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -3464,7 +3464,7 @@ export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderL * Exact MIRRORED-BUT-UNDECLARED key set per pair — the seed of the direction * objectui#9711 opened, measured on the tree this ledger landed on. * - * `this ledger seeds **7 entries** carrying **101 keys**` — and ⛔ read that off the + * `this ledger seeds **4 entries** carrying **60 keys**` — and ⛔ read that off the * census at the bottom of this file, not off this sentence: both figures are pinned * to this ledger's own AST by 'the fourth direction is enumerated and sized at test * time', so an entry or an arm added here moves them or the file reddens. @@ -3475,6 +3475,13 @@ export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderL * row stood says which ruling took it and why the neighbouring `operators` row * stayed after a ruling of its own. * + * ⭐ Then by three entries at once, when objectui#9736 executed ruling batch #167 + * item 4 (letter 甲): the SPEC-OWNED INFLOW rows — App, Dashboard, Page — left + * because the three TypeScript twins now take the spec by reference over the SAME + * exclusion arrays their mirrors read. That is not the "declare the keys one by + * one" remedy the next section refuses: no key was hand-declared, the twin and the + * mirror were put on one derivation, and the ruling named the mechanism. + * * ## ⛔ What this ledger is NOT * * ⛔ It is NOT a worklist to be drained by DECLARING the keys it names. Declaring a @@ -3518,57 +3525,25 @@ export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderL * `never` and would never be caught at all. */ interface MirroredUndeclared { - /** - * SPEC-OWNED INFLOW. The mirror is `BaseSchema.extend(SpecAppFields.shape).extend({…})`, - * so the spec's App surface — the `_lock*` / `_package*` / `_provenance` package-lock - * envelope, `protection`, `sharing`, `embed`, `objects`, `apis`, `homePageId`, - * `version`, `isDefault`, `_unpublished`, `aria`, `mobileNavigation`, `defaultAgent` — - * is validated here while the TypeScript interface restates only the component - * envelope its renderers read. `contextSelectors` is the one LOCAL arm: the spec - * fields deliberately omit it and the mirror's own `.extend({…})` adds it back, so - * it is mirrored by this file's own hand and still undeclared. - */ - "app.zod.ts#AppComponentSchema": - | "contextSelectors" - | "_lock" - | "_lockReason" - | "_lockSource" - | "_provenance" - | "_packageId" - | "_packageVersion" - | "_lockDocsUrl" - | "version" - | "isDefault" - | "_unpublished" - | "homePageId" - | "objects" - | "apis" - | "sharing" - | "embed" - | "mobileNavigation" - | "defaultAgent" - | "aria" - | "protection"; + // `app.zod.ts#AppComponentSchema` HAD a row here — 20 keys of SPEC-OWNED INFLOW + // (the package-lock envelope, `protection`, `isDefault`, `_unpublished`, + // `defaultAgent`, the spec's tombstones, and the LOCAL `contextSelectors` arm) — + // and its DELETION is the ratchet moving in its only permitted direction. + // objectui#9736 (ruling batch #167 item 4, letter 甲) made the TypeScript twin take + // the spec BY REFERENCE the way its mirror does: `AppComponentSchema extends + // BaseSchema, Omit< App, … >` over `APP_SPEC_EXCLUDED`, the one `as const` array the + // mirror's `specFieldsExcept` call also reads, and `contextSelectors` declared on + // the twin over the mirror's own element schema. The pair now measures `never`; + // re-adding the row without re-adding the defect reddens the reconciliation below. + + + // `complex.zod.ts#DashboardComponentSchema` HAD a row here — 11 keys (the envelope, + // `protection`, and the tombstones `aria` / `refreshInterval` / `performance`) — + // deleted by objectui#9736 for the reason the App note above gives: the twin + // extends `Omit< Dashboard, … >` over `DASHBOARD_SPEC_EXCLUDED`. Its `header` + // member is withheld from that projection on the TypeScript face only, and stays + // in `KnownDrift` / `WiderThanDeclared` where it was. - /** - * SPEC-OWNED INFLOW, same envelope as the App pair above plus this surface's own - * `refreshInterval` and `performance`. ⚠️ This pair already carries entries in - * `KnownDrift` and has carried them in `UnmirroredDeclared`; the three directions - * are independent measurements of one pair and an entry in one says nothing about - * the others. - */ - "complex.zod.ts#DashboardComponentSchema": - | "_lock" - | "_lockReason" - | "_lockSource" - | "_provenance" - | "_packageId" - | "_packageVersion" - | "_lockDocsUrl" - | "aria" - | "protection" - | "refreshInterval" - | "performance"; /** * LOCAL, and a single key: the mirror states `aria` and the declaration does not. @@ -3659,22 +3634,12 @@ interface MirroredUndeclared { | "minDate" | "maxDate"; - /** - * SPEC-OWNED INFLOW. The package-lock envelope again, plus `source`, - * `interfaceConfig` and `requires` — page-level spec keys the mirror validates and - * the TypeScript node does not state. - */ - "layout.zod.ts#PageNodeSchema": - | "source" - | "_lock" - | "_lockReason" - | "_lockSource" - | "_provenance" - | "_packageId" - | "_packageVersion" - | "_lockDocsUrl" - | "interfaceConfig" - | "requires"; + // `layout.zod.ts#PageNodeSchema` HAD a row here — 10 keys (the envelope, `source`, + // `interfaceConfig`, `requires`) — deleted by objectui#9736 for the same reason: + // the twin extends `Omit< Page, … >` over `PAGE_SPEC_EXCLUDED`. Its `slots` member + // is withheld from that projection on the TypeScript face only, and stays in + // `KnownDrift` / `WiderThanDeclared` where it was. + /** * LOCAL, one key — and the row STAYS after its ruling, which is the thing to @@ -3987,6 +3952,18 @@ const EXCLUSIONS: Readonly> = { "a union (`string | { dialect?, source }`) with no `.shape` of its own — the predicate WIRE shape `BaseSchema`'s `visible` / `hidden` / `disabled` and the form predicate keys carry (objectui#7530); its TS twin `ExpressionWire` (`../expression.ts`) is a type alias, not a key set, and the two faces are pinned equal in `base-schema-predicate-envelope-7530.test.ts`", 'index.zod.ts#SCHEMA_VERSION': "a version string, not a schema", + // objectui#9736 — the three exclusion lists each spec-derived mirror's + // `specFieldsExcept` call reads, hoisted to one `as const` array so the + // TypeScript twin can `Omit` over the SAME list. Key-name tuples, not schemas; + // what they govern is measured on the pairs they feed + // (`app.zod.ts#AppComponentSchema` / `complex.zod.ts#DashboardComponentSchema` / + // `layout.zod.ts#PageNodeSchema`), pinned in `./twins-spec-by-reference-9736.test.ts`. + 'app.zod.ts#APP_SPEC_EXCLUDED': + "a key-name tuple, not a schema — the exclusion list `SpecAppFields` and the `AppComponentSchema` twin both read (objectui#9736)", + 'complex.zod.ts#DASHBOARD_SPEC_EXCLUDED': + "a key-name tuple, not a schema — the exclusion list `SpecDashboardFields` and the `DashboardComponentSchema` twin both read (objectui#9736)", + 'layout.zod.ts#PAGE_SPEC_EXCLUDED': + "a key-name tuple, not a schema — the exclusion list `SpecPageFields` and the `PageNodeSchema` twin both read (objectui#9736)", 'objectql.zod.ts#KanbanConditionalFormattingRuleSchema': "a union of two rule dialects (native `{ field, operator, value }` | spec `{ condition, style }`) with no `.shape` of its own — exported by objectui#7664 so the `'kanban'` arm (`complex.zod.ts#KanbanSchema`) and the `'object-kanban'` arm mirror `conditionalFormatting` from ONE rule declaration; its TS twin `KanbanConditionalFormattingRule` (`../objectql.ts`) is a type union, not a key set, and both arms' `conditionalFormatting` keys are compared where they are declared", }; diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index 850ee60718..f9f026e8f9 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -51,8 +51,11 @@ import type { UrlNavItem as SpecUrlNavItem, ActionNavItem as SpecActionNavItem, ComponentNavItem as SpecComponentNavItem, + App as SpecApp, } from '@objectstack/spec/ui'; import type { BaseSchema } from './base.js'; +import type { z } from 'zod'; +import type { APP_SPEC_EXCLUDED, AppContextSelectorSchema } from './zod/app.zod.js'; // ============================================================================ // Unified Navigation Model (aligned with @objectstack/spec) @@ -366,8 +369,41 @@ export interface NavigationArea extends Omit { /** * Top-level Application Configuration (app.json) + * + * ## The spec half is taken BY REFERENCE (objectui#9736) + * + * Its zod mirror (`zod/app.zod.ts` `AppComponentSchema`) has long been + * `BaseSchema.extend(SpecAppFields.shape).extend({…})`: every key + * `@objectstack/spec/ui`'s `AppSchema` declares flows into the published + * validator by reference. This interface used to restate only the subset the + * renderers read, so the published validator admitted keys the published type + * did not declare — the package-lock envelope (`_lock*` / `_package*` / + * `_provenance`, written by the packaging pipeline, never by an author), + * `protection`, `isDefault`, `_unpublished`, `defaultAgent` — and the spec's + * retirement tombstones (`version`, `homePageId`, `objects`, `apis`, + * `sharing`, `embed`, `mobileNavigation`, `aria`) reached this type only as + * `any` through `BaseSchema`'s index signature. + * + * Now both faces project the SAME spec surface: this interface extends + * `Omit< App, … >` over `APP_SPEC_EXCLUDED`, the one `as const` array the + * mirror's `specFieldsExcept` call also reads. A key the spec adds on the next + * pin bump reaches both faces together; a key it retires with `retiredKey()` + * surfaces here as an optional member typed `undefined`, so authoring a value + * is a compile error — the verdict the validator gives at parse. + * + * The members this interface writes itself override the spec's where both + * exist (`icon`, `branding`, `active`, `hidden`, `requiredPermissions`), and + * each is assignable to the spec's type, so nothing beyond the shared list is + * omitted. The three keys the shared list withholds from the spec projection + * (`navigation` / `areas` / `contextSelectors`) are declared below with the + * same local element types the mirror re-adds; `name` / `label` / + * `description` are the component envelope. + * + * Pinned by `__tests__/twins-spec-by-reference-9736.test.ts`; the key-level + * reconciliation is the `MirroredUndeclared` ledger in + * `__tests__/zod-mirror-parity.test.ts`, which has no row for this pair. */ -export interface AppComponentSchema extends BaseSchema { +export interface AppComponentSchema extends BaseSchema, Omit { type: 'app'; /** @@ -518,6 +554,18 @@ export interface AppComponentSchema extends BaseSchema { */ areas?: NavigationArea[]; + /** + * App-level scope dropdowns (sidebar / topbar), whose selected value is + * injected into navigation items as a `{}` template var. + * + * Withheld from the spec projection by `APP_SPEC_EXCLUDED` because the + * mirror re-adds it with its own element schema (`AppContextSelectorSchema`, + * whose `label` also takes objectui's i18n label envelope), so this member + * takes that element BY REFERENCE (objectui#9736) rather than restating it — + * the two faces read one declaration. + */ + contextSelectors?: Array>; + /** * Global Actions (User Profile, Settings, etc) */ diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index e3787c6aa6..073d790f5a 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -19,8 +19,10 @@ import type { DashboardWidget as SpecDashboardWidget, DateRangeDefaultRange as SpecDateRangeDefaultRange, GlobalFilter as SpecGlobalFilter, + Dashboard as SpecDashboard, } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; +import type { DASHBOARD_SPEC_EXCLUDED } from './zod/complex.zod.js'; // `GroupingConfig`, `KanbanConditionalFormattingRule` and `ViewNavigationConfig` // were imported for `KanbanSchema`'s `grouping`, `conditionalFormatting` and // `navigation` members and had no other reader in this module; they left with @@ -2132,8 +2134,41 @@ export interface DashboardWidgetSlotComponentSchema extends BaseSchema { /** * Dashboard Schema + * + * ## The spec half is taken BY REFERENCE (objectui#9736) + * + * The zod mirror (`zod/complex.zod.ts` `DashboardComponentSchema`) is + * `BaseSchema.extend(SpecDashboardFields.shape).extend({…})`, so every key + * `@objectstack/spec/ui`'s `DashboardSchema` declares reaches the published + * validator by reference. This interface used to restate only the members the + * renderers read, which left the validator admitting keys the type never + * declared (the package-lock envelope `_lock*` / `_package*` / `_provenance`, + * written by the packaging pipeline, and `protection`) and the spec's + * tombstones (`aria`, `refreshInterval`, `performance`) typed `any` through + * `BaseSchema`'s index signature. + * + * Now it extends `Omit< Dashboard, … >` over `DASHBOARD_SPEC_EXCLUDED`, the one + * `as const` array the mirror's `specFieldsExcept` call also reads, so the two + * faces project the same spec surface and move together on a pin bump. A + * tombstone surfaces as an optional member typed `undefined`: authoring a + * value is a compile error, the verdict the validator gives at parse. + * + * ONE key is omitted from the spec projection beyond the shared list, on the + * TypeScript face only: + * - `header` — the member below is a hand-written restatement that DISAGREES + * with the spec's in both directions (`actions[].label` narrower: `string` + * against `I18nLabel`; `actions[].actionUrl` optional where the spec + * requires it; `actions[].actionType` an open `string` against the spec's + * enum), so it is not assignable to the spec's member and cannot sit beside + * it. That disagreement is already ledgered as `KnownDrift` and + * `WiderThanDeclared` for this pair in `__tests__/zod-mirror-parity.test.ts`; + * re-aligning it is that ledger's decision, ⛔ not widened here. The mirror + * keeps validating the spec's `header` — the omission is type-side only, + * which is why it is spelled beside the shared list, not inside it. + * + * Pinned by `__tests__/twins-spec-by-reference-9736.test.ts`. */ -export interface DashboardComponentSchema extends BaseSchema { +export interface DashboardComponentSchema extends BaseSchema, Omit { type: 'dashboard'; // `title` was DECLARED here until objectui#7623, under the comment "Dashboard // title displayed in the header" — by then a description of behaviour that had @@ -2260,9 +2295,9 @@ export interface DashboardComponentSchema extends BaseSchema { // `DashboardSchema.shape.aria` is a tombstone that refuses any value, the // Zod twin (`zod/complex.zod.ts`) inherits that refusal through // `SpecDashboardFields`, and `plugin-dashboard` has no `schema.aria` read - // site. Note `BaseSchema`'s index signature still types an authored `aria` - // as `any` — this deletion removes the type-level suggestion and the false - // parity claim, not a key that ever rendered. Pinned by + // site. Since objectui#9736 this interface inherits the same tombstone from + // the spec projection (`aria?: undefined`), so an authored `aria` is now a + // compile error rather than `any` through the index signature. Pinned by // `__tests__/dashboard-aria-retired-contract-twins.test.ts`. /** * REFUSED BY NAME (objectui#9256, ADR-0049) — `dashboard` reads NEITHER diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 6acdee8dac..aeb7d9ec1e 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -16,8 +16,9 @@ * @packageDocumentation */ -import type { I18nLabel, PageType as SpecPageType } from '@objectstack/spec/ui'; +import type { I18nLabel, Page as SpecPage, PageType as SpecPageType } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; +import type { PAGE_SPEC_EXCLUDED } from './zod/layout.zod.js'; import type { BreakpointName } from './mobile.js'; /** @@ -1284,6 +1285,45 @@ export interface PageNodeRegion { * Top-level container for a page route. * Aligned with @objectstack/spec PageSchema * + * ## The spec half is taken BY REFERENCE (objectui#9736) + * + * The zod mirror (`zod/layout.zod.ts` `PageNodeSchema`) is + * `BaseSchema.extend(SpecPageFields.shape).extend({…})`, so every key the + * spec's `PageSchema` declares reaches the published validator by reference. + * This interface used to restate only the members the renderers read, leaving + * the validator admitting keys the type never declared — the package-lock + * envelope (`_lock*` / `_package*` / `_provenance`, written by the packaging + * pipeline), `source`, `interfaceConfig`, `requires`. It now extends + * `Omit< Page, … >` over `PAGE_SPEC_EXCLUDED`, the one `as const` array the + * mirror's `specFieldsExcept` call also reads, so both faces project the same + * spec surface and move together on a pin bump. + * + * TWO keys are omitted from the spec projection beyond the shared list, on + * the TypeScript face only: + * - `slots` — the member below types each slot as objectui's `SchemaNode` + * (which admits primitives and `null`), not the spec's page-component + * shape, so it is not assignable to the spec's member and cannot sit beside + * it. That divergence is already ledgered as `KnownDrift` and + * `WiderThanDeclared` for this pair in `__tests__/zod-mirror-parity.test.ts`; + * ⛔ not changed here. The mirror keeps validating the spec's `slots` — the + * omission is type-side only, so it is spelled beside the shared list, not + * inside it. + * - `assignedProfiles` — a FORWARD-COMPAT omission. On the installed spec the + * spec's member is `string[]` and the one below matches it, but objectstack + * `main` has retired the key (`retiredKey()`, so its input type is + * `undefined`), and the hand-written `string[]` is not assignable to that. + * Without this omission the twin would stop compiling at the next pin bump, + * which `Spec Main Shape Gate` measures today. Retiring the member here is + * objectui#9409's decision, which is on hold until the installed spec + * refuses the key. ⛔ It is not retired, and not widened, here. Like + * `slots`, it is spelled beside the shared list, so the mirror keeps + * validating whatever the installed spec declares. + * + * The other members this interface writes itself (`icon`, `object`, + * `template`, `variables`, `isDefault`, `aria`, `kind`) + * are each assignable to the spec's, so they override it without an omission. + * Pinned by `__tests__/twins-spec-by-reference-9736.test.ts`. + * * This is the SDUI NODE, not the authored page DOCUMENT — the spec's `Page` * is that, and the two are deliberately different types (same layer split as * {@link PageNodeRegion}). @@ -1293,7 +1333,7 @@ export interface PageNodeRegion { * `@object-ui/components` registers `PageRenderer` under, i.e. the wire key * authored metadata carries. Nothing else in the repo pins it. */ -export interface PageNodeSchema extends BaseSchema { +export interface PageNodeSchema extends BaseSchema, Omit { type: 'page'; /** * ⛔ REFUSED BY NAME — `actions` is not a member of this node and never was diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index edba7cd23e..3020220e92 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -389,14 +389,20 @@ export const AppContextSelectorSchema = stripImportedDefaults(SpecAppContextSele * `.partial()` guarantees no *future* spec field can become required and * silently invalidate stored objectui apps. */ -const SpecAppFields = specFieldsExcept(stripImportedDefaults(SpecAppSchema).shape, [ +export const APP_SPEC_EXCLUDED = [ 'name', 'label', 'description', 'navigation', 'areas', 'contextSelectors', -] as const); +] as const; + +// One list, two readers (objectui#9736): this call and the `AppComponentSchema` +// TypeScript twin in `../app.ts`, which extends `Omit< App, … >` over the same +// array — so the published validator and the published type project one spec +// surface and cannot drift apart again. +const SpecAppFields = specFieldsExcept(stripImportedDefaults(SpecAppSchema).shape, APP_SPEC_EXCLUDED); /** * App Schema — the objectui app-shell renderer node, derived from diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 430ab45b47..47248a2fe1 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -1161,14 +1161,20 @@ export const GlobalFilterSchema = z.object({ * `.partial()` guarantees no *future* spec field can become required and * silently invalidate stored objectui dashboards. */ -const SpecDashboardFields = specFieldsExcept(stripImportedDefaults(SpecDashboardSchema).shape, [ +export const DASHBOARD_SPEC_EXCLUDED = [ 'name', 'label', 'description', 'widgets', 'globalFilters', 'dateRange', -] as const); +] as const; + +// One list, two readers (objectui#9736): this call and the `DashboardComponentSchema` +// TypeScript twin in `../complex.ts`, which extends `Omit< Dashboard, … >` over the same +// array — so the published validator and the published type project one spec +// surface and cannot drift apart again. +const SpecDashboardFields = specFieldsExcept(stripImportedDefaults(SpecDashboardSchema).shape, DASHBOARD_SPEC_EXCLUDED); /** * Dashboard Schema — the objectui dashboard renderer node, derived from diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 47b0ec6578..a21b3fa44f 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -631,13 +631,19 @@ export const PageTypeSchema = stripImportedDefaults(SpecPageTypeSchema); * `.partial()` guarantees no *future* spec field can become required and * silently invalidate stored objectui pages. */ -const SpecPageFields = specFieldsExcept(stripImportedDefaults(SpecPageSchema).shape, [ +export const PAGE_SPEC_EXCLUDED = [ 'name', 'label', 'description', 'type', 'regions', -] as const); +] as const; + +// One list, two readers (objectui#9736): this call and the `PageNodeSchema` +// TypeScript twin in `../layout.ts`, which extends `Omit< Page, … >` over the same +// array — so the published validator and the published type project one spec +// surface and cannot drift apart again. +const SpecPageFields = specFieldsExcept(stripImportedDefaults(SpecPageSchema).shape, PAGE_SPEC_EXCLUDED); /** * The `actions` REFUSAL on the `page` node (objectui#7926, maintainer ruling