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
69 changes: 69 additions & 0 deletions .changeset/7645-schema-registry-kanban-honesty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
'@object-ui/types': minor
---

`SchemaRegistry['kanban']` stops describing a component it cannot name

`SchemaRegistry` documents itself as "the Single Source of Truth for component
type lookups". Its `'kanban'` entry named `DeclarativeKanbanSchema` — the
authoring/validation face declared in this package — while the renderer
registered for that key is `ObjectKanbanRenderer` in `@object-ui/plugin-kanban`
(`ComponentRegistry.register('kanban', …)`), which consumes that package's own
`KanbanSchema`. The two are distinct dialects, measured member by member: the
plugin face declares 19 members in its own body, the declarative face 7, and
they share three names — `type`, `columns` and `onCardMove`. `onCardMove` has
the same signature on both sides; `columns` is required
`DeclarativeKanbanColumn[]` on one and optional `KanbanColumn[]` on the other,
the two element types sharing five of their six members; the remaining 16
plugin members and the declarative `draggable` / `onCardClick` have no
counterpart across the gap. For this one key the map's value did not describe
the component the key names.

Pointing the entry at the honest type is not reachable from this layer, and
that was measured rather than assumed. Importing `@object-ui/plugin-kanban`
here is a phantom dependency — `check:phantom-deps` rejects it by file and
pair, type-only imports included — and declaring the dependency would close the
cycle `@object-ui/types` → `@object-ui/plugin-kanban` → `@object-ui/types`,
because this package is the zero-workspace-dependency bottom layer.
objectui#6172's ruling (2026-08-31) kept the plugin's bare names rather than
relocating that dialect down here — that is why this entry cannot name the
plugin's type at this commit. That half of the ruling has since been reversed:
objectui#7664's ruling (a) (2026-09-05) rewrites this package's `'kanban'`
arm to the plugin's shape, has `@object-ui/plugin-kanban` conform to it, and
retires the `DeclarativeKanban*` trio; `registry.ts` is on its execution list,
so this entry is scheduled to be re-pointed at the declared type. The value
below is the transitional state under that ruling, not the permanent one.

So the entry now asserts only what this layer can prove, and what BOTH dialects
satisfy: `BaseSchema & { type: 'kanban' }`.

**What this changes for consumers.** `keyof SchemaRegistry` — and therefore the
published `ComponentType` union — is unchanged; `'kanban'` is still a member,
pinned at compile time so it cannot be narrowed away silently. What changes is
the value side of this one key, in two ways, one loud and one silent:

- **Breaking, loudly, for consumers who indexed `SchemaRegistry['kanban']` and
flowed the value into the declarative face.** Before this change
`SchemaRegistry['kanban']` *was* `DeclarativeKanbanSchema`, so
`const s: DeclarativeKanbanSchema = registryValue` compiled. It no longer
does: the entry lacks the required `columns` member, and that assignment is
a compile error (TS2741). Nothing in this repository performed such an
indexed access, so no in-repo code moves — the break is for consumers
outside it.
- **Silent, for member reads.** `BaseSchema` carries an index signature
(`[key: string]: any`), so reading the declarative face's own members —
`columns`, `draggable`, `onCardMove`, `onCardClick` — off the new entry is
not an error: the reads resolve through the index signature and type as
`any`. `SchemaRegistry['kanban']['columns']` was `DeclarativeKanbanColumn[]`
and is now `any`; nothing turns red, the read just stops being checked.
(Undeclared keys already read as `any` before, through the same signature;
what changes is those declared members.)

Migration: name the face you actually mean. `DeclarativeKanbanSchema` is still
exported from `@object-ui/types` unchanged for the authoring face, and
`KanbanSchema` from `@object-ui/plugin-kanban` for the rendered one.

This is a breaking change shipped as `minor`: this repository's
version-alignment rule keeps objectui's major pinned to `@objectstack`'s and
ships objectui's own breaking changes as `minor` with the break spelled out in
the changeset body, which is what the two bullets above are.
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* 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.
*/

/**
* The renderer's own face satisfies what `SchemaRegistry['kanban']` asserts.
*
* ## Why this pin lives here and can live nowhere else
*
* objectui#7645: `@object-ui/types`' `SchemaRegistry` advertises itself as the
* Single Source of Truth for component type lookups, and its `'kanban'` entry
* named the DECLARATIVE authoring face while the renderer registered for that
* key — `ObjectKanbanRenderer`, `ComponentRegistry.register('kanban', …)` in
* `../index` — consumes {@link KanbanSchema} from this package. The two are
* unrelated dialects (objectui#6172 ruled this package KEEPS the bare names).
*
* The entry was therefore weakened to the claim that layer can prove and both
* dialects satisfy: a schema node tagged `'kanban'`. That claim is only worth
* anything if it is actually TRUE of the renderer's face — and `@object-ui/types`
* cannot check that: it cannot name this package (the import is a phantom
* dependency; declaring it would close the cycle `@object-ui/types` →
* `@object-ui/plugin-kanban` → `@object-ui/types`). This package depends on
* `@object-ui/types`, so it is the only place in the workspace that can see both
* sides at once — which is why this file exists, not one more pin by the map.
*
* ⛔ This file does not touch {@link KanbanSchema} — it only reads it. It is the
* face objectui#6172 kept, and the one objectui#7664's ruling (a) (2026-09-05)
* makes the declared shape; `KanbanSchema.data` stays a raw-row input, since
* objectui#7651 was ruled B and closed as not_planned (2026-09-05T02:09:54Z).
*
* ## The instrument
*
* Compile-time only. Vitest strips types without checking them, so a green run
* of this file proves nothing on its own; the assertions are read by
* `tsc -p packages/plugin-kanban/tsconfig.test.json`, chained off this
* package's `type-check` script. That project sets `"paths": {}`, so
* `@object-ui/types` resolves through the workspace dependency to
* `packages/types/dist/index.d.ts` — BUILD `@object-ui/types` before believing
* either colour this file reports.
*/

import { describe, it, expect } from 'vitest';
import type { SchemaRegistry, DeclarativeKanbanSchema } from '@object-ui/types';
import type { KanbanSchema } from '../types';

/* -------------------------------------------------------------------------- */
/* Compile-time pins — compiled by tsconfig.test.json, chained off type-check. */
/* -------------------------------------------------------------------------- */

type Assert<T extends true> = T;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type IsAny<T> = 0 extends 1 & T ? true : false;

describe("the registered kanban renderer's schema satisfies the registry entry", () => {
it('is pinned at compile time', () => {
// Non-vacuity controls: `any` on either side would satisfy every `extends`
// below while checking nothing.
type _PluginFaceIsReal = Assert<Equal<IsAny<KanbanSchema>, false>>;
type _RegistryIsReal = Assert<Equal<IsAny<SchemaRegistry>, false>>;

// 1. The claim the map now makes for `'kanban'` is TRUE of the face the
// registered renderer actually consumes. This is the assertion that
// makes the weakened entry honest rather than merely vaguer.
type _PluginFaceSatisfiesTheEntry = Assert<
KanbanSchema extends SchemaRegistry['kanban'] ? true : false
>;

// 2. …and, at this commit, still two dialects. objectui#7664's ruling (a)
// (2026-09-05) schedules their convergence; when it lands, the weakened
// entry has outlived its reason and this pin retires with the re-point.
type _StillTwoDialects = Assert<
Equal<Equal<KanbanSchema, DeclarativeKanbanSchema>, false>
>;

// 3. Both faces agree on the one thing the map asserts: the tag.
type _PluginFaceIsTagged = Assert<Equal<KanbanSchema['type'], 'kanban'>>;

expect(true).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* 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.
*/

/**
* `SchemaRegistry['kanban']` — the key survives, the value stops asserting.
*
* ## What this pins, and why the key half is the load-bearing half
*
* `ComponentType = keyof SchemaRegistry` is a PUBLISHED union. The remedy for
* objectui#7645 (this map's `'kanban'` value described the declarative
* authoring face, not the type the registered renderer honours) had to leave
* that union byte-identical: dropping the key would silently narrow a
* published type, turning a false claim into a missing one. So the value was
* weakened to what this layer can prove and the KEY was kept — and only a pin
* can tell those two edits apart afterwards, because deleting the entry
* outright also removes the false claim and every runtime suite stays green.
*
* ## Why the value cannot simply be corrected
*
* The renderer registered for `'kanban'` is `ObjectKanbanRenderer` in
* `@object-ui/plugin-kanban`, which consumes that package's `KanbanSchema`.
* `@object-ui/types` cannot name it: the import is a phantom dependency
* (`check:phantom-deps` names the pair), and declaring the dependency closes
* the cycle `@object-ui/types` → `@object-ui/plugin-kanban` → `@object-ui/types`.
* objectui#6172's ruling (2026-08-31) kept the plugin's bare names there;
* objectui#7664's ruling (a) (2026-09-05) reverses that half — this package's
* `'kanban'` arm is rewritten to the plugin's shape and the entry re-pointed at
* it, so the value pinned below is TRANSITIONAL. What IS provable here — a
* node tagged `'kanban'` — is what it states; the plugin's face satisfies it in
* `packages/plugin-kanban/src/__tests__/schema-registry-kanban-honesty-7645.test.ts`.
*
* ## These assertions are compile-time only
*
* They mean something only because this package type-checks its tests:
* `tsconfig.json` excludes test files (they must not emit into `dist`) and
* `tsconfig.test.json` picks them back up, chained off the package's
* `type-check` script. Vitest strips types without checking them, so a green
* vitest run is NOT evidence about anything below. The instrument is
* `tsc -p packages/types/tsconfig.test.json`.
*/

import { describe, it, expect } from 'vitest';
import type {
SchemaRegistry,
ComponentType,
DeclarativeKanbanSchema,
} from '../index';

/* -------------------------------------------------------------------------- */
/* Compile-time pins — compiled by tsconfig.test.json, chained off type-check. */
/* -------------------------------------------------------------------------- */

type Assert<T extends true> = T;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type IsAny<T> = 0 extends 1 & T ? true : false;

describe("SchemaRegistry's kanban key outlives its value's retreat", () => {
it('is pinned at compile time', () => {
// Non-vacuity controls. `Equal<any, X>` is `false` and `any` satisfies
// every `extends`, so an `any` on either side would let the pins below
// pass while checking nothing at all.
type _RegistryIsReal = Assert<Equal<IsAny<SchemaRegistry>, false>>;
type _DeclarativeIsReal = Assert<Equal<IsAny<DeclarativeKanbanSchema>, false>>;

// 1. The published union still yields `'kanban'`. `Extract` collapses to
// `never` if the key is ever removed, which fails this pin loudly.
type _KeyKept = Assert<Equal<Extract<ComponentType, 'kanban'>, 'kanban'>>;

// 2. The value no longer claims the declarative authoring face. This is
// the objectui#7645 defect itself; re-pointing the entry turns it red.
type _ValueIsNotTheDeclarativeFace = Assert<
Equal<Equal<SchemaRegistry['kanban'], DeclarativeKanbanSchema>, false>
>;

// 3. What it DOES assert is true and non-empty: a node tagged `'kanban'`.
// `never` or `unknown` in that slot fails here rather than passing as a
// quieter kind of nothing.
type _ValueIsATaggedNode = Assert<Equal<SchemaRegistry['kanban']['type'], 'kanban'>>;

// 4. Nothing was invalidated by the retreat: the declarative face still
// satisfies the weaker claim, as does the plugin's face (pinned in
// `@object-ui/plugin-kanban`, the only package that can name both).
type _DeclarativeStillSatisfiesIt = Assert<
DeclarativeKanbanSchema extends SchemaRegistry['kanban'] ? true : false
>;

expect(true).toBe(true);
});
});
43 changes: 34 additions & 9 deletions packages/types/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* LICENSE file in the root directory of this source tree.
*/

import type { BaseSchema } from './base.js';

import type {
DivSchema,
BoxSchema,
Expand Down Expand Up @@ -92,7 +94,6 @@ import type {
} from './navigation.js';

import type {
DeclarativeKanbanSchema,
CalendarViewSchema,
FilterBuilderSchema,
CarouselSchema,
Expand Down Expand Up @@ -183,14 +184,38 @@ export interface SchemaRegistry {
'pagination': PaginationSchema;

// Complex
// ⚠️ The renderer registered for `'kanban'` (`ObjectKanbanRenderer`, in
// `@object-ui/plugin-kanban`) consumes that package's own `KanbanSchema`,
// NOT this declarative face. The mismatch is latent — the value side of this
// map is reached only through `ComponentType = keyof SchemaRegistry` and
// nothing performs an indexed access — and objectui#6172's ruling (option A)
// renamed the declarative copy rather than relocating the plugin dialect
// into this zero-dependency layer, so the mismatch stays latent-but-named.
'kanban': DeclarativeKanbanSchema;
// ⚠️ `'kanban'` is the one key whose value this layer cannot state
// precisely, so it deliberately states LESS rather than stating it wrongly
// (objectui#7645).
//
// The renderer registered for this key is `ObjectKanbanRenderer` in
// `@object-ui/plugin-kanban` (`ComponentRegistry.register('kanban', …)`,
// `plugin-kanban/src/index.tsx`), and it consumes THAT package's
// `KanbanSchema`. `@object-ui/types` cannot name that type, measured two
// ways: importing it is a phantom dependency this package does not declare
// (`check:phantom-deps` rejects it by file and pair), and declaring the
// dependency would close the cycle `@object-ui/types` →
// `@object-ui/plugin-kanban` → `@object-ui/types` — this is the
// zero-workspace-dependency bottom layer. objectui#6172's ruling (option A,
// 2026-08-31) kept the plugin's bare names rather than relocating that
// dialect down here. objectui#7664's ruling (a) (2026-09-05) reverses that
// half: this package's `'kanban'` arm is rewritten to the plugin's shape and
// this entry is re-pointed at the declared type — this value is TRANSITIONAL.
//
// Until then the entry asserts only what this layer can PROVE, and what BOTH
// dialects satisfy: a schema node tagged `'kanban'`. It no longer names
// `DeclarativeKanbanSchema` — the AUTHORING/validation face (the `'kanban'`
// arm of `ComplexSchema` → `AnyComponentSchema` → `safeValidateSchema`),
// not the type the registered renderer honours. Naming it here made a map
// that calls itself the Single Source of Truth describe the wrong component.
//
// ⛔ Do not "restore" a precise type here without moving the renderer's
// dialect into a layer this package may depend on. Two compile-time pins
// hold the shape: `src/__tests__/schema-registry-kanban-honesty-7645.test.ts`
// (the key survives in `keyof`; the value no longer claims the declarative
// face) and the same file name under `plugin-kanban/src/__tests__/` (the
// renderer's own `KanbanSchema` satisfies what this entry asserts).
'kanban': BaseSchema & { type: 'kanban' };
'calendar-view': CalendarViewSchema;
'filter-builder': FilterBuilderSchema;
'carousel': CarouselSchema;
Expand Down
Loading