diff --git a/.changeset/7645-schema-registry-kanban-honesty.md b/.changeset/7645-schema-registry-kanban-honesty.md new file mode 100644 index 0000000000..8322a99834 --- /dev/null +++ b/.changeset/7645-schema-registry-kanban-honesty.md @@ -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. diff --git a/packages/plugin-kanban/src/__tests__/schema-registry-kanban-honesty-7645.test.ts b/packages/plugin-kanban/src/__tests__/schema-registry-kanban-honesty-7645.test.ts new file mode 100644 index 0000000000..27b0874579 --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/schema-registry-kanban-honesty-7645.test.ts @@ -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; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type IsAny = 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, false>>; + type _RegistryIsReal = Assert, 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, false> + >; + + // 3. Both faces agree on the one thing the map asserts: the tag. + type _PluginFaceIsTagged = Assert>; + + expect(true).toBe(true); + }); +}); diff --git a/packages/types/src/__tests__/schema-registry-kanban-honesty-7645.test.ts b/packages/types/src/__tests__/schema-registry-kanban-honesty-7645.test.ts new file mode 100644 index 0000000000..f8a094d4ba --- /dev/null +++ b/packages/types/src/__tests__/schema-registry-kanban-honesty-7645.test.ts @@ -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; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type IsAny = 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` 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, false>>; + type _DeclarativeIsReal = Assert, 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, '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, 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>; + + // 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); + }); +}); diff --git a/packages/types/src/registry.ts b/packages/types/src/registry.ts index 11c9034f4b..b4b0b62abc 100644 --- a/packages/types/src/registry.ts +++ b/packages/types/src/registry.ts @@ -6,6 +6,8 @@ * LICENSE file in the root directory of this source tree. */ +import type { BaseSchema } from './base.js'; + import type { DivSchema, BoxSchema, @@ -92,7 +94,6 @@ import type { } from './navigation.js'; import type { - DeclarativeKanbanSchema, CalendarViewSchema, FilterBuilderSchema, CarouselSchema, @@ -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;