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
15 changes: 15 additions & 0 deletions .changeset/9880-undrawable-view-kinds-both-legs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/core': patch
---

`normalize-list-view`'s two view-kind tables now derive their partition from the undrawable table's own keys instead of a hard-coded list of kinds, so one spelling is total against both the `@objectstack/spec` this repository resolves today and one built from objectstack `main`.

`UNDRAWABLE_VIEW_KINDS` was annotated `Record<Exclude<ViewType, ListViewVisualization>, string | null>`, and `ListViewVisualization` subtracted the literal `'list' | 'detail' | 'page'`. An annotated object literal is exact in BOTH directions, so when objectstack retired the list-view kind `page` from the spec (objectstack#17063, ADR-0049 enforce-or-remove) the table's `page:` row became an excess property against the narrower union — a TS2353 whose only repair under that annotation is DELETING the row, which this repository cannot do while it resolves a published spec that still accepts `type: 'page'` views.

The table now carries `satisfies Record<string, string | null>` — the value constraint without the exactness — and the undrawable union is `Extract<ViewType, keyof typeof UNDRAWABLE_VIEW_KINDS>`, so a row for a kind the spec has retired goes inert at type level while staying live at runtime, which is what an author who can still write a `page` view against the resolved spec needs. `ListViewVisualization` subtracts that derived union instead of a literal list; its members are unchanged under either spec, and so is every runtime answer this module gives.

The guarantee the tables carry is unchanged and still mechanical, it just lands in one place instead of two: a kind the spec ADDS is classed by neither table, so it stays inside `ListViewVisualization`, and the total `Record<ListViewVisualization, true>` on `LIST_VIEW_KINDS` fails the build until the new kind is classed as drawable there or explained in the undrawable table. A MISSPELLED key in the undrawable table fails the same way, because the kind it was meant to class is not extracted either.

Compiled both ways rather than argued. With the published pin `@objectstack/spec@17.4.0` installed, `turbo run type-check --filter=@object-ui/core` exits 0 before and after this change. With a spec built from objectstack `main` injected in its place, the same command reported the TS2353 in `normalize-list-view.ts` before and does not report it after; the diagnostics that remain on that leg come from other files, are reproduced against the pristine source, and are not this change's.

`normalize-list-view.undrawableBothLegs-9880.test.ts` pins all of it over a SIMULATED vocabulary, because any single run installs only one spec: the retirement direction, the addition direction, and the old spelling's TS2353 kept as a firing control.
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* 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#9880 — the two view-kind tables in `normalize-list-view.ts` have to
* be total against TWO different `ViewType` unions at once:
*
* · the `@objectstack/spec` this repository RESOLVES today, which still
* publishes the list-view kind `page`;
* · a spec built from objectstack `main`, which RETIRED it (objectstack#17063,
* ADR-0049 enforce-or-remove) and which objectui#9860's shape gate compiles
* this repository against on every pull request.
*
* The spelling that failed was the ANNOTATION
* `Record<Exclude<ViewType, ListViewVisualization>, string | null>` on the
* undrawable table. An annotated object literal is exact in BOTH directions, so
* the day the kind left the union its row became an excess property — TS2353,
* the one diagnostic that gate reported on its first run — and the only repair
* the annotation admits is DELETING the row, which the resolved published spec
* forbids because an author can still write a `page` view against it.
*
* ⭐ The card's first deliverable was the judgement "is a both-legs spelling
* possible at all", with a re-grade switch if it was not. It is: keep the
* value constraint with `satisfies`, drop the exactness, and derive the
* undrawable union from the table's keys INTERSECTED with the live vocabulary
* (`Extract`), so a retired row is inert rather than illegal. This file pins
* that judgement in both directions, with the failing spelling kept as a firing
* control so the green below can never be the green of a check that stopped
* checking.
*
* ⚠️ Only ONE spec is installed in any single run, so the two legs cannot both
* be observed at runtime here. The vocabulary is therefore SIMULATED for the
* type-level half — the same two operators in the same composition, applied to
* a published-shaped union, a main-shaped one, and a grown one. The real
* module's own cross-leg invariants are asserted separately below, and they are
* written to hold identically under either spec; the two-spec compile itself
* lives in this card's pull request, where both legs were run.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';

import { isListViewVisualization } from '../normalize-list-view.js';
import type { ListViewVisualization } from '../normalize-list-view.js';

type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false;
type Expect<T extends true> = T;

/* ------------------------------------------------------------------ *
* The mechanism, lifted off the live vocabulary so both legs are *
* reachable from one compile. *
* ------------------------------------------------------------------ */

/** The source's `UndrawableViewKind`, over an arbitrary vocabulary and table. */
type Undrawable<Vocabulary, Table> = Extract<Vocabulary, keyof Table>;

/** The source's `ListViewVisualization`, over the same two parameters. */
type Drawable<Vocabulary, Table> = Exclude<Vocabulary, Undrawable<Vocabulary, Table>>;

/** A stand-in for `UNDRAWABLE_VIEW_KINDS`, spelled the way the source spells it. */
const SIMULATED_UNDRAWABLE = {
list: null,
detail: null,
page: 'the retired kind, still classed while the resolved spec accepts one',
} satisfies Record<string, string | null>;
type SimulatedTable = typeof SIMULATED_UNDRAWABLE;

/** The resolved spec's shape: the kind is still in the vocabulary. */
type PublishedVocabulary = 'grid' | 'kanban' | 'page' | 'list' | 'detail';
/** objectstack `main`'s shape: the kind is gone. */
type MainVocabulary = 'grid' | 'kanban' | 'list' | 'detail';
/** The direction the guard was written FOR: the spec grows a member. */
type GrownVocabulary = 'grid' | 'kanban' | 'page' | 'pivot' | 'list' | 'detail';

// The derived drawable set is the SAME under both legs — the retired row does
// not leak into it, and its absence does not take a drawable kind with it.
export type _DrawableUnderPublished = Expect<
Equal<Drawable<PublishedVocabulary, SimulatedTable>, 'grid' | 'kanban'>
>;
export type _DrawableUnderMain = Expect<Equal<Drawable<MainVocabulary, SimulatedTable>, 'grid' | 'kanban'>>;

// …and a table total over it compiles under both, which is the whole claim.
export const drawableUnderPublished: Record<Drawable<PublishedVocabulary, SimulatedTable>, true> = {
grid: true,
kanban: true,
};
export const drawableUnderMain: Record<Drawable<MainVocabulary, SimulatedTable>, true> = {
grid: true,
kanban: true,
};

// FIRING CONTROL — the spelling this card replaced. Annotated with the exact
// `Record<Exclude<…>>`, the retired row is an excess property the moment the
// vocabulary drops it. If this assignment ever compiles, the green above has
// stopped meaning "tolerant of a retirement" and this file must be re-read.
export const retiredRowUnderTheOldSpelling: Record<
Exclude<MainVocabulary, Drawable<MainVocabulary, SimulatedTable>>,
string | null
> = {
list: null,
detail: null,
// @ts-expect-error — TS2353: 'page' does not exist in type 'Record<"list" | "detail", string | null>'
page: 'the retired kind',
};

// The ADD direction objectui#8127 installed the guard for, unchanged by this
// card: a kind NEITHER table classes stays in the drawable union, so the
// drawable table is missing a key (TS2739) until it is classed.
// @ts-expect-error — 'pivot' is classed by neither table, so it is a required key here
export const drawableUnderGrown: Record<Drawable<GrownVocabulary, SimulatedTable>, true> = {
grid: true,
kanban: true,
};

/** The other half of "drawable OR explained": explaining it clears the red. */
const SIMULATED_UNDRAWABLE_WITH_PIVOT = {
...SIMULATED_UNDRAWABLE,
pivot: 'a hypothetical non-visualization list type',
} satisfies Record<string, string | null>;

export const drawableUnderGrownOnceExplained: Record<
Drawable<GrownVocabulary, typeof SIMULATED_UNDRAWABLE_WITH_PIVOT>,
true
> = {
grid: true,
kanban: true,
};

/* ------------------------------------------------------------------ *
* The real module: invariants that read the same under either spec. *
* ------------------------------------------------------------------ */

/**
* The exported visualization union is the nine kinds `ListView` has a `case`
* for — under the resolved spec AND under `main`'s. This is what makes the
* change a re-spelling rather than a re-definition: the published type does not
* move, only the way it is derived.
*/
export type _VisualizationUnionIsTheNineDrawnKinds = Expect<
Equal<
ListViewVisualization,
'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'
>
>;

const DRAWN_KINDS = [
'grid',
'kanban',
'gallery',
'calendar',
'timeline',
'gantt',
'map',
'chart',
'tree',
] as const;

describe('the undrawable table survives an upstream retirement (objectui#9880)', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('keeps the simulated legs honest: every table above is a real, non-empty reading', () => {
// A census over an empty set passes, so the simulated tables are sized
// before the type-level assertions above are trusted.
expect(Object.keys(SIMULATED_UNDRAWABLE)).toEqual(['list', 'detail', 'page']);
expect(Object.keys(SIMULATED_UNDRAWABLE_WITH_PIVOT)).toEqual(['list', 'detail', 'page', 'pivot']);
expect(Object.keys(drawableUnderPublished)).toEqual(Object.keys(drawableUnderMain));
expect(Object.keys(drawableUnderGrownOnceExplained)).toEqual(['grid', 'kanban']);
expect(Object.keys(retiredRowUnderTheOldSpelling)).toContain('page');
expect(Object.keys(drawableUnderGrown)).toEqual(['grid', 'kanban']);
});

it('answers the drawable question identically whichever spec is resolved', () => {
for (const kind of DRAWN_KINDS) {
expect(isListViewVisualization(kind)).toBe(true);
}
// Controls: the predicate is a membership test, not a `true` generator.
// All three are undrawable under either spec — `list` and `detail` because
// they are objectui CATEGORIES with no spec counterpart, `page` because the
// renderer has no branch for it whether or not the spec still offers it.
expect(isListViewVisualization('page')).toBe(false);
expect(isListViewVisualization('list')).toBe(false);
expect(isListViewVisualization('detail')).toBe(false);
});

it('still explains a `page` view out loud, because the resolved spec still accepts one', async () => {
// The retired row is kept at RUNTIME on purpose: `Extract` removes it from
// the type when the vocabulary drops it, and nothing removes it from the
// table. While the resolved spec accepts a `page` view, an author can still
// write one, and the degrade to a grid still has to say so.
//
// ⚠️ The warning is once-per-kind-per-MODULE-INSTANCE, and the `unit`
// project runs with `isolate: false`, so a sibling suite that already
// normalised a `page` view in this worker has spent it — asserting on the
// statically imported module measured whichever file ran first. A fresh
// instance is what makes the count below a reading instead of a race.
vi.resetModules();
const fresh = await import('../normalize-list-view.js');

const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

const out = fresh.normalizeListViewSchema({
type: 'list-view',
objectName: 'account',
specType: 'page',
});

expect((out as { viewType?: string }).viewType).toBe('grid');
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0])).toContain('"page"');

// Control: the same fresh instance says nothing for a kind it DRAWS, so the
// line above is the retired row answering and not a warning for every input.
warn.mockClear();
fresh.normalizeListViewSchema({ type: 'list-view', objectName: 'account', specType: 'grid' });
expect(warn).not.toHaveBeenCalled();
});
});
63 changes: 56 additions & 7 deletions packages/core/src/utils/normalize-list-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,21 @@ const ARIA_KEY_ALIASES: Record<string, string> = {
* a future non-visualization list type (another `page`) pass this site in
* silence, which is the exact failure being fixed.
*
* ⭐ What it subtracts is {@link UndrawableViewKind} — the keys of
* {@link UNDRAWABLE_VIEW_KINDS} the current `ViewType` still carries — and
* never a literal list of kinds (objectui#9880). A literal list is a second
* hand-written copy of the vocabulary, the failure class objectui#8127 removed
* from `ViewType` itself, and it rots in the direction the spec actually moved:
* `Exclude<ViewType, 'list' | 'detail' | 'page'>` kept subtracting `'page'`
* after objectstack RETIRED it, where `Exclude` of a non-member is a silent
* no-op no compiler reads. The sibling record's excess `page:` key is what went
* red; this line stayed green while saying something that had stopped being
* true.
*
* It happens to equal `@objectstack/spec/ui`'s `VisualizationType` today. That
* is a fact about 17.3.0, not the definition.
* is a fact about the resolved spec, not the definition.
*/
export type ListViewVisualization = Exclude<ViewType, 'list' | 'detail' | 'page'>;
export type ListViewVisualization = Exclude<ViewType, UndrawableViewKind>;

/**
* The view kinds `ListView` actually draws.
Expand Down Expand Up @@ -190,20 +201,58 @@ const LIST_VIEW_KINDS: Record<ListViewVisualization, true> = {
* with the reason — and, where the answer is "the author authored something
* valid and will get a grid", the sentence to say out loud.
*
* Total over `Exclude<ViewType, ListViewVisualization>` so the two records
* together partition the derived union: a member the spec adds must be classed
* as drawable or explained HERE, and until it is, neither record compiles.
* The two tables together still partition the derived union — a member the spec
* ADDS must be classed as drawable or explained HERE, and until it is the build
* is red — but the red lands on {@link LIST_VIEW_KINDS} alone, because a kind
* neither table classes is not extracted into {@link UndrawableViewKind} and so
* stays inside {@link ListViewVisualization}, whose record is total. A key
* MISSPELLED here fails the same way, for the same reason: the kind it was
* meant to class is not extracted either. Both directions are pinned in
* `__tests__/normalize-list-view.undrawableBothLegs-9880.test.ts` rather than
* asserted in this sentence.
*
* ⛔ NOT annotated `Record<Exclude<ViewType, ListViewVisualization>, …>`, and
* the `satisfies` is not a style choice (objectui#9880). An annotation makes
* the literal EXACT in BOTH directions: a kind the spec adds is a missing key
* (wanted) and a kind the spec RETIRES is an EXCESS PROPERTY — a TS2353 that
* demands a deletion this repository cannot make while it resolves a published
* spec which still carries the kind. `satisfies` keeps the value constraint
* (every reason is a `string` or `null`) and drops the exactness, which is what
* lets one spelling compile against both the pinned published spec and one
* built from objectstack `main`.
*
* `null` means silence is correct — these are objectui view CATEGORIES, never
* authored as a list-view kind:
* - `list` is the view CATEGORY, not a kind — it already folds to `grid`.
* - `detail` is a different renderer (`plugin-detail`), never a ListView case.
*
* ⚠️ `page` is a RETIRED spec kind and is kept on purpose. objectstack#17063
* removed `type: 'page'` from the list-view enum, and until this repository's
* `@objectstack/spec` resolution moves onto a release carrying that removal an
* author can still write one, so this table still has to answer for it. Once
* the resolution moves, `Extract` stops extracting the row and it becomes inert
* of its own accord — deletable, on the day the residual pins that hold it
* (`normalize-list-view.pageResidual-8429.test.ts`) are converted, and not
* before.
*/
const UNDRAWABLE_VIEW_KINDS: Record<Exclude<ViewType, ListViewVisualization>, string | null> = {
const UNDRAWABLE_VIEW_KINDS = {
list: null,
detail: null,
page: 'it mounts a published page (bound through `pageName`) in place of rows, which this renderer has no branch for',
};
} satisfies Record<string, string | null>;

/**
* The members of the current {@link ViewType} that {@link UNDRAWABLE_VIEW_KINDS}
* classes — the other half of the partition, derived rather than written out.
*
* `Extract` over the table's keys, not the table's raw `keyof`, is the load-
* bearing operator (objectui#9880): the table may carry a row for a kind the
* spec has since RETIRED, and `Extract` drops that row from the TYPE without
* the table having to lose it at runtime. That is what makes one spelling
* correct against both the pinned published spec (which still publishes `page`)
* and a spec built from objectstack `main` (which retired it).
*/
type UndrawableViewKind = Extract<ViewType, keyof typeof UNDRAWABLE_VIEW_KINDS>;

/** One warning per undrawable kind per process — see {@link warnUndrawableViewKind}. */
const warnedUndrawableKinds = new Set<string>();
Expand Down
Loading