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

`SchemaRegistry` names all three `plugin-chatbot` registrations, so the published
`ComponentType` union does too (objectui#7704).

`packages/plugin-chatbot/src/renderer.tsx` registers three components — `chatbot`,
`chatbot-enhanced` and `chatbot-floating` — and `SchemaRegistry` mapped one of them.
Since `ComponentType = keyof SchemaRegistry` is the published union, a consumer
discriminating on it was told two registered keys do not exist: an author narrowing a
node by `ComponentType`, or writing a `Record<ComponentType, …>` table, had no arm for
either. The asymmetry that showed which half was wrong is that
`packages/cli/src/utils/known-schema-types.ts` keeps its own parallel list containing
both keys, precisely because this map did not.

**Additive.** Two keys join the map under its `// Complex` group; no existing entry
changes, `'chatbot'` still maps to `ChatbotSchema`, and nothing is removed. The union
widens, which cannot break a consumer that produces `ComponentType` values and can only
help one that consumes them — except an exhaustive `Record<ComponentType, …>` or
`switch`, which now needs the two new arms. Nothing in this repo has one: the only
consumer of `ComponentType` outside its own declaration is a pin test.

**Why the entries can be honest now.** This map's value has to be the type the
registered renderer honours, and until objectui#7655 there was none to point at —
`ChatbotSchema` pins `type` to `'chatbot'`, and each registration's real key set lived
in an anonymous `ChatbotSchema & { … }` intersection local to the renderer file.
objectui#7655 published `ChatbotEnhancedSchema` and `ChatbotFloatingSchema` from this
package, and both registrations already take them as their `schema` parameter, so the
map's value and the renderer's prop type are one declaration — the same property the
`'kanban'` arm gained in objectui#7664. Being declared here also makes them reachable:
`@object-ui/types` has zero workspace dependencies, which is exactly what blocked the
`kanban` case objectui#7645 measured, where the honoured type lived in a plugin.

Pinned in `src/__tests__/schema-registry-chatbot-keys-7704.test.ts` in two channels —
compile-time (`tsc -p tsconfig.test.json`: the keys survive in `keyof`, each value is
the face its renderer honours, and each value's own `type` literal is its key) and
runtime (a TypeScript-AST census of the interface source, plus each key selecting its
own arm through `safeValidateSchema`).

Scope: these two keys, whose authoring faces now exist — not a sweep of the map's other
entries, which objectui#7665 holds.
209 changes: 209 additions & 0 deletions packages/types/src/__tests__/schema-registry-chatbot-keys-7704.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `SchemaRegistry` names all three `plugin-chatbot` registrations, so the
* published `ComponentType` union does too (objectui#7704).
*
* ## The defect this pins shut
*
* `packages/plugin-chatbot/src/renderer.tsx` registers three components —
* `chatbot` (`:62`), `chatbot-enhanced` (`:241`), `chatbot-floating` (`:379`).
* `SchemaRegistry` mapped one of them. `ComponentType = keyof SchemaRegistry`
* is the published union, so a consumer discriminating on it was told two
* registered keys DO NOT EXIST. The asymmetry that proves which half was wrong:
* `packages/cli/src/utils/known-schema-types.ts:83-84` lists both keys — the
* CLI keeps its own parallel list precisely because this map did not have them.
*
* The map could not have carried them earlier and stayed honest. This map's
* value has to be the type the registered renderer honours, and until
* objectui#7655 there was none to point at: `ChatbotSchema` pins `type` to
* `'chatbot'`, and each registration's real key set lived in an anonymous
* `ChatbotSchema & { ... }` intersection local to the renderer file.
* objectui#7655 gave each registration one named authoring face, declared in
* THIS package — which is what makes the two entries simultaneously
*
* - HONEST: each value pins `type` to its own key, and the registration for
* that key takes that exact type as its `schema` parameter
* (`renderer.tsx:256`, `:394`), so the map's value and the renderer's prop
* type are one declaration; and
* - REACHABLE: `@object-ui/types` has zero workspace dependencies, and both
* faces are its own declarations. This is the difference from the `kanban`
* case objectui#7645 measured, where the honoured type lived in
* `@object-ui/plugin-kanban` — naming it from here would have been a
* phantom dependency and a cycle, and it was resolved the other way, by
* moving the dialect down into this package (objectui#7664).
*
* ## Scope — two keys, not a sweep
*
* objectui#7704 is these two keys, whose faces now exist. Whether EVERY
* `SchemaRegistry` value must be the type its renderer honours is
* objectui#7665's question and is deliberately not asked or answered here.
*
* ## Two channels, stated so nobody reads the wrong one
*
* The `export type assertion…` block below is COMPILE-TIME: it is checked by
* `tsc -p packages/types/tsconfig.test.json` (this package's `type-check`
* script chains it) and erased before vitest runs. A green vitest run is NOT
* evidence about it — the same split `chatbot-registration-authoring-faces-7655
* .test.ts` and `kanban-plugin-dialect-authoritative-7664.test.ts` document.
* The `it` blocks are the RUNTIME channel: a source census of the interface
* (so deleting an entry fails vitest too, not only `tsc`) and the accept sets
* of the validator the CLI applies.
*/

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import ts from 'typescript';
import type {
SchemaRegistry,
ComponentType,
ComplexSchema,
ChatbotSchema,
ChatbotEnhancedSchema,
ChatbotFloatingSchema,
} from '../index';
import {
ChatbotEnhancedSchema as ChatbotEnhancedZod,
ChatbotFloatingSchema as ChatbotFloatingZod,
safeValidateSchema,
} from '../zod/index.zod';

/* -------------------------------------------------------------------------- */
/* Compile-time pins — read by tsc via tsconfig.test.json, not by this run. */
/* -------------------------------------------------------------------------- */

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;

// Non-vacuity controls: `any` on either side satisfies every `extends` below
// while checking nothing, and `Equal<any, X>` is `false`.
type _RegistryIsReal = Assert<Equal<IsAny<SchemaRegistry>, false>>;
type _EnhancedFaceIsReal = Assert<Equal<IsAny<ChatbotEnhancedSchema>, false>>;
type _FloatingFaceIsReal = Assert<Equal<IsAny<ChatbotFloatingSchema>, false>>;

// 1. THE CARD. The published union yields both keys. `Extract` collapses to
// `never` the moment an entry is dropped, which fails this loudly — the
// `_KeyKept` shape objectui#7645/#7664 carry for `'kanban'`.
type _EnhancedKeyPublished = Assert<Equal<Extract<ComponentType, 'chatbot-enhanced'>, 'chatbot-enhanced'>>;
type _FloatingKeyPublished = Assert<Equal<Extract<ComponentType, 'chatbot-floating'>, 'chatbot-floating'>>;

// 2. Each value IS the face its registration honours — not `ChatbotSchema`
// (which `Equal` rejects: it pins `type` to `'chatbot'`), and not a
// `BaseSchema & { type }` weakest-true-claim placeholder.
type _EnhancedValueIsItsFace = Assert<Equal<SchemaRegistry['chatbot-enhanced'], ChatbotEnhancedSchema>>;
type _FloatingValueIsItsFace = Assert<Equal<SchemaRegistry['chatbot-floating'], ChatbotFloatingSchema>>;

// 3. Honesty, stated independently of #2: each value's own `type` literal IS
// the key it is filed under. This is the property objectui#7645 found
// violated for `'kanban'`, and the reason these two keys could not be added
// before objectui#7655 minted the faces.
type _EnhancedValueIsHonest = Assert<Equal<SchemaRegistry['chatbot-enhanced']['type'], 'chatbot-enhanced'>>;
type _FloatingValueIsHonest = Assert<Equal<SchemaRegistry['chatbot-floating']['type'], 'chatbot-floating'>>;
// …and the honesty check can fail — the pre-#7655 value would have been
// `ChatbotSchema`, whose `type` is `'chatbot'`, not either new key.
type _HonestyCanFail = Assert<Equal<Equal<ChatbotSchema['type'], 'chatbot-enhanced'>, false>>;

// 4. The `'chatbot'` entry is untouched: this card ADDS two keys, it does not
// re-point the existing one.
type _ChatbotEntryUnchanged = Assert<Equal<SchemaRegistry['chatbot'], ChatbotSchema>>;

// 5. Registry value and validator arm are the same declaration, so the key the
// map publishes and the arm `safeValidateSchema` selects cannot drift.
type _EnhancedArmIsTheValue = Assert<
Equal<Extract<ComplexSchema, { type: 'chatbot-enhanced' }>, SchemaRegistry['chatbot-enhanced']>
>;
type _FloatingArmIsTheValue = Assert<
Equal<Extract<ComplexSchema, { type: 'chatbot-floating' }>, SchemaRegistry['chatbot-floating']>
>;

/* -------------------------------------------------------------------------- */
/* Runtime pin 1 — source census of the interface */
/* -------------------------------------------------------------------------- */

const REGISTRY_TS = join(dirname(fileURLToPath(import.meta.url)), '..', 'registry.ts');

/** The `SchemaRegistry` members as declared, in source order: key + value type text. */
function registryEntries(interfaceName = 'SchemaRegistry'): Array<{ key: string; value: string }> {
const sf = ts.createSourceFile(REGISTRY_TS, readFileSync(REGISTRY_TS, 'utf8'), ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);
const decl = sf.statements.find(
(s): s is ts.InterfaceDeclaration => ts.isInterfaceDeclaration(s) && s.name.text === interfaceName,
);
if (!decl) throw new Error(`no top-level interface ${interfaceName} in ${REGISTRY_TS}`);
return decl.members.filter(ts.isPropertySignature).map((m) => ({
key: ts.isStringLiteral(m.name) || ts.isIdentifier(m.name) ? m.name.text : m.name.getText(sf),
value: m.type ? m.type.getText(sf) : '',
}));
}

describe('SchemaRegistry declares all three chatbot registrations (objectui#7704)', () => {
const entries = registryEntries();
const keys = entries.map((e) => e.key);
const valueOf = (key: string) => entries.find((e) => e.key === key)?.value;

it('the census reader read a real map — lit control before any assertion about the two keys', () => {
// If the parse collapsed, or the interface were renamed, every assertion
// below would pass vacuously on an empty list. These are the controls.
expect(keys.length).toBeGreaterThan(60);
expect(keys).toContain('kanban');
expect(keys).toContain('chatbot');
expect(valueOf('chatbot')).toBe('ChatbotSchema');
});

it('both registered keys are declared, each pointing at its own authoring face', () => {
expect(keys).toContain('chatbot-enhanced');
expect(keys).toContain('chatbot-floating');
expect(valueOf('chatbot-enhanced')).toBe('ChatbotEnhancedSchema');
expect(valueOf('chatbot-floating')).toBe('ChatbotFloatingSchema');
});

it('they sit with the family, under the `// Complex` group', () => {
const at = keys.indexOf('chatbot');
expect(at).toBeGreaterThan(keys.indexOf('kanban'));
expect(keys[at + 1]).toBe('chatbot-enhanced');
expect(keys[at + 2]).toBe('chatbot-floating');
});

it('the census reader can fail — a name that is not there throws rather than reading empty', () => {
expect(() => registryEntries('NotARealRegistryInterface')).toThrow(/no top-level interface NotARealRegistryInterface/);
});
});

/* -------------------------------------------------------------------------- */
/* Runtime pin 2 — the key is reachable through the validator the CLI applies */
/* -------------------------------------------------------------------------- */

describe('each newly named key selects its own arm end to end (objectui#7704)', () => {
// `messages` is a required arm on both twins (measured: a `{ type }`-only
// document is refused at `messages`, not at `type`), so the minimal node
// carries it. Everything else on both faces is optional.
const ENHANCED_NODE = { type: 'chatbot-enhanced', messages: [] };
const FLOATING_NODE = { type: 'chatbot-floating', messages: [] };

it('a minimal node of either key passes safeValidateSchema — the union the CLI applies', () => {
expect(safeValidateSchema(ENHANCED_NODE).success).toBe(true);
expect(safeValidateSchema(FLOATING_NODE).success).toBe(true);
});

it('the discriminant is real — each twin refuses the sibling key, and refuses it AT `type`', () => {
// Same documents as the control below with only `type` swapped, so the
// one refusal path proves the key is the discriminant and nothing else.
const enhancedOnFloating = ChatbotEnhancedZod.safeParse(FLOATING_NODE);
const floatingOnEnhanced = ChatbotFloatingZod.safeParse(ENHANCED_NODE);
expect(enhancedOnFloating.success).toBe(false);
expect(floatingOnEnhanced.success).toBe(false);
if (!enhancedOnFloating.success) {
expect(enhancedOnFloating.error.issues.map((i) => i.path.join('.'))).toEqual(['type']);
}
if (!floatingOnEnhanced.success) {
expect(floatingOnEnhanced.error.issues.map((i) => i.path.join('.'))).toEqual(['type']);
}
});

it('control: each twin accepts its own key, so the refusal above is about the discriminant', () => {
expect(ChatbotEnhancedZod.safeParse(ENHANCED_NODE).success).toBe(true);
expect(ChatbotFloatingZod.safeParse(FLOATING_NODE).success).toBe(true);
});
});
37 changes: 37 additions & 0 deletions packages/types/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ import type {
FilterBuilderSchema,
CarouselSchema,
ChatbotSchema,
ChatbotEnhancedSchema,
ChatbotFloatingSchema,
} from './complex.js';

/**
Expand Down Expand Up @@ -201,6 +203,41 @@ export interface SchemaRegistry {
'filter-builder': FilterBuilderSchema;
'carousel': CarouselSchema;
'chatbot': ChatbotSchema;
// `'chatbot-enhanced'` and `'chatbot-floating'` are the other two keys
// `packages/plugin-chatbot/src/renderer.tsx` registers (`:241`, `:379`).
// They were absent from this map until objectui#7704, so `ComponentType`
// — the published `keyof SchemaRegistry` union — told a consumer
// discriminating on it that two registered keys do not exist, and
// `packages/cli/src/utils/known-schema-types.ts` had to keep its own
// parallel list (`:83-84`) to know they do.
//
// Why they can be added now, and could not before: this map's value has to
// be the type the registered renderer honours, and until objectui#7655
// there was no honest one to point at. `ChatbotSchema` pins `type` to
// `'chatbot'`, and the two registrations' real key sets lived in anonymous
// `ChatbotSchema & { ... }` intersections local to the renderer file,
// referenceable by nothing outside it. objectui#7655 gave each registration
// one named authoring face, declared HERE — so both entries are honest (each
// value pins `type` to its own key) AND reachable (this is a
// zero-workspace-dependency layer; both faces are its own declarations, not
// a plugin's). That is what separated this from the `kanban` case
// objectui#7645 measured, where the honoured type lived in
// `@object-ui/plugin-kanban` and naming it from here would have been a
// phantom dependency and a cycle — resolved the other way, by moving the
// dialect down here (objectui#7664, the `'kanban'` note above).
//
// The two registrations already take these exact types as their `schema`
// parameter (`renderer.tsx:256`, `:394`), so the map's value and the
// renderer's prop type are one declaration, the same property the `'kanban'`
// arm above has.
//
// Pinned in `src/__tests__/schema-registry-chatbot-keys-7704.test.ts`: the
// keys survive in `keyof`, each value IS the face its renderer honours, and
// each value's `type` literal IS its own key. Scope note — objectui#7704 is
// these two keys, whose faces now exist; it is NOT a sweep of the map's
// other entries, which objectui#7665 holds.
'chatbot-enhanced': ChatbotEnhancedSchema;
'chatbot-floating': ChatbotFloatingSchema;
}

/**
Expand Down
Loading