diff --git a/.changeset/console-form-field-spec-one-declaration-5542.md b/.changeset/console-form-field-spec-one-declaration-5542.md new file mode 100644 index 000000000..59b1a9fdc --- /dev/null +++ b/.changeset/console-form-field-spec-one-declaration-5542.md @@ -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. diff --git a/apps/console/src/components/FormPage.fieldSpec.test.ts b/apps/console/src/components/FormPage.fieldSpec.test.ts new file mode 100644 index 000000000..4565a2781 --- /dev/null +++ b/apps/console/src/components/FormPage.fieldSpec.test.ts @@ -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; +type Equal = (() => T extends A ? 1 : 2) extends () => 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[0]; +type ConsoleSectionSpec = NonNullable[number]; +type ConsoleFieldSpec = Exclude; + +/** + * 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> = 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, 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[number]['fields'][number]; + const notTheSameThing: Assert, 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']); + }); +}); diff --git a/apps/console/src/components/FormPage.tsx b/apps/console/src/components/FormPage.tsx index 7539cd1cb..b58e52bf2 100644 --- a/apps/console/src/components/FormPage.tsx +++ b/apps/console/src/components/FormPage.tsx @@ -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'; @@ -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; } -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; diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 7eab6ce6a..91975ee72 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -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. diff --git a/packages/app-shell/src/views/metadata-admin/index.ts b/packages/app-shell/src/views/metadata-admin/index.ts index a212fccbc..ca3dbfc14 100644 --- a/packages/app-shell/src/views/metadata-admin/index.ts +++ b/packages/app-shell/src/views/metadata-admin/index.ts @@ -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 {