Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .changeset/object-view-unmirrored-keys-7779.md
Original file line number Diff line number Diff line change
@@ -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`).
4 changes: 2 additions & 2 deletions content/docs/plugins/plugin-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions packages/plugin-view/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
50 changes: 31 additions & 19 deletions packages/types/src/__tests__/object-view-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([
'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',
Expand All @@ -268,9 +272,9 @@ const SPEC_COUNTERPART: Record<string, { schema: unknown; key: string; note: str
description: { schema: SpecListViewSchema, key: 'description', note: 'same name, same i18n type difference' },
layout: { schema: SpecNavigationConfigSchema, key: 'mode', note: 'spec is a superset; fold into `navigation`, do not keep a parallel three-value enum' },
defaultViewType: { schema: SpecListViewSchema, key: 'type', note: 'spec is a superset (adds chart, tree); ListViewSchema already imports this enum by reference' },
navigation: { schema: SpecListViewSchema, key: 'navigation', note: 'objectui ViewNavigationConfig is a hand-copied duplicate — re-export by reference' },
searchableFields: { schema: SpecListViewSchema, key: 'searchableFields', note: 'identical string[] — re-export by reference' },
filterableFields: { schema: SpecListViewSchema, key: 'filterableFields', note: 'identical; inherits the spec\'s "legacy shorthand for userFilters.fields" deprecation' },
navigation: { schema: SpecListViewSchema, key: 'navigation', note: 'DONE (objectui#7779): the zod mirror is this very slot by reference (`SpecListViewSchema.shape.navigation`); the TS face is the spec\'s `NavigationConfig` (objectui#4588)' },
searchableFields: { schema: SpecListViewSchema, key: 'searchableFields', note: 'DONE (objectui#7779): the zod mirror is this slot by reference' },
filterableFields: { schema: SpecListViewSchema, key: 'filterableFields', note: 'DONE (objectui#7779): the zod mirror is this slot by reference, and so inherits the spec\'s "legacy shorthand for userFilters.fields" deprecation' },
showSearch: { schema: SpecUserActionsConfigSchema, key: 'search', note: 'scope A step 3, same fold' },
showFilters: { schema: SpecUserActionsConfigSchema, key: 'filter', note: 'scope A step 3, same fold' },
showSort: { schema: SpecUserActionsConfigSchema, key: 'sort', note: 'scope A step 3, same fold' },
Expand Down Expand Up @@ -304,7 +308,12 @@ const SANCTIONED_LOCAL = new Set<string>([
'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',
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading