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
46 changes: 46 additions & 0 deletions .changeset/console-form-field-spec-one-declaration-5542.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
'@object-ui/app-shell': patch
'@object-ui/console': patch
---

The form-field authoring contract now has ONE declaration, and the console reads it
instead of its own copy.

objectui#5040 was not a missing key. It was that **two hand-written descriptions of
one contract drifted**, and nothing could notice, because each was only ever checked
against itself. PR #5537 converged the two app-shell descriptions into
`views/metadata-admin/form-spec.ts`. A **third** survived in `apps/console`:
`FormPage.tsx` declared its own nine-key `interface FormFieldSpec`, under the same
name, in a different package — so the same failure mode stayed fully available.

Measured key by key before choosing a route, because the two honest outcomes are
"same contract, import it" and "genuinely narrower layer, rename it and pin the
subset". The console's copy was a strict subset — 9 of the shared type's 26 keys,
every one identical in type, none console-only — and it sat in a position that
describes an **authored document**: `FormSectionSpec.fields`, read straight off the
`/meta/view/:name` payload, the same spec `FormView` metadata-admin renders (both
files even spell the same six-member `type` union and call the element type
`FormFieldSpec`). The narrow, renderer-honoured shape is a different type that
already exists in that file, `RenderableField`. So this was one contract described
twice, and the console's description was wrong about the document: legal metadata —
`visibleWhen`, `dependsOn`, `type`, `options`, `immutable`, the recursive `fields`,
and ten more keys — was undeclared there. That is #5040's own symptom, "the type
rejects the configuration the runtime accepts", which no runtime test can see.

`@object-ui/app-shell` therefore re-exports `FormFieldSpec` from its package root
(type-only, erased at build — nothing is added to the bundle), and `FormPage.tsx`
imports it and deletes the local declaration. Reachability is the load-bearing half:
a type that cannot be imported is a type that gets retyped, and retyped copies drift.
`form-spec.ts` itself is untouched.

`FormPage.fieldSpec.test.ts` is the pin that makes future drift loud. It reads the
field-spec type back out of the **exported** `buildSections` signature rather than
naming it, so re-inlining a local `interface FormFieldSpec` fails `type-check` even
if the copy agrees on every key on the day it is written — which is exactly what did
not happen to the copy this change removes. Its liveness controls are what stop it
being a phantom check: the removed nine-key shape is pinned NOT equal to the shared
type (so the `Equal` helper is proven to still discriminate), `RenderableField` is
pinned not equal to it either (so the honoured-row and authored-document types cannot
be collapsed again), and an undeclared key is still rejected (so the import did not
smuggle in an index signature). Behaviour is unchanged: the runtime always accepted
these keys, and the vitest half proves the same rows are built.
202 changes: 202 additions & 0 deletions apps/console/src/components/FormPage.fieldSpec.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ONE description of the form-field authoring contract — objectui#5542.
*
* ## The class this pins shut
*
* objectui#5040 was not a missing key. It was that **two descriptions of one
* contract drifted**, and nothing could notice, because each was only ever
* checked against itself. PR #5537 converged the two app-shell descriptions
* into `packages/app-shell/src/views/metadata-admin/form-spec.ts`. A **third**
* survived in this app: `FormPage.tsx` declared its own nine-key
* `interface FormFieldSpec`, under the same name, in a different package.
*
* The measurement that chose the route: the console's copy was a strict subset
* — 9 of the shared type's 26 keys, every one of them identical, none of them
* console-only — sitting in a position that describes an **authored document**
* (`FormSectionSpec.fields`, read straight off `/meta/view/:name`), not a
* narrower thing this app authors. The narrow, renderer-honoured shape is a
* separate type that already exists here, `RenderableField`. So the two names
* were one contract, and the console's copy simply described it wrongly: legal
* metadata (`visibleWhen`, `dependsOn`, `type`, `options`, `immutable`, the
* recursive `fields`, …) was undeclared, which is #5040's own symptom — "the
* type rejects the configuration the runtime accepts".
*
* ## What is pinned, and by which tool
*
* Two halves that fail in different places, on purpose:
*
* • **`tsc`** — {@link formFieldSpecContractPins}. Type assertions are erased
* at runtime, so vitest proves nothing about them; the app's `type-check`
* script (`tsc --noEmit`, whose `include` is `["src", "dev"]` and therefore
* compiles this file) is what judges them. PIN A is the one that closes the
* class: it reads the field-spec type back out of the **exported**
* `buildSections` signature, so a re-inlined local copy fails here even if
* it agrees on every key on the day it is written. PIN B is its liveness
* control — it asserts that `Equal` still returns `false` for a type that
* really differs, so PIN A can never pass vacuously.
* • **vitest** — the `buildSections` block. It proves the widening is inert
* at runtime: a field spec carrying the previously-undeclarable keys builds
* the same rows, and the keys this renderer does not honour are recorded as
* not honoured rather than assumed.
*/

import { describe, expect, it } from 'vitest';
import type { FormFieldSpec } from '@object-ui/app-shell';
import { buildSections } from './FormPage';

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;

/**
* The console's own view of the field-spec type, read back out of the public
* surface rather than re-stated. `buildSections` is exported and takes the
* FormView it renders, so this walks spec → sections → fields → the non-string
* arm. Deriving it this way is the point: nothing in this file names the type
* the way `FormPage.tsx` names it, so the pin follows whatever that file
* actually uses in that position.
*/
type ConsoleFormViewSpec = Parameters<typeof buildSections>[0];
type ConsoleSectionSpec = NonNullable<ConsoleFormViewSpec['sections']>[number];
type ConsoleFieldSpec = Exclude<ConsoleSectionSpec['fields'][number], string>;

/**
* These never run. They are the half of this card a runtime test cannot express
* — "there is only one declaration of this contract" is a statement about `tsc`.
*/
export function formFieldSpecContractPins(): void {
// ── PIN A — the class. The element type of the console's authored-field array
// IS the app-shell declaration, not a structural twin of it. Re-inlining a
// local `interface FormFieldSpec` here turns this line red on the day it is
// written, which is exactly what did NOT happen to the copy #5542 removed.
//
// Spelled as a `const` rather than a bare `type` alias because this app sets
// `noUnusedLocals`, which reports an unreferenced alias (TS6196) — a pin that
// has to be deleted to compile is no pin. The assertion is unchanged: the
// annotation is `Assert<…>`, so a false `Equal` fails the `extends true`
// constraint (TS2344) here, and the initializer is erased at runtime.
const oneContract: Assert<Equal<ConsoleFieldSpec, FormFieldSpec>> = true;
void oneContract;

// ── PIN B — liveness control for PIN A. `Equal` is a conditional-type trick
// and a broken one would answer `true` for everything, which would make PIN A
// a phantom check that no drift could ever fail. Pin the negative answer too:
// the nine-key shape this file removed is NOT the shared type, and `Equal`
// must still say so.
type _removedCopy = {
field: string;
label?: string;
placeholder?: string;
helpText?: string;
required?: boolean;
readonly?: boolean;
hidden?: boolean;
colSpan?: 1 | 2 | 3 | 4;
widget?: string;
};
const equalStillDiscriminates: Assert<Equal<Equal<_removedCopy, FormFieldSpec>, false>> = true;
void equalStillDiscriminates;

// ── PIN C — the measurement, made executable. Every key below was
// `TS2353: … does not exist in type 'FormFieldSpec'` in this app before
// #5542, while the runtime accepted it and metadata-admin authored it. They
// are the difference the key-by-key comparison found.
const wasUndeclarableHere: ConsoleFieldSpec = {
field: 'stage',
type: 'select',
options: [{ label: 'New', value: 'new' }],
reference: 'showcase_stage',
dependsOn: 'objectName',
immutable: true,
multiple: false,
minLength: 1,
maxLength: 40,
min: 0,
max: 10,
precision: 2,
scale: 1,
disclosure: 'popover',
language: 'sql',
visibleWhen: '${data.kind == "deal"}',
visibleOn: { dialect: 'cel', source: 'data.kind == "deal"' },
fields: ['nested', { field: 'deeper' }],
};
void wasUndeclarableHere;

// ── PIN D — negative control on the KEY. PIN C means "these keys are
// declared", not "this position stopped checking". Excess-property checking
// is still live, so the import did not smuggle in an index signature or
// `any` — the failure mode a widening is most likely to reach for.
const undeclaredKey: ConsoleFieldSpec = {
field: 'stage',
// @ts-expect-error objectui#5542 — an undeclared key is still rejected
thisKeyIsNotPartOfTheAuthoringSurface: true,
};
void undeclaredKey;

// ── PIN E — the renderer's honoured shape is a DIFFERENT type, and stays
// that way. Collapsing the two is how the removed copy came to describe an
// authored document with the nine keys this file happens to read.
type ConsoleRenderableField = ReturnType<typeof buildSections>[number]['fields'][number];
const notTheSameThing: Assert<Equal<Equal<ConsoleRenderableField, FormFieldSpec>, false>> = true;
void notTheSameThing;
}

describe('objectui#5542 — the console renders the shared field spec', () => {
it('builds the same rows from a spec carrying the previously-undeclarable keys', () => {
// Typed through `buildSections`' own parameter, so this literal is checked
// against whatever `FormPage.tsx` declares in that position.
const sections = buildSections(
{
sections: [
{
label: 'Details',
columns: 2,
fields: [
{
field: 'stage',
label: 'Stage',
colSpan: 2,
required: true,
// Legal metadata the console could not declare before #5542.
type: 'select',
options: [{ label: 'New', value: 'new' }],
dependsOn: 'objectName',
visibleWhen: '${data.kind == "deal"}',
immutable: true,
maxLength: 40,
},
],
},
],
},
null,
);

expect(sections).toHaveLength(1);
expect(sections[0].columns).toBe(2);
expect(sections[0].fields).toHaveLength(1);

const row = sections[0].fields[0];
expect(row.name).toBe('stage');
expect(row.label).toBe('Stage');
expect(row.colSpan).toBe(2);
expect(row.required).toBe(true);

// Recorded rather than assumed: `RenderableField` is the narrow honoured
// shape, and it takes `maxLength` from the OBJECT schema, never from the
// field override — so authoring it on the field is declarable-but-inert in
// this renderer. That was true before #5542 too; the only thing that
// changed is that the type now tells the truth about the document instead
// of pretending the key cannot be written at all.
expect(row.maxLength).toBeUndefined();
});

it('still accepts the bare string arm, which is most of what authors write', () => {
const sections = buildSections({ sections: [{ fields: ['name', 'amount'] }] }, null);
expect(sections[0].fields.map((f) => f.name)).toEqual(['name', 'amount']);
});
});
35 changes: 23 additions & 12 deletions apps/console/src/components/FormPage.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -53,6 +53,7 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import type { FormFieldSpec } from '@object-ui/app-shell';
import { resolveSubmitRedirect } from './submitRedirect';

const API_BASE = (import.meta.env.VITE_SERVER_URL || '') + '/api/v1';
Expand DownExpand Up@@ -272,21 +273,31 @@ interface FormSectionSpec {
collapsible?: boolean;
collapsed?: boolean;
columns?: 1 | 2 | 3 | 4 | '1' | '2' | '3' | '4';
/**
* `FormFieldSpec` here is the app-shell declaration, imported — NOT a local
* copy of it (objectui#5542).
*
* This position describes what an AUTHOR wrote: `sec.fields` is read straight
* off the `/meta/view/:name` payload, the same `FormView` document
* metadata-admin authors and renders. Until #5542 this file declared its own
* nine-key `interface FormFieldSpec` in that position — a second description
* of one contract, and the description was wrong about the document: the
* shared surface has 26 keys, so legal metadata (`visibleWhen`, `dependsOn`,
* `type`, `options`, `immutable`, the recursive `fields`, …) was undeclared
* here. That is the exact failure mode objectui#5040 recorded — "the type
* rejects the configuration the runtime accepts" — and nothing could notice,
* because each copy was only ever checked against itself.
*
* The narrow shape this renderer actually honours is a DIFFERENT type and
* already exists: {@link RenderableField}, what {@link buildSections} emits.
* Keeping the incoming-document type wide and the honoured-row type narrow is
* the distinction the old declaration collapsed. `FormFieldSpec.contract.test.ts`
* pins this element type to the app-shell one, so re-inlining a local copy
* fails `type-check` even if it agrees on every key on the day it is written.
*/
fields: Array<string | FormFieldSpec>;
}

interface FormFieldSpec {
field: string;
label?: string;
placeholder?: string;
helpText?: string;
required?: boolean;
readonly?: boolean;
hidden?: boolean;
colSpan?: 1 | 2 | 3 | 4;
widget?: string;
}

/** Normalized field row used by the renderer. */
interface RenderableField {
name: string;
Expand Down
6 changes: 6 additions & 0 deletions packages/app-shell/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -308,6 +308,12 @@ export type {
MetadataSelection,
MetadataInspector,
MetadataInspectorProps,
// The form-field authoring surface, in ONE declaration (objectui#5040 /
// #5542). `apps/console` renders the same authored `FormView` documents this
// package's metadata-admin does; before it could import this name it kept a
// third hand-written copy of the shape. See the note on the re-export in
// `views/metadata-admin/index.ts`.
FormFieldSpec,
} from './views/metadata-admin/index.js';

// Studio WYSIWYG design surface (ADR-0080) — the open-source design surface.
Expand Down
13 changes: 13 additions & 0 deletions packages/app-shell/src/views/metadata-admin/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,6 +27,19 @@ export { MetadataDiagnosticsPage } from './DiagnosticsPage.js';
export { MetadataQuickFind } from './QuickFind.js';
export { PageShell as MetadataPageShell } from './PageShell.js';
export { SchemaForm } from './SchemaForm.js';
/**
* The ONE declaration of the metadata-admin form-field authoring surface
* (objectui#5040, converged by PR #5537 into the `./form-spec.js` leaf).
*
* Re-exported through the package root because it has an out-of-package
* consumer: `apps/console`'s `FormPage.tsx` reads the same authored `FormView`
* documents and, until objectui#5542, held a THIRD hand-written description of
* this shape under the same name. A type that cannot be imported is a type
* that gets retyped, and retyped copies drift — which is the defect #5040
* recorded. Reachability is what makes the convergence hold outside this
* directory. Type-only: erased at build, so nothing is added to the bundle.
*/
export type { FormFieldSpec } from './form-spec.js';
export { LayeredDiff } from './LayeredDiff.js';
export { PermissionMatrixEditPage } from './PermissionMatrixEditor.js';
export {
Expand Down
Loading