From e9f861947bc6ab154afd6b0da99010f0440e7fed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 03:13:40 +0000 Subject: [PATCH] feat(types)!: mirror eight ObjectViewSchema keys (three by spec reference) and retire viewTabBar (objectui#7779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectViewSchema`'s TypeScript face declared ten keys its Zod mirror never did (objectui#7279's `UnmirroredDeclared` reading), so a document authoring any of them passed the validator unexamined through `BaseSchema`'s passthrough. Maintainer ruling B (2026-09-06): liveness first, then mirror-or-retire per key. - objectql.zod.ts: `navigation` / `searchableFields` / `filterableFields` are `SpecListViewSchema.shape.*` by reference (identity-pinned); `allowCreateView` / `viewActions` are the sibling `ViewSwitcherSchema` slots by reference (the renderer forwards both verbatim into the `view-switcher` node); `defaultViewType` (the declaration's seven-value union) / `defaultListView` / `showViewSwitcher` are local literals after a reader census on `plugin-view/src/ObjectView.tsx`; `viewTabBar` is a `retirementTombstone()` (zero reads — the tab-bar config is `ViewTabBar`'s `config` prop from the host, never a node key) - objectql.ts: `viewTabBar?: never` with the RETIRED docblock (the #7322 shape) - `listViews` stays unmirrored on the ruling's own fallback clause, with the measurement pinned against the spec: the declaration's `NamedListView` (about 52 members, seven read) and the spec's strict `ObjectListViewSchema` (requires `columns`, refuses `options`, tuple filters and `default`) are incompatible, and the spec value refuses the named views the docs teach - zod-mirror-parity: the entry shrinks to `listViews`; `ObjectViewSchema` joins `SPEC_DERIVED_PAIRS` by a real code reference; header figures 14 / 96 -> 14 / 87, split 1 / 2 + 13 / 94 -> 2 / 3 + 12 / 84, with the history sentences - object-view-spec-parity: `TS_ONLY_BACKLOG` shrinks to `listViews` + `onNavigate`; the audit figures move 11 / 13 -> 20 / 22 with the arithmetic - new pin object-view-unmirrored-keys-7779: reads derived off disk with controls, membership, spec/sibling identity, accept-and-survive plus wrong-typed refusal at the key per mirrored key, the tombstone on both faces, the `listViews` measurement, neighbour and passthrough controls, the two doc tables - docs: the plugin-view README and plugin-view.mdx import tables no longer teach `viewTabBar` as a node key - changeset: `@object-ui/types` minor, "Breaking for authored metadata" Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BAZFhALsQsGqxui8sNqM8s --- .../object-view-unmirrored-keys-7779.md | 80 +++ content/docs/plugins/plugin-view.mdx | 4 +- packages/plugin-view/README.md | 4 +- .../__tests__/object-view-spec-parity.test.ts | 50 +- .../object-view-unmirrored-keys-7779.test.ts | 592 ++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 138 ++-- packages/types/src/objectql.ts | 30 +- packages/types/src/zod/objectql.zod.ts | 70 +++ 8 files changed, 898 insertions(+), 70 deletions(-) create mode 100644 .changeset/object-view-unmirrored-keys-7779.md create mode 100644 packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts diff --git a/.changeset/object-view-unmirrored-keys-7779.md b/.changeset/object-view-unmirrored-keys-7779.md new file mode 100644 index 0000000000..767209c30b --- /dev/null +++ b/.changeset/object-view-unmirrored-keys-7779.md @@ -0,0 +1,80 @@ +--- +'@object-ui/types': minor +--- + +**Breaking for authored metadata:** `ObjectViewSchema.viewTabBar` is RETIRED +(objectui#7779, maintainer ruling B of 2026-09-06; ADR-0049 enforce-or-remove). +An `object-view` node that authors `viewTabBar` no longer validates: the parse +fails loudly on the `viewTabBar` path with the explanation in the message, and +the TS member is a `?: never` tombstone so the same document is refused at +compile time. Nothing ever read the key off the node — the tab-bar UX config +(`ViewTabBarConfig`, still exported) is the `config` prop of the `ViewTabBar` +component, composed by the host, not authored metadata. Remove the key. + +In the same change, eight `ObjectViewSchema` keys the TypeScript interface +declared and the Zod mirror never did are now ENFORCED. Until now a document +authoring any of them passed the validator unexamined through `BaseSchema`'s +passthrough while the published type invited the author to write it — declared +but not enforced (objectui#7279's `UnmirroredDeclared` reading). Each key now +admits exactly what the declaration promises and refuses a wrong-typed value at +its own path; a correctly typed document is untouched. + +| key | disposition | how | +| --- | --- | --- | +| `navigation` | mirrored | the spec's `ListViewSchema.navigation` slot by reference (`NavigationConfigSchema`, optional) | +| `searchableFields` | mirrored | the spec's `ListViewSchema.searchableFields` slot by reference (`string[]`, optional) | +| `filterableFields` | mirrored | the spec's `ListViewSchema.filterableFields` slot by reference (`string[]`, optional; the spec marks it a legacy shorthand for `userFilters.fields`) | +| `allowCreateView` | mirrored | the sibling `ViewSwitcherSchema.allowCreateView` slot by reference — the renderer forwards the value verbatim into the `view-switcher` node it composes | +| `viewActions` | mirrored | the sibling `ViewSwitcherSchema.viewActions` slot by reference, for the same reason | +| `defaultViewType` | mirrored | local literal: the declaration's seven-value union (`grid`, `kanban`, `gallery`, `calendar`, `timeline`, `gantt`, `map`) — read as `schema.defaultViewType \|\| 'grid'` | +| `defaultListView` | mirrored | local literal: `string` — read as `namedListViews?.[schema.defaultListView]` | +| `showViewSwitcher` | mirrored | local literal: `boolean` — read as `schema.showViewSwitcher === true` | +| `viewTabBar` | RETIRED | `?: never` + `retirementTombstone()` — zero reads on the node | +| `listViews` | unchanged (still unmirrored) | see below | + +**What was measured.** Every reading was taken on the `object-view` node +renderer (`packages/plugin-view/src/ObjectView.tsx`, registered by +`plugin-view/src/index.tsx`) with `schema.objectName` / `schema.layout` as the +positive controls of the same `schema.KEY` query, so each zero is a reading; +the repo-wide census of `viewTabBar` finds the key in no source file outside +`@object-ui/types` (two doc tables listed it as authorable and are corrected +here). The spec side was read through the installed pin +(`@objectstack/spec@17.2.0`, `ui` entry, 117 exported object schemas walked; +control keys `objectName` / `columns` / `navigation` / `listViews` hit): the +three spec-modelled keys are optional slots on `ListViewSchema` and +`ObjectListViewSchema`; the six local keys have no spec slot anywhere. + +**`listViews` stays unmirrored, on the ruling's own fallback clause.** The +declaration's value is the local `NamedListView` (about 52 members, of which the +renderer reads seven — `label`, `type`, `columns`, `filter`, `sort`, `options`, +`data`); the spec's `ViewSchema.listViews` is a record of the STRICT +`ObjectListViewSchema`, which requires `columns` and refuses `options`, ObjectQL +tuple filters and `default` — that is, it refuses the named views this package's +own README and `content/docs/api/schema-reference.md` teach (`{ label: 'All +Users' }` fails at `columns`; `filter: [["owner", "=", "..."]]` fails at +`filter.0`). Mirroring the spec value would lose documented behaviour; mirroring +the local value would enforce roughly 45 unread members into the contract — the +very thing ruling B refused for the six local keys. The key therefore stays in +the parity ledger with that measurement, pinned, until the maintainer decides its +value type. It is not papered over with `z.any()`. + +**Who is affected:** an author who wrote `viewTabBar` on an `object-view` node +(remove it), or who wrote a wrong-typed value for one of the eight keys — e.g. +`defaultViewType: 'tree'` (host-composition-only, objectui#5321), +`navigation: 'page'` (write `navigation: { mode: 'page' }`), +`searchableFields: 'name'` (write an array), `viewActions: 'share'` (write +`[{ type: 'share' }]`). Such documents used to pass validation and render with +the key ignored; they now fail at the key with the reason. + +**Who is NOT affected:** every correctly typed document, and every document +that never wrote these keys — `absent` stays valid on all nine. No renderer +changed. The parity ledger (`zod-mirror-parity.test.ts`) records the move: +`UnmirroredDeclared` 14 entries / 96 keys to 14 / 87, the `ObjectViewSchema` +entry re-derived into the SPEC-DERIVED half because the mirror now references +the spec in code. + +Graded `minor`, not `patch`: this narrows the accepted input set, which is +breaking for any author who wrote the tolerated spellings. It is not `major` +per this repo's fixed-group convention (objectui's own breaking changes ship as +`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略, +mechanically enforced by `scripts/check-changeset-no-major.mjs`). diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index a0562fea6d..b83c1ca9d7 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -573,10 +573,10 @@ under your own key" — it ships **no schema types**. The authored | Import from `@object-ui/types` | What it types | | --- | --- | -| `ObjectViewSchema` | the whole `type: 'object-view'` node — `objectName` (required), `title`, `description`, `layout`, `defaultViewType`, `listViews`, `defaultListView`, `navigation`, `table`, `form`, `searchableFields`, `filterableFields`, the `show*` flags, `operations`, `onNavigate`, `viewTabBar`, `viewActions` | +| `ObjectViewSchema` | the whole `type: 'object-view'` node — `objectName` (required), `title`, `description`, `layout`, `defaultViewType`, `listViews`, `defaultListView`, `navigation`, `table`, `form`, `searchableFields`, `filterableFields`, the `show*` flags, `operations`, `onNavigate`, `allowCreateView`, `viewActions` (`viewTabBar` is retired — objectui#7779 — and refused by name) | | `NamedListView` | one entry of `listViews` | | `ViewNavigationConfig` | `navigation` — row/item click behaviour | -| `ViewTabBarConfig` | `viewTabBar` — tab-bar UX (inline add, overflow, indicators) | +| `ViewTabBarConfig` | the `config` prop of `ViewTabBar` — tab-bar UX (inline add, overflow, indicators), composed by the host; not an `object-view` node key | ```typescript import type { ObjectViewSchema } from '@object-ui/types'; diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index 822c22cf03..fdb816b505 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -639,10 +639,10 @@ re-exporting, so import it from there: | Import from `@object-ui/types` | What it types | | --- | --- | -| `ObjectViewSchema` | the whole `type: 'object-view'` node — `objectName` (required), `title`, `description`, `layout`, `defaultViewType`, `listViews`, `defaultListView`, `navigation`, `table`, `form`, `searchableFields`, `filterableFields`, `show*`, `operations`, `onNavigate`, `viewTabBar`, `viewActions` | +| `ObjectViewSchema` | the whole `type: 'object-view'` node — `objectName` (required), `title`, `description`, `layout`, `defaultViewType`, `listViews`, `defaultListView`, `navigation`, `table`, `form`, `searchableFields`, `filterableFields`, `show*`, `operations`, `onNavigate`, `allowCreateView`, `viewActions` (`viewTabBar` is retired — objectui#7779 — and refused by name) | | `NamedListView` | one entry of `listViews` | | `ViewNavigationConfig` | `navigation` — row/item click behaviour | -| `ViewTabBarConfig` | `viewTabBar` — tab-bar UX (inline add, overflow, indicators) | +| `ViewTabBarConfig` | the `config` prop of `ViewTabBar` — tab-bar UX (inline add, overflow, indicators), composed by the host; not an `object-view` node key | ```typescript import type { ObjectViewSchema } from '@object-ui/types'; diff --git a/packages/types/src/__tests__/object-view-spec-parity.test.ts b/packages/types/src/__tests__/object-view-spec-parity.test.ts index f28324c3c5..893689b3fd 100644 --- a/packages/types/src/__tests__/object-view-spec-parity.test.ts +++ b/packages/types/src/__tests__/object-view-spec-parity.test.ts @@ -238,16 +238,20 @@ const tsOwnKeys = Object.keys(TS_OWN_KEYS); * interface only, which is the drift this guard exists to stop. */ const TS_ONLY_BACKLOG = new Set([ - 'defaultViewType', + // objectui#7779 (maintainer ruling B, 2026-09-06) closed nine of the ten gaps + // this set held: `navigation` / `searchableFields` / `filterableFields` are the + // spec's `ListViewSchema` slots by reference, `allowCreateView` / `viewActions` + // the sibling `ViewSwitcherSchema` slots by reference, `defaultViewType` / + // `defaultListView` / `showViewSwitcher` local literals after a reader census, + // and `viewTabBar` is a tombstone on BOTH faces (declared, refused by name) — + // so none of the nine is TS-only any more. The one that stays: + // + // `listViews` — its VALUE type is undecided (the declaration's `NamedListView` + // vs the spec's strict `ObjectListViewSchema`, which refuses the named views + // this package's docs teach); it stays in the parity ledger with that + // measurement (`zod-mirror-parity.test.ts`, `UnmirroredDeclared`) until the + // maintainer decides. ⛔ Not closed with `z.any()`. 'listViews', - 'defaultListView', - 'navigation', - 'searchableFields', - 'filterableFields', - 'showViewSwitcher', - 'viewTabBar', - 'allowCreateView', - 'viewActions', // Not a zod gap: a function, so it CANNOT be declared in a JSON protocol // schema. Recorded here rather than exempted silently. 'onNavigate', @@ -268,9 +272,9 @@ const SPEC_COUNTERPART: Record([ 'operations', // View-management chrome. No spec counterpart — // `UserActionsConfigSchema.buttons` is a string[] of action ids, a different - // shape. + // shape. `allowCreateView` / `viewActions` are READ (forwarded verbatim into + // the `view-switcher` node) and mirrored by reference to that sibling's slots + // (objectui#7779); `viewTabBar` is RETIRED by the same card — a `?: never` / + // `retirementTombstone()` twin, still DECLARED on both faces so an authored + // value is refused by name, which is why it stays in this set: the spec models + // no such key, and the tombstone is objectui-only surface. 'allowCreateView', 'viewActions', 'viewTabBar', @@ -388,14 +397,17 @@ describe('ObjectViewSchema declared-surface consistency (#2890 scope B)', () => ).toEqual([...TS_ONLY_BACKLOG].sort()); }); - it('reproduces the 2026-07 audit\'s declared-surface figures', () => { + it('reproduces the 2026-07 audit\'s declared-surface figures, moved by objectui#7779', () => { // Kept executable so "we closed the gap" is a test edit, not a claim. // - // The audit reported "declared in zod: 13". Reproduced here as 11 - // non-envelope keys plus the 2 narrowed envelope keys above — the same 13 - // keys, with the arithmetic shown rather than asserted. - expect(ouiZodKeys.filter((k) => !ENVELOPE.has(k))).toHaveLength(11); - expect(ouiDeclaredKeys).toHaveLength(13); + // The audit reported "declared in zod: 13" — reproduced then as 11 + // non-envelope keys plus the 2 narrowed envelope keys above. objectui#7779 + // added NINE members to the zod shape (eight mirrored keys plus the + // `viewTabBar` tombstone, which is a shape member so it can refuse by name): + // 11 + 9 = 20 non-envelope keys, 20 + 2 = 22 declared. The TS figure below + // did not move — every one of the nine was already declared there. + expect(ouiZodKeys.filter((k) => !ENVELOPE.has(k))).toHaveLength(20); + expect(ouiDeclaredKeys).toHaveLength(22); // The interface's own surface beyond the envelope. The audit counted 25 // declared fields including the 3 whose names the envelope also owns // (`type`, `description`, `className`); 22 is that figure with those three diff --git a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts new file mode 100644 index 0000000000..d4ea5d03de --- /dev/null +++ b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -0,0 +1,592 @@ +/** + * 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#7779 — `ObjectViewSchema`'s ten unmirrored declared keys, closed + * nine-for-ten under maintainer ruling B (2026-09-06: liveness first, then + * mirror-or-retire per key), with `listViews` left in the parity ledger by + * measurement. + * + * ## The defect + * + * `ObjectViewSchema` in `../objectql.ts` declared ten keys its Zod mirror in + * `../zod/objectql.zod.ts` never did (objectui#7279's `UnmirroredDeclared` + * reading). `BaseSchema` is `.passthrough()`, so a document authoring any of + * them passed the validator UNEXAMINED while the published type invited the + * author to write it: `defaultViewType: 'tree'`, `navigation: 'page'`, + * `searchableFields: 'name'` all parsed green and rendered with the key + * ignored — declared, not enforced. + * + * ## What this file pins, and the shapes it borrows + * + * The mirror half is `object-kanban-group-by-limit-7322.test.ts`: membership is + * asserted on the mirror's OWN `.shape`, never on parse acceptance (under + * `.passthrough()` acceptance cannot tell "declared" from "admitted + * unexamined"); every mirrored key carries an accepted-and-survives assertion + * AND a wrong-typed refusal AT the key — the pairing that makes the pin a + * reading rather than a tolerance; and the read set is DERIVED off the renderer + * with a positive control, so a zero is a reading. + * + * The by-reference half is `spec-subschema-parity.test.ts`: the three spec keys + * are pinned by IDENTITY against the spec slot (`SpecListViewSchema.shape.*`), + * not against a copy, so a spec-side change moves them; the two view-switcher + * keys the same way against the sibling `ViewSwitcherSchema` slots the renderer + * forwards them into verbatim. + * + * The retire half is `TimelineSchema.timeScale` (objectui#6355) / + * `ObjectKanbanSchema.groupField` (objectui#7322): `?: never` on the TS face and + * `retirementTombstone()` on the mirror, BOTH halves, so the retired spelling is + * refused BY NAME on each face rather than deleted into the index signature. + * + * ## The one that stayed, and why it is pinned too + * + * `listViews` is NOT mirrored. The ruling's own fallback clause fires on the + * measurement below: the declaration's value is the local `NamedListView` + * (about 52 members, of which the renderer reads seven), the spec's + * `ViewSchema.listViews` is a record of the STRICT `ObjectListViewSchema`, and + * the spec value refuses the named views this package's docs teach. Both facts + * are asserted against the SPEC schema here, so the day the spec relaxes (or + * the renderer's read set moves) the measurement — and the stop — is re-taken + * rather than remembered. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { + ListViewSchema as SpecListViewSchema, + ObjectListViewSchema as SpecObjectListViewSchema, + NavigationConfigSchema as SpecNavigationConfigSchema, + ViewSchema as SpecViewSchema, +} from '@objectstack/spec/ui'; + +import { ObjectViewSchema } from '../zod/objectql.zod'; +import { ViewSwitcherSchema } from '../zod/views.zod'; +import { safeValidateSchema } from '../zod/index.zod'; +import type { ObjectViewSchema as TsObjectViewSchema, NamedListView } from '../objectql'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +/** The `object-view` NODE renderer — registered by `plugin-view/src/index.tsx`. */ +const READER = 'packages/plugin-view/src/ObjectView.tsx'; +/** The switcher the renderer composes; it reads the two forwarded keys. */ +const SWITCHER = 'packages/plugin-view/src/ViewSwitcher.tsx'; +const REGISTRATION = 'packages/plugin-view/src/index.tsx'; +const MIRROR = 'packages/types/src/zod/objectql.zod.ts'; +const DECLARATION = 'packages/types/src/objectql.ts'; +const README = 'packages/plugin-view/README.md'; +const DOC = 'content/docs/plugins/plugin-view.mdx'; + +const SPEC_REFERENCED = ['navigation', 'searchableFields', 'filterableFields'] as const; +const SIBLING_REFERENCED = ['allowCreateView', 'viewActions'] as const; +const LOCAL_LITERALS = ['defaultViewType', 'defaultListView', 'showViewSwitcher'] as const; +const MIRRORED = [...SPEC_REFERENCED, ...SIBLING_REFERENCED, ...LOCAL_LITERALS] as const; +type Mirrored = (typeof MIRRORED)[number]; +const RETIRED = 'viewTabBar'; +const LEDGERED = 'listViews'; + +/** + * Exact source text of the reads, as they stand today. Line numbers drift and + * live in the docblocks' prose only; the READ is the fact. + */ +const READ_TEXT: Record> = { + navigation: [[READER, 'const navigationConfig: ViewNavigationConfig | undefined = schema.navigation;']], + searchableFields: [[READER, 'searchableFields: activeView?.searchableFields ?? (schema as any).searchableFields,']], + filterableFields: [[READER, 'filterableFields: activeView?.filterableFields ?? (schema as any).filterableFields,']], + allowCreateView: [ + [READER, 'allowCreateView: schema.allowCreateView,'], + [SWITCHER, 'const createViewButton = schema.allowCreateView ? ('], + ], + viewActions: [ + [READER, 'viewActions: schema.viewActions,'], + [SWITCHER, '{schema.viewActions.map((action, idx) => {'], + ], + defaultViewType: [[READER, "return schema.defaultViewType || 'grid';"]], + defaultListView: [[READER, 'if (schema.defaultListView && namedListViews?.[schema.defaultListView]) {']], + showViewSwitcher: [[READER, 'const showViewSwitcherToggle = schema.showViewSwitcher === true;']], +}; + +/** The registration's editable-props meta names the three local literals too. */ +const REGISTRATION_TEXT: readonly string[] = [ + "{ name: 'defaultViewType', type: 'enum', label: 'Default View Type', enum: ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map'] },", + "{ name: 'defaultListView', type: 'string', label: 'Default Named View' },", + "{ name: 'showViewSwitcher', type: 'boolean', label: 'Show View Switcher' },", +]; + +/** A declared-keys-only control of the read set: read, declared, untouched. */ +const READ_CONTROL_KEY = 'objectName'; +/** + * A plausible view-chrome spelling the renderer never reads. It stays undeclared + * on both faces — the proof that this change declares the keys the card measured + * and nothing else. + */ +const CONTROL_KEY = 'viewSwitcherPosition'; + +/** The seven `NamedListView` members the renderer reads off a named view. */ +const NAMED_VIEW_READS = ['columns', 'data', 'filter', 'label', 'options', 'sort', 'type'] as const; + +/** The documented node; every assertion below is a delta on it. */ +const NODE = { type: 'object-view', objectName: 'accounts' } as const; + +/** One value per mirrored key that the declaration admits — the accept leg. */ +const ACCEPTED: Record = { + navigation: { mode: 'drawer', view: 'summary_view' }, + searchableFields: ['name', 'email'], + filterableFields: ['status'], + allowCreateView: true, + viewActions: [{ type: 'share', icon: 'share-2' }, { type: 'delete' }], + defaultViewType: 'kanban', + defaultListView: 'all', + showViewSwitcher: true, +}; + +/* ── Type-level pins (invariant equality, house form) ─────────────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; +/** The canonical `any` detector: only `any` absorbs `1 &` down to something `0` extends. */ +type IsAny = 0 extends (1 & T) ? true : false; + +type ViewKind = 'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map'; + +// `defaultViewType`: the declaration's SEVEN-value union, optional, not `any`. +export type _DefaultViewTypeIsSevenUnion = Expect>; +export type _DefaultViewTypeIsNotAny = Expect, false>>; +// `viewTabBar`: a `?: never` tombstone — the only value it admits is absence. +// Deleting the member instead would make this `any` (index signature) and the +// pin red, which is the point: the tombstone is load-bearing. +export type _ViewTabBarIsTombstone = Expect>; +export type _ViewTabBarIsNotAny = Expect, false>>; +// `listViews`: STILL the declaration's local value — this card moved neither face. +export type _ListViewsIsTheLocalRecord = Expect | undefined>>; +// The control key is NOT declared: it resolves to `any` through the index +// signature, exactly as the ten did on the zod side before this card. +export type _ControlKeyFallsThroughToIndexSignature = Expect>; + +// The TS face accepts the documented shape on a literal. +export const literal: TsObjectViewSchema = { ...NODE, ...(ACCEPTED as Record) }; +// …REFUSES the retired spelling on a literal (an object is not `never`). This +// directive goes unused — and the type-check goes red with TS2578 — the moment +// the tombstone is deleted or widened back to `ViewTabBarConfig`. +// @ts-expect-error — `viewTabBar` is RETIRED on this node (objectui#7779); it was never read +export const retiredLiteral: TsObjectViewSchema = { ...NODE, viewTabBar: { showAddButton: true } }; +// …and REFUSES the host-only view kind: `tree` is not authorable here (objectui#5321). +// @ts-expect-error — `defaultViewType` is the seven-value union; `tree` is host composition only +export const hostOnlyLiteral: TsObjectViewSchema = { ...NODE, defaultViewType: 'tree' }; + +/* ── Off-disk derivations ─────────────────────────────────────────────────── */ + +function readRepo(rel: string): string { + return readFileSync(join(REPO_ROOT, rel), 'utf8'); +} + +/** Every `schema.KEY` / `(schema as any).KEY` read in a source file, off disk. */ +function schemaReads(rel: string): Set { + const src = readRepo(rel); + return new Set([...src.matchAll(/\bschema(?: as any\))?\.([A-Za-z_$][\w$]*)/g)].map((m) => m[1])); +} + +/** Every `currentNamedViewConfig?.KEY` read in the renderer — the named-view read set. */ +function namedViewReads(): string[] { + const src = readRepo(READER); + return [...new Set([...src.matchAll(/currentNamedViewConfig(?: as any\))?\?\.([A-Za-z_$][\w$]*)/g)].map((m) => m[1]))].sort(); +} + +/** Top-level members of `NamedListView`, counted off the declaration source. */ +function namedListViewMemberCount(): number { + const src = readRepo(DECLARATION); + const start = src.indexOf('export interface NamedListView {'); + expect(start, 'NamedListView is no longer declared where this pin reads it').toBeGreaterThan(-1); + const end = src.indexOf('\n}\n', start); + const body = src.slice(start, end); + return [...body.matchAll(/^ {2}[A-Za-z_$][\w$]*\??:/gm)].length; +} + +function shapeKeys(schema: unknown): string[] { + return Object.keys((schema as { shape: Record }).shape); +} + +function shapeMember(schema: unknown, key: string): unknown { + return (schema as { shape: Record }).shape[key]; +} + +interface Issue { + path?: readonly (string | number)[]; + message?: string; + code?: string; + /** zod 4 `invalid_union`: the issues of every option that was tried. */ + errors?: readonly (readonly Issue[])[]; +} + +/** + * Every issue as `path` + `message`, with the nested option errors of an + * `invalid_union` flattened in: `AnyComponentSchema` is a plain `z.union`, so + * a refusal inside the `object-view` arm surfaces as one root `invalid_union` + * issue whose `errors` carry the per-arm paths. + */ +function issueEntries(issues: readonly Issue[], prefix: readonly (string | number)[] = []): Array<{ path: string; message: string; code: string }> { + const out: Array<{ path: string; message: string; code: string }> = []; + for (const issue of issues) { + const path = [...prefix, ...(issue.path ?? [])]; + out.push({ path: path.join('.'), message: issue.message ?? '', code: issue.code ?? '' }); + for (const nested of issue.errors ?? []) out.push(...issueEntries(nested, path)); + } + return out; +} + +function issuePaths(issues: readonly Issue[]): string[] { + return issueEntries(issues).map((e) => e.path); +} + +/** Does any issue sit AT the key or below it (`key`, `key.0`, `key.mode`)? */ +function refusedAt(issues: readonly Issue[], key: string): boolean { + return issuePaths(issues).some((p) => p === key || p.startsWith(`${key}.`)); +} + +/* ── The reads ────────────────────────────────────────────────────────────── */ + +describe('objectui#7779 — the renderer reads the eight mirrored keys, which is the fact the mirror records', () => { + it('the batch is exactly the eight keys the card mirrored, in its three dispositions', () => { + // Non-vacuity for every per-key assertion below, and the card's own bound. + expect(SPEC_REFERENCED).toHaveLength(3); + expect(SIBLING_REFERENCED).toHaveLength(2); + expect(LOCAL_LITERALS).toHaveLength(3); + expect(MIRRORED).toHaveLength(8); + expect(new Set(MIRRORED).size).toBe(8); + }); + + it.each(MIRRORED)('`%s` is still read, as the exact text the docblocks cite', (key) => { + for (const [file, text] of READ_TEXT[key]) { + expect(readRepo(file), `${file} no longer reads \`${key}\` as \`${text}\``).toContain(text); + } + }); + + it('the registration still exposes the three local literals as editable props', () => { + const src = readRepo(REGISTRATION); + for (const text of REGISTRATION_TEXT) expect(src).toContain(text); + }); + + it('the read set, derived from the renderer and the switcher, contains every mirrored key and the read control — and NOT the retired key or the control key', () => { + const reads = new Set([...schemaReads(READER), ...schemaReads(SWITCHER)]); + for (const key of MIRRORED) expect(reads.has(key), `renderer no longer reads schema.${key}`).toBe(true); + // The positive control: the query that returns zero for `viewTabBar` is + // the same query that returns `objectName`, so the zero is a reading. + expect(reads.has(READ_CONTROL_KEY)).toBe(true); + // The retirement's premise: nothing on this node ever read `viewTabBar`. + // If the renderer starts reading it, the tombstone is wrong and this turns + // red BEFORE anyone re-authors the key. + expect(reads.has(RETIRED)).toBe(false); + // Non-vacuity for the control key: if the renderer ever starts reading it, + // this turns red and the control must be re-chosen, not declared on the + // way past. + expect(reads.has(CONTROL_KEY)).toBe(false); + }); + + it('`viewTabBar` reaches the tab bar only as a component PROP from the host, never off the node', () => { + // The boundary the retirement's docblock states: `ViewTabBar` takes + // `config?: ViewTabBarConfig` as a prop; the node renderer renders no tab + // bar itself (ADR-0053, host owns the switcher). + expect(readRepo('packages/plugin-view/src/ViewTabBar.tsx')).toContain('config?: ViewTabBarConfig;'); + expect(readRepo(READER)).not.toMatch(/ { + it.each([...MIRRORED, RETIRED])('`%s` is a member of the mirror shape (membership cannot be read off acceptance under passthrough)', (key) => { + expect(shapeKeys(ObjectViewSchema)).toContain(key); + }); + + it('`listViews` is STILL not a member — the ruling\'s fallback clause, pinned so the ledger entry cannot go stale unnoticed', () => { + expect(shapeKeys(ObjectViewSchema)).not.toContain(LEDGERED); + }); + + it('the control key is undeclared on the mirror too', () => { + expect(shapeKeys(ObjectViewSchema)).not.toContain(CONTROL_KEY); + }); +}); + +/* ── By reference: the spec slots and the sibling slots ───────────────────── */ + +describe('objectui#7779 — the three spec-modelled keys are the spec\'s own slots BY REFERENCE', () => { + it.each(SPEC_REFERENCED)('`%s` IS `SpecListViewSchema.shape.%s` — the same object, not a copy', (key) => { + expect( + shapeMember(ObjectViewSchema, key), + `ObjectViewSchema.${key} must be SpecListViewSchema.shape.${key} by reference — a local ` + + 'restatement is the drift objectui#4588 measured; if the spec slot is wrong, fix the spec', + ).toBe(shapeMember(SpecListViewSchema, key)); + }); + + it.each(SPEC_REFERENCED)('`%s` is also the slot `ObjectListViewSchema` carries under that name (the spec models it on both view faces)', (key) => { + // Not identity — the spec builds the two objects separately — but the same + // accept set on the probes below, which is what "models it" means. + const a = shapeMember(SpecListViewSchema, key) as { safeParse(v: unknown): { success: boolean } }; + const b = shapeMember(SpecObjectListViewSchema, key) as { safeParse(v: unknown): { success: boolean } }; + for (const probe of [undefined, ACCEPTED[key], 'page', 42, ['a'], { mode: 'bogus' }]) { + expect(a.safeParse(probe).success, `${key} disagrees on ${JSON.stringify(probe)}`).toBe(b.safeParse(probe).success); + } + }); + + it('`navigation` parses exactly as the spec\'s `NavigationConfigSchema` does (the slot is that schema, optional)', () => { + const slot = shapeMember(ObjectViewSchema, 'navigation') as { safeParse(v: unknown): { success: boolean; data?: unknown } }; + // The spec declares `mode: NavigationModeSchema.default('page')`, so a + // config that lets the mode default is legal authored metadata — the exact + // input the hand copy of objectui#4588 refused. + const defaulted = slot.safeParse({ view: 'summary_view' }); + expect(defaulted.success).toBe(true); + expect((defaulted.data as { mode?: string }).mode).toBe('page'); + for (const probe of [{ mode: 'drawer' }, { mode: 'bogus' }, 'page', { mode: 'page', bogus: 1 }, undefined]) { + expect(slot.safeParse(probe).success, JSON.stringify(probe)).toBe(SpecNavigationConfigSchema.optional().safeParse(probe).success); + } + // A string is refused at `navigation`, an unknown mode at `navigation.mode`: + // the spec's strict object, not a local `z.any()`. + expect(SpecNavigationConfigSchema.safeParse({ mode: 'bogus' }).success).toBe(false); + expect(SpecNavigationConfigSchema.safeParse({ mode: 'page', bogus: 1 }).success).toBe(false); + }); + + it('the spec describes `filterableFields` as the legacy shorthand — the deprecation the mirror now inherits by reference', () => { + const slot = shapeMember(ObjectViewSchema, 'filterableFields') as { description?: string }; + expect(slot.description).toMatch(/legacy shorthand for userFilters\.fields/i); + }); +}); + +describe('objectui#7779 — `allowCreateView` / `viewActions` are the sibling `ViewSwitcherSchema` slots BY REFERENCE', () => { + it.each(SIBLING_REFERENCED)('`%s` IS `ViewSwitcherSchema.shape.%s` — one shape for the key the renderer forwards verbatim', (key) => { + expect(shapeMember(ObjectViewSchema, key)).toBe(shapeMember(ViewSwitcherSchema, key)); + }); + + it('the switcher slot admits exactly the four action types the declaration spells, and refuses a fifth', () => { + const slot = shapeMember(ObjectViewSchema, 'viewActions') as { safeParse(v: unknown): { success: boolean } }; + for (const type of ['share', 'settings', 'duplicate', 'delete']) { + expect(slot.safeParse([{ type }]).success, type).toBe(true); + } + expect(slot.safeParse([{ type: 'archive' }]).success).toBe(false); + expect(slot.safeParse([{ icon: 'x' }]).success).toBe(false); + }); +}); + +/* ── The accept leg and the refusal leg, per key ──────────────────────────── */ + +describe('objectui#7779 — each mirrored key is accepted with its declared value and the value SURVIVES the parse', () => { + it('the documented node with all eight keys parses green, directly and through the union door', () => { + const doc = { ...NODE, ...ACCEPTED }; + const r = ObjectViewSchema.safeParse(doc); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + const u = safeValidateSchema(doc); + expect(u.success, u.success ? '' : JSON.stringify(u.error.issues)).toBe(true); + }); + + it.each(MIRRORED)('`%s` survives the parse with its value (spec defaults may be added, nothing is dropped)', (key) => { + const r = ObjectViewSchema.safeParse({ ...NODE, [key]: ACCEPTED[key] }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + if (!r.success) return; + const out = (r.data as Record)[key]; + if (key === 'navigation') { + // `NavigationConfigSchema` fills its defaults on parse; the authored + // members are still there and unchanged. + expect(out).toMatchObject(ACCEPTED.navigation as Record); + } else { + expect(out).toEqual(ACCEPTED[key]); + } + }); + + it.each(MIRRORED)('`%s` is optional: the node without it parses green on both entry paths', (key) => { + const doc: Record = { ...NODE, ...ACCEPTED }; + delete doc[key]; + expect(ObjectViewSchema.safeParse(doc).success).toBe(true); + expect(safeValidateSchema(doc).success).toBe(true); + }); +}); + +describe('objectui#7779 — each mirrored key REFUSES a wrong-typed value AT its key: the enforcement mirroring adds', () => { + it.each([ + ['navigation', 'page'], + ['navigation', { mode: 'bogus' }], + ['searchableFields', 'name'], + ['searchableFields', [1]], + ['filterableFields', 'status'], + ['allowCreateView', 'yes'], + ['viewActions', 'share'], + ['viewActions', [{ type: 'archive' }]], + ['defaultViewType', 'tree'], + ['defaultViewType', 42], + ['defaultListView', 7], + ['showViewSwitcher', 'true'], + ] as const)('refuses `%s` = %j at the key, directly and through the union door', (key, value) => { + // Before this card every one of these rode `.passthrough()` unexamined. + // This is the verdict that moves, and it moves toward refusal. + const r = ObjectViewSchema.safeParse({ ...NODE, [key]: value }); + expect(r.success).toBe(false); + if (!r.success) expect(refusedAt(r.error.issues as readonly Issue[], key), JSON.stringify(issuePaths(r.error.issues as readonly Issue[]))).toBe(true); + const u = safeValidateSchema({ ...NODE, [key]: value }); + expect(u.success).toBe(false); + if (!u.success) expect(refusedAt(u.error.issues as readonly Issue[], key), JSON.stringify(issuePaths(u.error.issues as readonly Issue[]))).toBe(true); + }); + + it('`defaultViewType` admits exactly the seven declared kinds — not the spec\'s nine (`chart` / `tree` are host composition only, objectui#5321)', () => { + const slot = shapeMember(ObjectViewSchema, 'defaultViewType') as { safeParse(v: unknown): { success: boolean } }; + for (const kind of ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map']) { + expect(slot.safeParse(kind).success, kind).toBe(true); + } + for (const kind of ['chart', 'tree', 'list', 'detail']) { + expect(slot.safeParse(kind).success, kind).toBe(false); + } + }); +}); + +/* ── The zod mirror: the retired key ──────────────────────────────────────── */ + +describe('objectui#7779 — the zod mirror REFUSES `viewTabBar` by name', () => { + it('a `viewTabBar`-authored node is refused AT `viewTabBar`, and the message says why and what owns the config now', () => { + const r = ObjectViewSchema.safeParse({ ...NODE, viewTabBar: { showAddButton: true } }); + expect(r.success).toBe(false); + if (r.success) return; + const entries = issueEntries(r.error.issues as readonly Issue[]); + const hit = entries.find((e) => e.path === RETIRED); + expect(hit, JSON.stringify(entries)).toBeDefined(); + expect(hit?.message).toContain('RETIRED (objectui#7779)'); + expect(hit?.message).toContain('ViewTabBar'); + expect(hit?.message).toContain('config'); + // A tombstone reports `invalid_type` — the same code a bare `z.never()` + // reports — so tooling that classifies refusals is unchanged. + expect(hit?.code).toBe('invalid_type'); + }); + + it('…and through the published union entry point', () => { + const u = safeValidateSchema({ ...NODE, viewTabBar: {} }); + expect(u.success).toBe(false); + if (!u.success) expect(refusedAt(u.error.issues as readonly Issue[], RETIRED)).toBe(true); + }); + + it('absent stays valid on both entry paths — a node that never wrote the key is untouched', () => { + expect(ObjectViewSchema.safeParse(NODE).success).toBe(true); + expect(safeValidateSchema(NODE).success).toBe(true); + }); + + it('the guidance is the SAME string on both author-facing channels (message and describe)', () => { + const slot = shapeMember(ObjectViewSchema, RETIRED) as { description?: string }; + const r = ObjectViewSchema.safeParse({ ...NODE, viewTabBar: true }); + expect(r.success).toBe(false); + if (r.success) return; + const hit = issueEntries(r.error.issues as readonly Issue[]).find((e) => e.path === RETIRED); + expect(slot.description).toBe(hit?.message); + }); + + it('both faces carry the tombstone, lockstep — off disk, so deleting either half is caught', () => { + expect(readRepo(DECLARATION)).toContain('viewTabBar?: never;'); + expect(readRepo(DECLARATION)).toContain('RETIRED (objectui#7779)'); + expect(readRepo(MIRROR)).toContain('viewTabBar: retirementTombstone('); + }); +}); + +/* ── `listViews`: the measurement that keeps it in the ledger ─────────────── */ + +describe('objectui#7779 — `listViews` stays unmirrored on the ruling\'s fallback clause; the measurement is pinned against the SPEC', () => { + it('the spec slot `ViewSchema.listViews` is a record whose value is the strict `ObjectListViewSchema`', () => { + const slot = shapeMember(SpecViewSchema, 'listViews') as { unwrap(): { def?: { type?: string; valueType?: unknown }; _def?: { type?: string; valueType?: unknown } } }; + const inner = slot.unwrap(); + const def = inner.def ?? inner._def; + expect(def?.type).toBe('record'); + expect(def?.valueType).toBe(SpecObjectListViewSchema); + // Strict: an unknown key is refused, not stripped — the reason a spec-typed + // value cannot admit the local `NamedListView` vocabulary. + const r = SpecObjectListViewSchema.safeParse({ label: 'x', columns: ['a'], options: {} }); + expect(r.success).toBe(false); + if (!r.success) expect((r.error.issues as readonly Issue[]).some((i) => i.code === 'unrecognized_keys')).toBe(true); + }); + + it('the spec value REFUSES the named views this package\'s docs teach — the behaviour a by-reference mirror would lose', () => { + // README / plugin-view.mdx: `listViews: { all: { label: 'All Users' } }` — + // "each needs a `label`", nothing else. The spec requires `columns`. + const labelOnly = SpecObjectListViewSchema.safeParse({ label: 'All Users' }); + expect(labelOnly.success).toBe(false); + if (!labelOnly.success) expect(refusedAt(labelOnly.error.issues as readonly Issue[], 'columns')).toBe(true); + // README: a `filter`-only view with `type: 'grid'` and no `columns`. + const filtered = SpecObjectListViewSchema.safeParse({ label: 'Under 100', type: 'grid', filter: [{ field: 'price', operator: 'less_than', value: 100 }] }); + expect(filtered.success).toBe(false); + if (!filtered.success) expect(refusedAt(filtered.error.issues as readonly Issue[], 'columns')).toBe(true); + // schema-reference.md: an ObjectQL tuple filter and a `default: true` flag. + const tuple = SpecObjectListViewSchema.safeParse({ label: 'My Deals', columns: ['name'], filter: [['owner', '=', '${currentUser.id}']] }); + expect(tuple.success).toBe(false); + if (!tuple.success) expect(refusedAt(tuple.error.issues as readonly Issue[], 'filter')).toBe(true); + const flagged = SpecObjectListViewSchema.safeParse({ label: 'My Deals', columns: ['name'], default: true }); + expect(flagged.success).toBe(false); + // The control: the shape the schema catalog authors IS accepted, so the + // refusals above are readings of the dialect, not of the schema being + // uniformly closed. + expect(SpecObjectListViewSchema.safeParse({ label: 'Directory', columns: ['name', 'email'] }).success).toBe(true); + }); + + it('the documented shapes the spec refuses are still what the docs teach (the measurement\'s inputs, off disk)', () => { + expect(readRepo(README)).toContain("listViews: { all: { label: 'All Users' } }"); + expect(readRepo(DOC)).toContain("listViews: { all: { label: 'All Users' } }"); + expect(readRepo('content/docs/api/schema-reference.md')).toContain('"filter": [["owner", "=", "${currentUser.id}"]],'); + }); + + it('the renderer reads exactly seven `NamedListView` members off a named view, of a declaration with far more — the reason a local key-for-key mirror was not the answer either', () => { + expect(namedViewReads()).toEqual([...NAMED_VIEW_READS]); + // The tab strip reads `label` off the entries too — same member, second site. + expect(readRepo(READER)).toContain('{view.label || key}'); + const declared = namedListViewMemberCount(); + expect(declared, 'NamedListView shrank to (near) its read set — re-take the listViews decision').toBeGreaterThanOrEqual(40); + expect(declared).toBeGreaterThan(NAMED_VIEW_READS.length); + }); + + it('the TS face still declares `listViews` as the local record (neither face moved)', () => { + expect(readRepo(DECLARATION)).toContain('listViews?: Record;'); + }); +}); + +/* ── Controls: what did NOT move ──────────────────────────────────────────── */ + +describe('objectui#7779 — neighbouring keys did not move, and the passthrough envelope is untouched', () => { + it.each(['showSearch', 'showFilters', 'showSort', 'showCreate', 'showRefresh', 'layout', 'operations', 'table', 'form', 'title', 'description'])( + '`%s` is still a mirror member', + (key) => { + expect(shapeKeys(ObjectViewSchema)).toContain(key); + }, + ); + + it('a neighbour still refuses a wrong-typed value the way it did before (`showSearch`, `layout`)', () => { + const a = ObjectViewSchema.safeParse({ ...NODE, showSearch: 'yes' }); + expect(a.success).toBe(false); + if (!a.success) expect(refusedAt(a.error.issues as readonly Issue[], 'showSearch')).toBe(true); + const b = ObjectViewSchema.safeParse({ ...NODE, layout: 'popover' }); + expect(b.success).toBe(false); + if (!b.success) expect(refusedAt(b.error.issues as readonly Issue[], 'layout')).toBe(true); + }); + + it('the host-composition surface still rides the passthrough (objectui#5097) — this card declared the measured keys and nothing else', () => { + const r = ObjectViewSchema.safeParse({ ...NODE, wrapHeaders: true, [CONTROL_KEY]: 'left' }); + expect(r.success).toBe(true); + if (r.success) { + expect((r.data as Record).wrapHeaders).toBe(true); + expect((r.data as Record)[CONTROL_KEY]).toBe('left'); + } + }); + + it('the mirror still requires the discriminator and the object binding', () => { + expect(ObjectViewSchema.safeParse({ objectName: 'accounts' }).success).toBe(false); + expect(ObjectViewSchema.safeParse({ type: 'object-view' }).success).toBe(false); + }); +}); + +/* ── Docs: the two tables that taught `viewTabBar` as authorable ──────────── */ + +describe('objectui#7779 — the docs no longer teach `viewTabBar` as an `object-view` node key', () => { + it.each([README, DOC])('%s lists `ViewTabBarConfig` as the `ViewTabBar` prop, and drops `viewTabBar` from the node row', (file) => { + const src = readRepo(file); + const nodeRow = src.split('\n').find((line) => line.startsWith('| `ObjectViewSchema` |')); + expect(nodeRow, 'the import table lost its ObjectViewSchema row').toBeDefined(); + expect(nodeRow).not.toMatch(/`viewTabBar`,/); + expect(nodeRow).toContain('`viewTabBar` is retired'); + const configRow = src.split('\n').find((line) => line.startsWith('| `ViewTabBarConfig` |')); + expect(configRow).toContain('`config` prop of `ViewTabBar`'); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 3d07264776..a7469e027e 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -111,7 +111,18 @@ * and 37 / 53 until objectui#7344 swept the string / `z.any()` handler mirrors: * `DetailSchema` and `DetailViewSchema` entered (one `onBack` each) and * `CalendarViewSchema` grew by `onEventClick`. - * - **14 entries** in `UnmirroredDeclared`, **96 keys** across them — 13 / 94 until + * - **14 entries** in `UnmirroredDeclared`, **87 keys** across them — 14 / 96 until + * objectui#7779 closed nine of `ObjectViewSchema`'s ten keys (maintainer ruling + * B, 2026-09-06): eight MIRRORED — `navigation`, `searchableFields`, + * `filterableFields` by reference to the spec's `ListViewSchema` slots, + * `allowCreateView` / `viewActions` by reference to the sibling + * `ViewSwitcherSchema` slots the renderer forwards them into, `defaultViewType` + * / `defaultListView` / `showViewSwitcher` as local literals after a reader + * census — and `viewTabBar` RETIRED (tombstoned on both faces, zero reads). The + * entry KEPT `listViews` on the ruling's own fallback clause (its two value types + * measured incompatible), so the entry count held and only the keys moved; the + * spec reference also moved the entry between the split's halves — see the + * split below. 13 / 94 until * objectui#7655 SEEDED a `ChatbotFloatingSchema` entry with `displayMode` and * `floatingConfig`, the two keys that face declares alongside `ChatbotSchema` * (whose own entry keeps all three of its keys — a pair born ledgered, not a @@ -1202,7 +1213,14 @@ interface KnownDrift { * explicit that forcing the 121 per-key decisions now would be wrong. Two splits * are recorded here so whoever works them off does not re-derive them: * - * - **SPEC-DERIVED (1 entry, 2 keys)** — `DashboardWidgetSchema`. It was 2 / 12 + * - **SPEC-DERIVED (2 entries, 3 keys)** — `DashboardWidgetSchema`, and since + * objectui#7779 `ObjectViewSchema` again, by MEMBERSHIP this time rather than by + * the old false positive: that card made the mirror reference + * `SpecListViewSchema.shape.navigation` / `.searchableFields` / + * `.filterableFields` in CODE, so `SPEC_DERIVED_PAIRS` re-derives the pair + * here, and its one remaining key, `listViews`, is a value-type question the + * card's fallback clause left with the maintainer — NOT objectui#2231's + * unification (ruled out for this pair by name). It was 1 / 2 until then; 2 / 12 * until objectui#7279 RE-DERIVED `ObjectViewSchema`'s side and moved that entry * (ten keys) to the LOCAL half — a RECLASSIFICATION on evidence, not a repair: * no key moved between ledgers and no mirror changed. It was 3 / 13 until @@ -1213,16 +1231,18 @@ interface KnownDrift { * refuses a root `title` outright, and objectui#7509 had already retired every * read of it, so the local declaration was offering a member the spec models * nowhere and no renderer consumed. ⚠️ objectui#6705 invalidated the - * evidence for `ObjectViewSchema`: it is no longer in - * `SPEC_DERIVED_PAIRS` below, because it never referenced a spec schema — it is + * evidence for `ObjectViewSchema`: it was no longer in + * `SPEC_DERIVED_PAIRS` below (until objectui#7779 put a real code reference + * there), because it had never referenced a spec schema — it was * `BaseSchema.extend({…})` of local literals, and the pre-#6705 text scanner * charged it a neighbouring private const's `Spec…` token. That misclassification * was left STANDING as #6705 found it — re-routing those keys from #2231's * unification question to a local mirror edit is a remedy decision on the * `UnmirroredDeclared` ledger, which #6705 was fenced out of — until * objectui#7279 re-derived the side on two measurements. (a) The mirror - * (`objectql.zod.ts`, `ObjectViewSchema = BaseSchema.extend({…})`) takes NO - * shape from the spec: every member is a local literal or a `z.lazy` to a + * (`objectql.zod.ts`, `ObjectViewSchema = BaseSchema.extend({…})`) took NO + * shape from the spec THEN (objectui#7779 later gave it three spec slots by + * reference, which is what re-derives it into this half today): every member was a local literal or a `z.lazy` to a * sibling objectui mirror, and the `Spec…` consts below it (`KanbanConfig`, * `ViewKindEnum`) feed `ListViewSchema`. (b) Read through the pin * (`@objectstack/spec@17.2.0`, `ui` entry, all 122 exported object schemas @@ -1246,8 +1266,12 @@ interface KnownDrift { * spec schema does not model, which is objectui#2231's unification question and * NOT a local mirror edit. They are marked, not exempted: exempting them in the * instrument would re-blind exactly the pairs objectui#5927 leaned on hardest. - * - **LOCAL (13 entries, 94 keys)** — plain omissions from a hand-written mirror. - * It was 12 / 84 until objectui#7279 RECLASSIFIED the `ObjectViewSchema` entry + * - **LOCAL (12 entries, 84 keys)** — plain omissions from a hand-written mirror. + * It was 13 / 94 until objectui#7779 MIRRORED eight of `ObjectViewSchema`'s ten + * keys and RETIRED a ninth, and the spec reference that mirroring introduced + * re-derived the entry (down to `listViews`) into the SPEC-DERIVED half — 12 / 84 + * again, the figure this half read before objectui#7279, the same entry leaving by + * the opposite mechanism. 12 / 84 until objectui#7279 RECLASSIFIED the `ObjectViewSchema` entry * (ten keys) into this half from the SPEC-DERIVED one — the split's first move * of an ENTRY between its halves; no key and no mirror moved. Before that it * was 13 / 84 until objectui#7129 RETIRED `DetailViewSectionSchema.hideEmpty`, @@ -1269,10 +1293,14 @@ interface KnownDrift { * MIRRORED two — the LOCAL `ChartSchema` entry and the seeded `ObjectDataTableSchema` * one — and objectui#7655 SEEDED the LOCAL `ChatbotFloatingSchema` entry, born with * two keys. objectui#7279 then moved `ObjectViewSchema` between the halves (2 / 12 - * and 12 / 84 before it) without moving the totals. The seeded pair is no longer + * and 12 / 84 before it) without moving the totals. objectui#7779 then shrank that + * same entry from ten keys to one (96 → 87 keys; the entry count held) and moved it + * back to the SPEC-DERIVED half by membership — the ledger's first shrink by + * MIRRORING BY REFERENCE across most of an entry, and its second RETIREMENT on the + * LOCAL half (`viewTabBar`). The seeded pair is no longer * among them, and the ledger now totals — on ONE line, because the pin below reads * this sentence off disk — - * **14 entries / 96 keys** — 1 / 2 spec-derived, 13 / 94 local. + * **14 entries / 87 keys** — 2 / 3 spec-derived, 12 / 84 local. * * ⛔ The four split figures above and this totals line are PINNED: 'objectui#7279' * at the bottom of this file derives every one of them from the `UnmirroredDeclared` @@ -1409,35 +1437,46 @@ interface UnmirroredDeclared { | 'reorderableColumns' | 'resizableColumns' | 'rowColor' | 'rowHeight' | 'rowSpecActions' | 'singleClickEdit'; /** - * LOCAL since objectui#7279 — recorded as SPEC-DERIVED → objectui#2231 from - * objectui#6058 until then, on the scanner false positive objectui#6705 exposed: the - * mirror is `BaseSchema.extend({…})` of local literals and `z.lazy` siblings, it - * references no spec schema, and it is not in `SPEC_DERIVED_PAIRS`. ⭐ This pair had - * NO entry in EITHER ledger before objectui#6058 — eleven declared keys the - * published validator has never heard of, and the guard reported the pair clean. - * It is the clearest single instance of the blind spot this ledger exists to make - * visible. It was eleven: `onNavigate` is in `RuntimeOnlyDeclared` below - * (objectui#6152), which did not change the routing of the other ten. Their route - * is the ordinary local one (objectui#6152's worklist); the ten-key widening is a - * remedy on the manual floor and was ⛔ not performed by #7279. For whoever - * mirrors, the spec read through the pin (`@objectstack/spec@17.2.0`, `ui` entry) - * splits them 4 / 6: - * - FOUR the spec models on View-shaped schemas — take the shape BY REFERENCE: - * `navigation` (`ListViewSchema.navigation` = `NavigationConfigSchema`, the - * very type the declaration imports for `ViewNavigationConfig`); - * `searchableFields` and `filterableFields` (`ListViewSchema`, `array(string)`; - * the spec marks `filterableFields` a legacy shorthand for `userFilters.fields`); - * `listViews` (`ViewSchema.listViews`, a record of spec list views — ⚠️ the - * declaration's value is the local `NamedListView`, so the VALUE type is a - * unification question the mirror edit must not paper over with a `z.any()`). - * - SIX the spec models nowhere — plain hand-written omissions: `allowCreateView`, - * `defaultListView`, `defaultViewType`, `showViewSwitcher`, `viewActions`, - * `viewTabBar`. + * SPEC-DERIVED by MEMBERSHIP since objectui#7779 (the mirror references + * `SpecListViewSchema.shape.*` in code, so `SPEC_DERIVED_PAIRS` re-derives it); + * LOCAL between objectui#7279 and then; recorded as SPEC-DERIVED → objectui#2231 + * from objectui#6058 until #7279, on the scanner false positive objectui#6705 + * exposed. ⭐ This pair had NO entry in EITHER ledger before objectui#6058 — + * eleven declared keys the published validator had never heard of, and the guard + * reported the pair clean. It is the clearest single instance of the blind spot + * this ledger exists to make visible. It was eleven: `onNavigate` is in + * `RuntimeOnlyDeclared` below (objectui#6152). It was TEN until objectui#7779 + * (maintainer ruling B, 2026-09-06 — liveness first, then mirror-or-retire per + * key) closed nine, each with its census on the `object-view` node renderer + * (`packages/plugin-view/src/ObjectView.tsx`; `schema.objectName` / `schema.layout` + * the positive controls of the same `schema.KEY` query; pinned in + * `object-view-unmirrored-keys-7779.test.ts`): + * - MIRRORED by reference to the spec slot (`SpecListViewSchema.shape.*`): + * `navigation`, `searchableFields`, `filterableFields` — identity-pinned, so a + * spec-side change moves them; + * - MIRRORED by reference to the sibling `ViewSwitcherSchema` slots the renderer + * forwards them into verbatim: `allowCreateView`, `viewActions`; + * - MIRRORED as local literals matching the declaration: `defaultViewType` (read + * `schema.defaultViewType || 'grid'`), `defaultListView` (read + * `namedListViews?.[schema.defaultListView]`), `showViewSwitcher` (read + * `schema.showViewSwitcher === true`); + * - RETIRED (`?: never` + `retirementTombstone()`, the objectui#7129 route): + * `viewTabBar` — zero reads; the tab-bar config is `ViewTabBar`'s `config` PROP + * from the host, never a node key. + * ⚠️ `listViews` STAYS, on the ruling's own fallback clause, with the measurement + * that triggered it: the declaration's value is the local `NamedListView` (~52 + * members, of which the renderer reads seven — `label`, `type`, `columns`, `filter`, + * `sort`, `options`, `data`); the spec slot `ViewSchema.listViews` is a record of + * the STRICT `ObjectListViewSchema`, which requires `columns` and refuses `options`, + * ObjectQL tuple filters and `default` — the named views `plugin-view`'s README and + * `content/docs/api/schema-reference.md` teach fail it at `columns` / `filter.0` / + * unrecognized_keys. Mirroring the spec value loses documented behaviour; mirroring + * the local value enforces ~45 unread members into the contract (the reason ruling B + * refused option A for the six local keys). Neither is a mirror edit this ledger can + * authorise; ⛔ `z.any()` was ruled out by name. The value type is the maintainer's + * decision, recorded on objectui#7779's report. */ - 'objectql.zod.ts#ObjectViewSchema': - | 'allowCreateView' | 'defaultListView' | 'defaultViewType' | 'filterableFields' - | 'listViews' | 'navigation' | 'searchableFields' | 'showViewSwitcher' - | 'viewActions' | 'viewTabBar'; + 'objectql.zod.ts#ObjectViewSchema': 'listViews'; /** LOCAL. */ 'reports.zod.ts#ReportComponentSchema': 'chartConfig' | 'conditionalFormatting' | 'reportType'; /** @@ -1595,11 +1634,13 @@ interface RuntimeOnlyDeclared { /** 1 of `ObjectGridSchema`'s former 17. POLICY group. Read at `ObjectGrid.tsx:1334`. */ 'objectql.zod.ts#ObjectGridSchema': 'onNavigate'; /** - * 1 of `ObjectViewSchema`'s former 11 — the one key that sits in both stories. Its - * other ten keys stay in `UnmirroredDeclared`; reclassifying its callback did not - * re-route the pair, and neither did objectui#7279's move of that entry from the - * split's SPEC-DERIVED half to its LOCAL one (the pair was never spec-derived — - * see the entry above). POLICY group. + * 1 of `ObjectViewSchema`'s former 11 — the one key that sits in both stories. Of + * its other ten keys, nine closed with objectui#7779 (eight mirrored, `viewTabBar` + * retired) and `listViews` stays in `UnmirroredDeclared`; reclassifying its callback + * did not re-route the pair, and neither did objectui#7279's move of that entry from + * the split's SPEC-DERIVED half to its LOCAL one (the pair had never been + * spec-derived until #7779 gave the mirror real spec references — see the entry + * above). POLICY group. */ 'objectql.zod.ts#ObjectViewSchema': 'onNavigate'; /** @@ -2253,8 +2294,9 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ 'app.zod.ts#NavigationAreaSchema', // `base.zod.ts#BaseSchema` and `objectql.zod.ts#ObjectViewSchema` used to stand // here and were removed by objectui#6705 — NOT because either mirror changed, - // but because the re-check below stopped mis-reading them. Neither references a - // spec schema; each was held here by one of the two defects that card names: + // but because the re-check below stopped mis-reading them. Neither referenced a + // spec schema THEN (`ObjectViewSchema` does since objectui#7779 — see the end of + // this list); each was held here by one of the two defects that card names: // - `BaseSchema` — its file's ONLY `Spec…` token is in a COMMENT // (`base.zod.ts`, "rather than calling `SpecSchema.omit(…)`"). Prose. // - `ObjectViewSchema` — `BaseSchema.extend({…})` with every member a local @@ -2278,6 +2320,12 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ 'objectql.zod.ts#ObjectGallerySchema', 'objectql.zod.ts#ObjectGanttSchema', 'objectql.zod.ts#ObjectMapSchema', + // objectui#7779: BACK, by a real code reference this time — `navigation`, + // `searchableFields` and `filterableFields` are `SpecListViewSchema.shape.*` + // by reference (identity-pinned in `object-view-unmirrored-keys-7779.test.ts`). + // Membership here is what re-derives the pair's one remaining + // `UnmirroredDeclared` key (`listViews`) into the split's SPEC-DERIVED half. + 'objectql.zod.ts#ObjectViewSchema', ]; /* ── Runtime: the population is closed ──────────────────────────────────────── */ diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 33212f3bd4..9d3a05b45c 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1906,9 +1906,35 @@ export interface ObjectViewSchema extends BaseSchema { className?: string; /** - * View tab bar UX configuration (inline add, context menu, overflow, indicators). + * RETIRED (objectui#7779) — the tab-bar config key this node's renderer never + * read. `packages/plugin-view/src/ObjectView.tsx` reads `schema.objectName`, + * `schema.layout`, `schema.defaultViewType`, `schema.allowCreateView` and + * `schema.viewActions` off the node and `schema.viewTabBar` never; the + * repo-wide census (2026-09-06, on `6a9ee323`) finds the key in no source + * file outside this package — only in two doc tables that listed it as + * authorable. The tab-bar UX config ({@link ViewTabBarConfig}, still + * exported) is the `config` PROP of the `ViewTabBar` component, composed by + * the host (`@object-ui/app-shell`), not authored metadata: `plugin-view`'s + * own `ObjectView` renders no tab bar at all (ADR-0053 — the host owns the + * switcher). The 2026-07 audit + * (`docs/audits/2026-07-objectview-detailview-schema.md`) had measured it + * "dead since introduction". Maintainer ruling B on objectui#7779 + * (2026-09-06): a key nothing reads is retired, not mirrored. + * + * `?: never` is this package's tombstone convention (see + * `ObjectKanbanSchema.groupField`, objectui#7322), and it is load-bearing + * rather than decorative: {@link BaseSchema} carries `[key: string]: any`, + * so DELETING this member would let the retired spelling type-check green + * and go on doing nothing. Keeping the key declared as `never` is what makes + * the retirement audible at the authoring boundary. Lockstep with the Zod + * twin (`zod/objectql.zod.ts`, `retirementTombstone()`): both halves or + * neither. Absent stays valid on both, so a node that never wrote the key is + * untouched. + * + * @deprecated RETIRED (objectui#7779) — remove the key; pass `config` to + * `ViewTabBar` from the host instead. */ - viewTabBar?: ViewTabBarConfig; + viewTabBar?: never; /** * Show "+" button in ViewSwitcher to create a new view. diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 97b7a6681e..44e31169b4 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -38,6 +38,7 @@ import { import { BaseSchema, specFieldsExcept } from './base.zod.js'; import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { DrillDownConfigSchema } from './data-display.zod.js'; +import { ViewSwitcherSchema } from './views.zod.js'; /** * HTTP Method Schema — `@objectstack/spec/ui` schema re-exported by reference @@ -192,6 +193,55 @@ export const ObjectFormSchema = BaseSchema.extend({ /** * ObjectView Schema + * + * Ten keys the `ObjectViewSchema` interface (`../objectql.ts`) declared and this + * mirror never did — objectui#7279's `UnmirroredDeclared` reading — were closed + * nine-for-ten by objectui#7779 under the maintainer's ruling B (2026-09-06: + * liveness first, then mirror-or-retire per key). Every reading below was taken + * on the `object-view` NODE renderer, `packages/plugin-view/src/ObjectView.tsx` + * (registered by `plugin-view/src/index.tsx`), with `schema.objectName` / + * `schema.layout` as the positive controls of the same `schema.KEY` query: + * + * - `navigation`, `searchableFields`, `filterableFields` — the spec models + * all three on `ListViewSchema` (`@objectstack/spec/ui`), so they are the + * spec's own slots BY REFERENCE (`SpecListViewSchema.shape.*`), never a + * local restatement: the declaration already imports the spec's + * `NavigationConfig` for `ViewNavigationConfig`, and a literal restating it + * is the drift this repo keeps paying for (objectui#4588). The pin asserts + * identity against the spec schema, so a spec-side change moves them. + * - `allowCreateView`, `viewActions` — READ: the renderer forwards both + * verbatim into the `view-switcher` node it composes + * (`allowCreateView: schema.allowCreateView`, `viewActions: schema.viewActions`, + * then `ViewSwitcher.tsx` reads `schema.allowCreateView` / `schema.viewActions`), + * so they are that sibling mirror's slots by reference + * (`ViewSwitcherSchema.shape.*`, `./views.zod.ts`) — one shape, two nodes. + * - `defaultViewType` (READ: `schema.defaultViewType || 'grid'`), + * `defaultListView` (READ: `namedListViews?.[schema.defaultListView]`), + * `showViewSwitcher` (READ: `schema.showViewSwitcher === true`) — local + * literals matching the declaration. `defaultViewType` is the declaration's + * SEVEN-value union on purpose, not the spec's view-kind enum: `chart` and + * `tree` are host-composition-only on this node (objectui#5321) and the + * `NamedListView.type` twin spells the same seven. + * - `viewTabBar` — RETIRED (`retirementTombstone()` below; `?: never` on the + * TS face). ZERO reads of the key on the node: the tab-bar UX config + * (`ViewTabBarConfig`, still exported) is the `config` PROP of the + * `ViewTabBar` component, composed by the host (`@object-ui/app-shell`), + * and `plugin-view`'s own `ObjectView` never renders that bar (ADR-0053: + * the host owns the switcher). The 2026-07 audit + * (`docs/audits/2026-07-objectview-detailview-schema.md`) had already + * measured it dead since introduction. + * - `listViews` — STILL UNMIRRORED, on the ruling's own fallback clause and + * by measurement: the declaration's value is the local `NamedListView` + * (~52 members, of which the renderer reads `label`, `type`, `columns`, + * `filter`, `sort`, `options`, `data`), while the spec slot + * (`ViewSchema.listViews`) is a record of the STRICT `ObjectListViewSchema`, + * which requires `columns` and refuses `options`, ObjectQL tuple filters and + * `default` — i.e. it refuses the named views this package's own README and + * `content/docs/api/schema-reference.md` teach. Neither value type can be + * mirrored without either losing documented behaviour (spec) or enforcing + * ~45 unread members into the contract (local), so the key stays in the + * parity ledger with that measurement until the maintainer decides its + * value type. ⛔ Not `z.any()`: that was ruled out by name. */ export const ObjectViewSchema = BaseSchema.extend({ type: z.literal('object-view'), @@ -199,19 +249,39 @@ export const ObjectViewSchema = BaseSchema.extend({ title: z.string().optional().describe('View title'), description: z.string().optional().describe('View description'), layout: z.enum(['drawer', 'modal', 'page']).optional().describe('Layout mode'), + defaultViewType: z.enum(['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map']).optional().describe('Default list view type (grid unless a named view sets its own type)'), + defaultListView: z.string().optional().describe('Key of the listViews entry shown first'), + // Spec slot by reference (objectui#7779) — `NavigationConfigSchema.optional()`, + // the same object `ListViewSchema` derives its `navigation` from. + navigation: SpecListViewSchema.shape.navigation, table: z.lazy(() => ObjectGridSchema.omit({ type: true, objectName: true }).partial()).optional().describe('Table config'), form: z.lazy(() => ObjectFormSchema.omit({ type: true, objectName: true, mode: true }).partial()).optional().describe('Form config'), + // Spec slots by reference (objectui#7779) — `array(string).optional()` on + // both; the spec's own description marks `filterableFields` a legacy + // shorthand for `userFilters.fields`. + searchableFields: SpecListViewSchema.shape.searchableFields, + filterableFields: SpecListViewSchema.shape.filterableFields, showSearch: z.boolean().optional().describe('Show search'), showFilters: z.boolean().optional().describe('Show filters'), showSort: z.boolean().optional().describe('Show sort controls'), showCreate: z.boolean().optional().describe('Show create button'), showRefresh: z.boolean().optional().describe('Show refresh button'), + showViewSwitcher: z.boolean().optional().describe('Show the view-type switcher toggle (hidden unless true)'), operations: z.object({ create: z.boolean().optional(), read: z.boolean().optional(), update: z.boolean().optional(), delete: z.boolean().optional(), }).optional().describe('Enabled operations'), + viewTabBar: retirementTombstone( + 'RETIRED (objectui#7779) — `viewTabBar` was never read off the object-view node: the tab-bar UX config ' + + '(`ViewTabBarConfig`) is the `config` PROP of the `ViewTabBar` component, composed by the host ' + + '(`@object-ui/app-shell`), not authored metadata (ADR-0053: the host owns the switcher). Remove the key.', + ), + // Sibling slots by reference (objectui#7779): the renderer forwards both + // verbatim into the `view-switcher` node it composes. + allowCreateView: ViewSwitcherSchema.shape.allowCreateView, + viewActions: ViewSwitcherSchema.shape.viewActions, }); /**