Skip to content

Commit befe75f

Browse files
committed
refactor(types)!: the action sub-vocabularies derive from @objectstack/spec (objectstack#4074)
`packages/types/src/ui-action.ts` imported exactly one of the spec's action vocabularies — `ActionType`, derived in #2231/#2901 — and hand-declared the rest under doc comments claiming spec canonicity. `ActionLocation`'s comment said the single source of truth was `@objectstack/spec/ui` and that this file "re-export"s it, while the code re-DECLARED a parallel union, `as const` tuple, and `z.enum`. That is why objectstack#3856 predicted a compile error when spec 17 removed `action.execute` and there wasn't one: nothing here was bound to the spec's `z.infer`, so a key removal upstream produced no signal in objectui. Already drifted, not merely drift-prone. `ActionParamSchema.type` is `FieldType.optional()` and `FieldType` carries 49 members; the hand-written `ActionParamFieldType` listed 16. A spec-valid param typed `lookup`, `multiselect`, `currency`, `user`, `tags` or `json` failed `tsc` against this package even though the dialog renders it — the same failure `ActionType` had before it was derived (missing `form` while `executeForm` implemented it). - `ActionLocation` / `ACTION_LOCATIONS` / `ActionLocationSchema` are now the spec's own three symbols. The latter two stay VALUE exports: #2561 decision (a) drops spec/ui's `…Schema` names from this package but explicitly keeps these, so they must not sit in an `export type` block that erases them. - `ActionComponent` is `NonNullable<Action['component']>`, read off the spec's resolved `Action` rather than `ActionSchema.shape.component` — spec exports `ActionSchema` as a `lazySchema` proxy that does not forward `.shape`. - `ActionParamFieldType` is the spec's `FieldType`, with `ACTION_PARAM_FIELD_TYPES` as a runtime witness: a type alias erases, so identity against `FieldType.options` is what catches a future re-fork. - `ActionParam` gains the 13 optional capability fields it could not express (`visible`, `accept`, `maxSize`, `multiple`, and the lookup-picker group), all already declared by core's `ActionParamDef` and mapped into the shared field renderer by app-shell's `paramToField.ts` (ADR-0059). A blind derivation would have broken authored metadata: `paramToField.ts`'s `PARAM_TYPE_ALIASES` still accepts `checkbox`, `reference` and `datetime-local`, none of which is a spec `FieldType`. They are now declared as `ObjectUiLocalParamFieldType` and `ActionParam.type` takes `ResolvableParamFieldType` (spec ∪ local) — the shape this file already uses for `navigation`, so the dialect is visible to an importer instead of hidden in a `Record<string, string>` in another package. Also corrects this guard file's own claim. Its header said the `satisfies` checks "are the real enforcement"; they are not. Every package tsconfig excludes test files by glob and there is no vitest `typecheck` project, so no `tsc` invocation reads that file — reverting `ActionParamFieldType` to the 16-member fork produces zero type errors. The #4074 cases are therefore runtime identity and membership checks, which do fail against the pre-fix source. BREAKING CHANGE: `ActionParamFieldType` widens from 16 members to 49, so an exhaustive `switch` over a param `type` stops being exhaustive. All 16 previous members remain valid, so no authored metadata breaks. The new `ActionParam` fields are optional and additive. Refs objectstack#4074, objectstack#3856, #2231, #2901, #2561, #2944 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DBMo8gnFduzgpuSHd5Eje
1 parent 6f29aa5 commit befe75f

4 files changed

Lines changed: 336 additions & 70 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
'@object-ui/types': major
3+
---
4+
5+
**The action sub-vocabularies derive from `@objectstack/spec` instead of restating it (framework#4074).**
6+
7+
`packages/types/src/ui-action.ts` imported exactly one of the spec's action
8+
vocabularies — `ActionType`, derived in #2231/#2901 — and hand-declared the rest
9+
under doc comments claiming spec canonicity. `ActionLocation`'s comment said
10+
"Single source of truth lives in `@objectstack/spec/ui`**re-export** here"
11+
while the code re-*declared* a parallel union, `as const` tuple, and `z.enum`.
12+
13+
That is why framework#3856 predicted a compile error when spec 17 removed
14+
`action.execute` and there wasn't one: nothing in this package was bound to the
15+
spec's `z.infer`, so a key removal upstream produced no signal here.
16+
17+
**Already drifted, not merely drift-prone.** `ActionParamSchema.type` is
18+
`FieldType.optional()` and `FieldType` carries **49** members; the hand-written
19+
`ActionParamFieldType` listed **16**. A spec-valid param typed `lookup`,
20+
`multiselect`, `currency`, `user`, `tags` or `json` failed `tsc` against this
21+
package even though `ActionParamDialog` renders it — the same failure `ActionType`
22+
had before it was derived (missing `form` while `ActionRunner.executeForm`
23+
implemented it).
24+
25+
- `ActionLocation` / `ACTION_LOCATIONS` / `ActionLocationSchema` are now the spec's
26+
own three symbols, re-exported. `ACTION_LOCATIONS` and `ActionLocationSchema`
27+
stay **value** exports, as #2561 decision (a) explicitly keeps them.
28+
- `ActionComponent` is `NonNullable<Action['component']>`. Read off the spec's
29+
resolved `Action` rather than `ActionSchema.shape.component`, because spec
30+
exports `ActionSchema` as a `lazySchema` proxy that does not forward `.shape`.
31+
- `ActionParamFieldType` is the spec's `FieldType` (16 → 49 members), with
32+
`ACTION_PARAM_FIELD_TYPES` as its runtime witness.
33+
- `ActionParam` gains the 13 optional capability fields it could not express —
34+
`visible`, `accept`, `maxSize`, `multiple`, and the lookup-picker group
35+
(`referenceTo`, `displayField`, `idField`, `descriptionField`, `titleFormat`,
36+
`lookupColumns`, `lookupFilters`, `lookupPageSize`, `dependsOn`) — all of which
37+
`@object-ui/core`'s `ActionParamDef` already declares and app-shell's
38+
`paramToField.ts` maps into the shared field renderer (ADR-0059).
39+
40+
**The legacy param spellings are now named, not hidden.** `paramToField.ts` folds
41+
`checkbox``boolean`, `reference``lookup`, `datetime-local``datetime`.
42+
None is a spec `FieldType`, so deriving `ActionParamFieldType` alone would have
43+
made authored metadata a type error. They are declared as
44+
`ObjectUiLocalParamFieldType` / `OBJECTUI_LOCAL_PARAM_FIELD_TYPES` and
45+
`ActionParam.type` accepts `ResolvableParamFieldType` (spec ∪ local) — the same
46+
shape `ObjectUiLocalActionType` / `RunnableActionType` already use for
47+
`navigation`, and for the same reason: a dialect hidden inside a
48+
`Record<string, string>` in another package is invisible to an importer.
49+
50+
**Breaking:** `ActionParamFieldType` widens from 16 members to 49, so an
51+
exhaustive `switch` over a param `type` in a host app stops being exhaustive. The
52+
16 old members are all still valid, so no authored metadata breaks. The added
53+
`ActionParam` fields are optional and additive.
54+
55+
Not included, and still open on framework#4074: `ActionParam`'s `name` / `label` /
56+
`type` stay required where the spec makes them optional, and the
57+
`field` / `objectOverride` field-reference form remains unrepresentable. Both are
58+
breaking in a way that needs its own migration note.

packages/types/src/__tests__/spec-derived-unions.test.ts

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,14 @@
2727
* implements it, so a host app typing against @object-ui/types got an error
2828
* on working code.
2929
*
30-
* The `satisfies` checks below are the real enforcement: they stop compiling if
31-
* the objectui alias stops covering the spec vocabulary. The runtime assertions
32-
* guard against the spec's own enum being emptied or renamed underneath us.
30+
* The `satisfies` checks below document that contract at the type level — but they
31+
* are NOT enforcement today, and the previous version of this comment claiming
32+
* they were "the real enforcement" was wrong (#4074). Every package tsconfig
33+
* excludes test files by glob (see `packages/types/tsconfig.json`) and there is no
34+
* vitest `typecheck` project, so no `tsc` invocation reads this file at all. They would
35+
* bite the day tests are type-checked; until then the RUNTIME assertions are the
36+
* only thing that fails CI, which is why the #4074 cases below are written as
37+
* identity and membership checks.
3338
*
3439
* The last case is the inverse: `navigation` is a name objectui runs that the
3540
* spec does NOT have. It is asserted absent from the spec so that the day the
@@ -41,11 +46,27 @@ import {
4146
ReportType as SpecReportType,
4247
ActionType as SpecActionType,
4348
PageTypeSchema as SpecPageTypeSchema,
49+
ACTION_LOCATIONS as SpecACTION_LOCATIONS,
50+
ActionLocationSchema as SpecActionLocationSchema,
51+
ActionSchema as SpecActionSchema,
4452
} from '@objectstack/spec/ui';
45-
import { OBJECTUI_LOCAL_ACTION_TYPES } from '../ui-action';
53+
import { FieldType as SpecFieldType } from '@objectstack/spec/data';
54+
import {
55+
OBJECTUI_LOCAL_ACTION_TYPES,
56+
OBJECTUI_LOCAL_PARAM_FIELD_TYPES,
57+
ACTION_LOCATIONS,
58+
ActionLocationSchema,
59+
ACTION_PARAM_FIELD_TYPES,
60+
} from '../ui-action';
4661
import type { ChartType } from '../data-display';
4762
import type { ReportType } from '../reports';
48-
import type { ActionType, RunnableActionType } from '../ui-action';
63+
import type {
64+
ActionType,
65+
RunnableActionType,
66+
ActionComponent,
67+
ActionParamFieldType,
68+
ResolvableParamFieldType,
69+
} from '../ui-action';
4970
import type { PageType, PageVisualizationAlias } from '../layout';
5071

5172
/**
@@ -60,8 +81,22 @@ const _pageCovers = null as unknown as 'record' | 'home' | 'app' | 'utility' | '
6081
// The sanctioned local extensions are still part of their unions.
6182
const _vizCovers = null as unknown as PageVisualizationAlias satisfies PageType;
6283
const _runnableCovers = null as unknown as ActionType satisfies RunnableActionType;
84+
// #4074: the action sub-vocabularies that were restated rather than derived.
85+
// `ActionComponent`'s real compile-time enforcement lives in `ui-action.ts`,
86+
// which IS type-checked: `ActionSchema.component?: ActionComponent` stops
87+
// compiling there if the derivation breaks.
88+
const _componentCovers = null as unknown as
89+
| 'action:button'
90+
| 'action:icon'
91+
| 'action:menu'
92+
| 'action:group' satisfies ActionComponent;
93+
// `ActionParamFieldType` is now the spec's `FieldType`, so every spec field type
94+
// must be assignable — the old 16-member subset fails here.
95+
type SpecField = typeof SpecFieldType extends { options: readonly (infer T)[] } ? T : never;
96+
const _paramFieldCovers = null as unknown as SpecField satisfies ActionParamFieldType;
97+
const _resolvableCovers = null as unknown as ActionParamFieldType satisfies ResolvableParamFieldType;
6398
void _chartCovers; void _reportCovers; void _actionCovers; void _pageCovers; void _vizCovers;
64-
void _runnableCovers;
99+
void _runnableCovers; void _componentCovers; void _paramFieldCovers; void _resolvableCovers;
65100

66101
/** Read a spec enum's members, failing loudly if the shape ever changes. */
67102
const optionsOf = (schema: unknown, name: string): string[] => {
@@ -116,6 +151,67 @@ describe('unions derived from a spec vocabulary stay derived (#2944)', () => {
116151
expect(forked.filter((v) => !spec.includes(v))).toEqual([]);
117152
});
118153

154+
it('ACTION_LOCATIONS / ActionLocationSchema ARE the spec\'s, by reference (#4074)', () => {
155+
// The doc comment claimed "re-export" while the code re-declared a parallel
156+
// union + `as const` tuple + `z.enum`. Identity is the only check that tells
157+
// a re-export from a faithful copy — a copy passes any value comparison.
158+
expect(ACTION_LOCATIONS).toBe(SpecACTION_LOCATIONS);
159+
expect(ActionLocationSchema).toBe(SpecActionLocationSchema);
160+
// #2561 decision (a) drops spec/ui's `…Schema` names from this package, but
161+
// these two are explicitly kept — so they must still be defined VALUES, not
162+
// type-erased to undefined.
163+
expect(ActionLocationSchema).toBeDefined();
164+
expect(optionsOf(ActionLocationSchema, 'ActionLocationSchema')).toContain('record_header');
165+
});
166+
167+
it('the spec still accepts every ActionComponent value (#4074)', () => {
168+
// `ActionSchema` is a lazySchema proxy that does not forward `.shape`, so the
169+
// enum is asserted behaviorally through parse — stronger than reading
170+
// `.options` anyway, since it proves the value is accepted end-to-end.
171+
const base = { name: 'act', label: 'Act', type: 'script' as const, target: 'run' };
172+
for (const component of ['action:button', 'action:icon', 'action:menu', 'action:group']) {
173+
const parsed = SpecActionSchema.safeParse({ ...base, component });
174+
expect(parsed.success, `spec rejected component '${component}'`).toBe(true);
175+
}
176+
// Negative control: the whitelist is a whitelist.
177+
expect(SpecActionSchema.safeParse({ ...base, component: 'action:carousel' }).success).toBe(false);
178+
});
179+
180+
it('ACTION_PARAM_FIELD_TYPES IS the spec\'s FieldType list, by reference (#4074)', () => {
181+
// `ActionParamFieldType` is a type alias, so it erases — nothing at runtime
182+
// stops a future edit from restating it as a literal union, which is how the
183+
// 16-member fork got there. This array is the witness: identity fails against
184+
// a hand-listed copy.
185+
expect(ACTION_PARAM_FIELD_TYPES).toBe(SpecFieldType.options);
186+
});
187+
188+
it('ActionParamFieldType covers the spec vocabulary the 16-member fork dropped (#4074)', () => {
189+
const spec = optionsOf(SpecFieldType, 'FieldType');
190+
const forked = [
191+
'text', 'textarea', 'number', 'boolean', 'date', 'datetime', 'time',
192+
'select', 'email', 'phone', 'url', 'password', 'file', 'color', 'slider', 'rating',
193+
];
194+
// Everything the fork had is still real, so no consumer of the old union breaks.
195+
expect(forked.filter((v) => !spec.includes(v))).toEqual([]);
196+
// And it was a strict subset — these are the ones that failed `tsc` while
197+
// `ActionParamDialog` rendered them.
198+
expect(spec.length).toBeGreaterThan(forked.length);
199+
for (const missed of ['lookup', 'multiselect', 'currency', 'user', 'tags', 'json']) {
200+
expect(spec).toContain(missed);
201+
expect(forked).not.toContain(missed);
202+
}
203+
});
204+
205+
it('the param-only type aliases are NOT spec field types (#4074)', () => {
206+
// Same contract as `navigation` above: these are objectui dialect
207+
// (`PARAM_TYPE_ALIASES` in app-shell's `paramToField.ts`), folded onto a
208+
// canonical widget type. If the spec adopts one, this fails and names the
209+
// alias to retire.
210+
const spec = optionsOf(SpecFieldType, 'FieldType');
211+
expect([...OBJECTUI_LOCAL_PARAM_FIELD_TYPES]).toEqual(['checkbox', 'reference', 'datetime-local']);
212+
expect(OBJECTUI_LOCAL_PARAM_FIELD_TYPES.filter((v) => spec.includes(v))).toEqual([]);
213+
});
214+
119215
it('the page visualization names are NOT spec page types', () => {
120216
// `ui/page.zod.ts` says so explicitly; they survive only as the sanctioned
121217
// local extension in `layout.ts`. If the spec ever adopts one, drop it there.

packages/types/src/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -870,6 +870,9 @@ export type {
870870
ActionType,
871871
ObjectUiLocalActionType,
872872
RunnableActionType,
873+
ActionParamFieldType,
874+
ObjectUiLocalParamFieldType,
875+
ResolvableParamFieldType,
873876
ActionParam,
874877
ActionSchema as UIActionSchema,
875878
ActionGroup,
@@ -886,7 +889,13 @@ export type {
886889
UndoRedoState,
887890
} from './ui-action';
888891

889-
export { ACTION_LOCATIONS, ActionLocationSchema, OBJECTUI_LOCAL_ACTION_TYPES } from './ui-action';
892+
export {
893+
ACTION_LOCATIONS,
894+
ActionLocationSchema,
895+
OBJECTUI_LOCAL_ACTION_TYPES,
896+
OBJECTUI_LOCAL_PARAM_FIELD_TYPES,
897+
ACTION_PARAM_FIELD_TYPES,
898+
} from './ui-action';
890899

891900
// ============================================================================
892901
// ObjectStack Protocol Namespaces - Protocol Re-exports

0 commit comments

Comments
 (0)