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
45 changes: 45 additions & 0 deletions .changeset/inverted-pin-burndown-probes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
"@object-ui/types": patch
---

Fix the admission probes behind objectstack#4171's three inverted pins, and
derive the `NavigationItem` keys that genuinely became derivable (objectui#3177).

Spec 17.0.0-rc.1 typed `NavigationItem`, `FormField` and
`ConditionalValidation.then`/`.otherwise`, so the `IsAny` / `IsUnknown` pins
guarding them fired. Firing was supposed to mean "the burn-down is due". A
per-symbol triage found it did not: **`any` was never the only blocker for any
of the three**, so "no longer `any`" was never the right admission question.
Nothing was bound; the probes now ask the condition that actually governs each
symbol, and each still asserts today's state — so they pass now and stop
compiling the day their own blocker lifts.

- `NavigationItem` — the spec models navigation as a nine-variant discriminated
union; objectui keeps one flat shape, and the spec has no counterpart at
either tier for `visible: boolean` (which `menuItemToNavigationItem`
manufactures when it inverts legacy `MenuItem.hidden`), `pinned` (backs
`useNavPins`), the legacy `defaultOpen` spelling, or a separator carrying a
`label`. Four probes, one per blocker.
- `FormField` — two concepts on two layers, not two dialects of one: the
required keys are disjoint (objectui `name` = the form data path; spec
`field` = an object-field reference, with no `name` at either tier), and the
shared `field` key is a string on one side and the resolved metadata object
on the other. Binding would also collapse the objectui#3090 disambiguation
that exports `SpecFormField` separately, and revert framework#4074's
`dependsOn` widening.
- `ConditionalValidation` — the branches went from `unknown` to
`BaseValidationRuleShape`, which is `{ type: string; …; [key: string]:
unknown }`. Better than `unknown`, still not derivable: `type` is not a
literal union so a branch cannot narrow by discriminant, and the index
signature waves through any member — a typo'd `type: 'formatt'` included. The
spec says so itself and names the remaining work as objectstack#4075. The
probe now pins "literal discriminant / no index signature", so it goes green
exactly when that lands.

What DID become derivable is derived. `NavigationItemType` now comes off the
spec's own nav-item discriminant instead of a hand-written nine-member copy —
the objectstack#4115 failure class, and it also makes a future spec variant a
compile error at exhaustive consumers rather than a silent `default:`. Same for
`recordMode`, `filters`, `badge`, `target`, `params` and `actionDef`, each taken
from the spec branch that owns it, extending the existing `badgeVariant`
precedent. No member changes today, so no consumer is affected.
157 changes: 139 additions & 18 deletions packages/types/src/__tests__/spec-derived-unions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,14 +61,18 @@ import {
import {
FieldType as SpecFieldType,
} from '@objectstack/spec/data';
// The objectstack#4171 inverted pin must import the banned name to probe its
// any-ness — this guard is a sanctioned importer (#3090 tripwire).
// The objectstack#4171 / #3177 pins must import the banned name to probe it —
// this guard is a sanctioned importer (#3090 tripwire).
/* eslint-disable no-restricted-imports -- reported at the specifier line, out of -next-line reach */
import type {
NavigationItem as SpecNavigationItem,
NavigationItemInput as SpecNavigationItemInput,
FormField as SpecFormField,
FormFieldInput as SpecFormFieldInput,
} from '@objectstack/spec/ui';
/* eslint-enable no-restricted-imports */
import type { NavigationItem, NavigationItemType } from '../app';
import type { FormField } from '../form';
import type { BreakpointName } from '../mobile';
import type { ExportJobStatus, ImportJobStatus, ImportWriteMode, ValidationError } from '../data';
import {
Expand DownExpand Up@@ -145,40 +149,157 @@ const _validationErrorShape: ValidationError = { field: 'name', message: 'requir
// objectui query-AST vocabulary they have become.

/**
* Inverted pins — the tripwire FIRED on spec 17.0.0-rc.1 (#3177).
* Admission probes for `NavigationItem` and `FormField` (#3177).
*
* ## What these used to be, and why they were replaced
*
* `NavigationItem`, `JoinNode` and `FormField` used to collide with a spec
* export whose own declaration resolved to `any` (the spec annotated the
* recursive schemas behind them as `z.ZodType<any>`, and `z.infer` of that is
* `any`). Binding objectui's local interface to that would have replaced a
* precise, documented shape with `any` — a type-safety regression wearing a
* burn-down's clothes — so they stayed local, and these pins asserted the
* burn-down's clothes — so they stayed local, and two `IsAny` pins asserted the
* premise held. Filed upstream as objectstack#4171.
*
* Two of them are now typed properly upstream, so the assertions below are
* inverted to record that fact rather than the old one. **They are a record,
* not a resolution**: the burn-down each one asks for — deriving objectui's
* `NavigationItem` / `FormField` from the spec — touches widely-used public
* types and is deliberately NOT bundled into a version bump. Tracked in #3177.
* Spec 17.0.0-rc.1 typed both properly and the `IsAny` pins fired. The #3177
* triage then measured what the burn-down they demanded would actually cost —
* and found that **`any` was never the only blocker for either symbol**, so
* "no longer `any`" was never the right admission question. `IsAny` going
* `false` proves the spec type is no longer EMPTY; it says nothing about
* whether it is PRECISE enough to bind, which is what the burn-down needs.
*
* So the probes below ask the real question instead, one blocker per line. Each
* asserts the CURRENT state (so this file is green today) and stops compiling
* the day that specific blocker lifts — at which point that line names exactly
* what became derivable. `JoinNode` needs none of this: spec 17.0.0 retired the
* symbol (framework#4286), so there is no collision left to reason about.
*
* `JoinNode`'s pin is gone entirely: spec 17.0.0 retired the symbol
* (framework#4286), so there is no collision left to reason about.
* ## Why not simply compare the two types
*
* Mutual assignability CANNOT distinguish the `any` case on its own: `any`
* answers every `extends` question affirmatively, so a naive probe reports such
* a symbol as "identical to the spec" and recommends exactly the wrong edit.
* That is why these are written as explicit `IsAny` probes.
* Mutual assignability lies here, in three separate ways, all of them present
* in this repo (the list is `scripts/check-spec-symbol-derivation.mjs`'s):
* `any` answers every `extends` question affirmatively; so does `unknown` on
* one side; and objectui's `FormField` carries `[key: string]: any`, which
* absorbs any member the spec has and makes the two compare equal while they
* accept wildly different objects. A structural `extends` ALSO silently permits
* excess properties, so it cannot see that the spec declares no `pinned`. Hence
* per-key, per-tier probes rather than one verdict.
*/
type IsAny<T> = 0 extends 1 & T ? true : false;
const _specNavigationItemIsStillAny = false satisfies IsAny<SpecNavigationItem>;
const _specFormFieldIsStillAny = false satisfies IsAny<SpecFormField>;

/** Every key of every branch of a union (plain `keyof` on a union gives the intersection). */
type KeysOfUnion<T> = T extends unknown ? keyof T : never;
/** Does the spec declare this key on ANY nav branch, at EITHER tier? */
type SpecNavDeclares<K extends string> =
K extends KeysOfUnion<SpecNavigationItem> | KeysOfUnion<SpecNavigationItemInput> ? true : false;

// ── NavigationItem: the three blockers, none of which `any` ever caused ──────
//
// Umbrella verdict: still not bindable. The lines under it say why, and are the
// ones to act on — this one stays `false` while ANY blocker remains.
const _localNavIsNotYetTheSpecUnion = false satisfies [NavigationItem] extends [SpecNavigationItem]
? true
: false;

// 1. `visible: boolean`. The spec takes a CEL string (input) / Expression
// envelope (output); neither tier admits a boolean. `NavigationRenderer`
// evaluates one, and `menuItemToNavigationItem` MANUFACTURES one when it
// inverts legacy `MenuItem.hidden`. Measured: binding to the input tier
// fails with 3x TS2322 on exactly those lines.
type SpecNavVisible =
| NonNullable<Extract<SpecNavigationItem, { type: 'url' }>['visible']>
| NonNullable<Extract<SpecNavigationItemInput, { type: 'url' }>['visible']>;
const _specNavVisibleStillRejectsBoolean = false satisfies boolean extends SpecNavVisible
? true
: false;

// 2. Keys the spec has no counterpart for at either tier. `pinned` backs
// `useNavPins` + `FavoritesProvider`; `defaultOpen` is the legacy spelling
// `navigation-spec-parity.test.ts` keeps accepting for published metadata.
// If the spec ever claims either NAME, this fails and the two meanings must
// be reconciled rather than silently shadowed.
const _specNavStillHasNoPinned = false satisfies SpecNavDeclares<'pinned'>;
const _specNavStillHasNoDefaultOpen = false satisfies SpecNavDeclares<'defaultOpen'>;

// 3. objectui's separator carries a `label`; the spec's separator branch
// declares only `type` / `id?` / `order?`. `menuItemToNavigationItem` emits
// one (measured: TS2353), so this is load-bearing, not decorative.
const _specSeparatorStillHasNoLabel = false satisfies 'label' extends keyof Extract<
SpecNavigationItem,
{ type: 'separator' }
>
? true
: false;

// What IS derivable today is derived: `app.ts` now takes `NavigationItemType`
// off the spec's discriminant, and `recordMode` / `filters` / `badge` /
// `target` / `params` / `actionDef` / `badgeVariant` off the branch that owns
// each. This asserts the membership list really is the spec's — a restatement
// that drops a member (the objectstack#4115 failure class) fails here.
const _navTypeCoversSpec = null as unknown as SpecNavigationItem['type'] satisfies NavigationItemType;

// ── FormField: not one concept in two dialects, but two concepts on two layers ─
//
// `select-option-spec-parity.test.ts` states the distinction in its own header —
// "Unlike the FormField pair — two genuinely different concepts on two layers —
// a select option is ONE concept in two dialects" — and `index.ts` exports
// `SpecFormField` SEPARATELY as the disambiguation the #3090 tripwire exists to
// force. Binding would make `FormField === SpecFormField` and collapse that.
//
// The decisive, mechanical form of "two layers": the two types' REQUIRED keys
// are disjoint. objectui requires `name` (the form data path); the spec requires
// `field` (a reference to an object field) and has no `name` at either tier.
const _specFormFieldIsNoLongerAny = false satisfies IsAny<SpecFormField>;
const _specFormFieldStillHasNoName = false satisfies 'name' extends keyof SpecFormField
? true
: false;
const _specFormFieldInputStillHasNoName = false satisfies 'name' extends keyof SpecFormFieldInput
? true
: false;

// The same key on both sides, meaning different things — the pun `form.ts`
// flags with a ⚠️. The spec's `field` is the referenced field's NAME; on a
// runtime `FormField` the slot holds the RESOLVED metadata object, and
// `normalizeSectionField` (@object-ui/plugin-form) is the only place the two
// layers meet.
const _specFieldSlotIsStillAName = true satisfies [SpecFormField['field']] extends [string]
? true
: false;
const _localFieldSlotIsStillAnObject = false satisfies [NonNullable<FormField['field']>] extends [
string,
]
? true
: false;

// framework#4074 widened objectui's `dependsOn` to match its runtime reader
// (`resolveCascadingOptions` has always accepted arrays and `{ field, param }`
// entries). The spec still says `string`, so binding would revert that fix.
const _specDependsOnStillTakesNoArray = false satisfies string[] extends NonNullable<
SpecFormFieldInput['dependsOn']
>
? true
: false;

// ADR-0089 D2 folds `visibleOn` into `visibleWhen` at the spec's schema
// boundary, so it is absent from the OUTPUT type by construction — while
// objectui's #2212 wire contract keeps it. (The spec's input tier still
// accepts it; this asks the output tier on purpose.)
const _specOutputStillDropsVisibleOn = false satisfies 'visibleOn' extends keyof SpecFormField
? true
: false;

void _chartCovers; void _reportCovers; void _actionCovers; void _pageCovers; void _vizCovers;
void _runnableCovers; void _componentCovers; void _paramFieldCovers; void _resolvableCovers;
void _fieldBackedParam; void _minimalTypedParam;
void _breakpointCovers; void _importModeCovers; void _importStatusCovers; void _exportStatusCovers;
void _validationErrorShape;
void _specNavigationItemIsStillAny; void _specFormFieldIsStillAny;
void _localNavIsNotYetTheSpecUnion; void _specNavVisibleStillRejectsBoolean;
void _specNavStillHasNoPinned; void _specNavStillHasNoDefaultOpen;
void _specSeparatorStillHasNoLabel; void _navTypeCoversSpec;
void _specFormFieldIsNoLongerAny; void _specFormFieldStillHasNoName;
void _specFormFieldInputStillHasNoName; void _specFieldSlotIsStillAName;
void _localFieldSlotIsStillAnObject; void _specDependsOnStillTakesNoArray;
void _specOutputStillDropsVisibleOn;

/** Read a spec enum's members, failing loudly if the shape ever changes. */
const optionsOf = (schema: unknown, name: string): string[] => {
Expand Down
79 changes: 61 additions & 18 deletions packages/types/src/__tests__/validation-rule-spec-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,19 +104,55 @@ const _conditionalNonBranchKeysAreSpec = true satisfies Equal<
// …and the branches are precise here, not `unknown`.
const _conditionalBranchIsTyped = false satisfies IsUnknown<ConditionalValidation['then']>;

// INVERTED PIN (objectstack#4171) — FIRED on spec 17.0.0-rc.1 (#3177).
// ── The branch probes, retargeted (#3177) ───────────────────────────────────
//
// The divergence above was only justified while the spec's own
// `then`/`otherwise` erased to `unknown`. They no longer do, so the assertions
// are inverted to record that. **A record, not a resolution**: what the pin asks
// for — deleting the divergence and deriving `ConditionalValidation` whole — is
// deliberately not bundled into a version bump. Tracked in #3177.
const _specThenIsStillUnknown = false satisfies IsUnknown<
z.input<typeof ConditionalValidationSchema>['then']
>;
const _specOtherwiseIsStillUnknown = false satisfies IsUnknown<
z.input<typeof ConditionalValidationSchema>['otherwise']
>;
// These used to ask `IsUnknown<…>`, on the reasoning that the divergence was
// justified only while the spec's `then`/`otherwise` erased to `unknown`. Spec
// 17.0.0-rc.1 made that `false` and the pins fired — but the #3177 triage found
// the pins had asked too weak a question, and firing did NOT mean the branches
// had become derivable.
//
// What they actually became is `BaseValidationRuleShape`:
//
// interface BaseValidationRuleShape {
// type: string; name: string; message: string; …; [key: string]: unknown;
// }
//
// The spec says so itself, at that type's declaration: "it types the KNOWN keys
// and accepts any others. That is a real improvement over `unknown` (which types
// nothing) but **it is not strictness** — the discriminated union below is what
// actually rejects a malformed rule, at parse time. Removing the index signature
// is the #4075 family of work, not this change."
//
// So deriving wholesale today would trade a 9-member discriminated union for a
// bag: `then.type` degrades from a literal union to `string`, `then.condition`
// to `unknown`, and a typo'd `type: 'formatt'` starts compiling. That is the
// same regression the original pin existed to prevent, one notch weaker — and
// it lands where it costs most, because renderers read plain objects and never
// parse, so the authoring-time discriminant is the ONLY gate on these rules.
//
// The probes below therefore ask the pin's TRUE trigger — "does the spec type
// them PROPERLY", not "does it type them at all". Both assert today's state, so
// this file is green now; both stop compiling when objectstack#4075 removes the
// index signature and the branches become a real union, which is when the
// wholesale derivation genuinely comes due.
type SpecThen = z.input<typeof ConditionalValidationSchema>['then'];
type SpecOtherwise = NonNullable<z.input<typeof ConditionalValidationSchema>['otherwise']>;

/** A discriminated union has a LITERAL `type`; a bag widens it to `string`. */
type HasLiteralDiscriminant<T extends { type: unknown }> = string extends T['type'] ? false : true;
/** `[key: string]: unknown` absorbs any member, so nothing is rejected at authoring time. */
type HasIndexSignature<T> = string extends keyof T ? true : false;

const _specThenStillCannotNarrow = false satisfies HasLiteralDiscriminant<SpecThen>;
const _specOtherwiseStillCannotNarrow = false satisfies HasLiteralDiscriminant<SpecOtherwise>;
const _specThenStillCarriesAnIndexSignature = true satisfies HasIndexSignature<SpecThen>;
const _specOtherwiseStillCarriesAnIndexSignature = true satisfies HasIndexSignature<SpecOtherwise>;

// Kept as the record of WHY the predicate had to change: the branches are no
// longer `unknown`, so the original pin's own stated condition really did lapse
// — it was simply not the condition that governed the burn-down.
const _specThenIsNoLongerUnknown = false satisfies IsUnknown<SpecThen>;

/**
* The spec ships these as `lazySchema()` thunks, so `.shape` is only reachable
Expand DownExpand Up@@ -332,13 +368,20 @@ describe('predicates are on the ExpressionInput wire shape, not `string`', () =>
});
});

describe('pinned divergence: `then` / `otherwise` (objectstack#4171)', () => {
describe('pinned divergence: `then` / `otherwise` (objectstack#4171, #4075)', () => {
// The type half of this divergence is pinned at the top of this file
// (`_specThenIsStillUnknown` / `_conditionalBranchIsTyped`), because that is
// the half a runtime assertion cannot reach. What IS observable at runtime is
// that the spec's own parser still rejects a nonsense branch — so the erasure
// is purely a published-typing defect, not a loosened contract, and objectui
// re-typing the branch cannot admit anything the server would refuse.
// (`_specThenStillCannotNarrow` / `_specThenStillCarriesAnIndexSignature` /
// `_conditionalBranchIsTyped`), because that is the half a runtime assertion
// cannot reach. What IS observable at runtime is that the spec's own parser
// still rejects a nonsense branch — so the weak typing is purely a published-
// typing defect, not a loosened contract, and objectui re-typing the branch
// cannot admit anything the server would refuse.
//
// That asymmetry is the whole argument for keeping the divergence: the spec
// REJECTS this payload at parse time and DESCRIBES it as legal at compile
// time. objectui's renderers read plain objects and never parse, so only the
// compile-time half is ever consulted on the client — which is precisely why
// adopting the spec's weaker branch type would matter.
it('is a typing gap only — the spec`s parser still rejects a nonsense branch', () => {
expect(() =>
ConditionalValidationSchema.parse({
Expand Down
Loading
Loading