Skip to content

Commit d147a13

Browse files
os-zhuangclaude
andauthored
refactor(types): retire the hand-written @objectstack/spec/ui sub-schema mirrors (#2231 phase 2) (#2882)
The zod schemas headed "Mirrors @objectstack/spec/ui X" are now the spec's schemas by reference, closing the double-maintenance / silent-divergence gap the same way #2622 did for ListViewSchema: - objectql.zod.ts: HttpMethodSchema, HttpRequestSchema, ViewDataSchema, SelectionConfigSchema, PaginationConfigSchema are direct re-exports; ListColumnSchema derives from the spec base + the two sanctioned local extensions (prefix, broadened summary incl. the { type, field } object form useColumnSummary supports). - theme.zod.ts: ColorPalette/Typography/Spacing/BorderRadius/Shadow/ Breakpoints/Animation/ZIndex/ThemeMode/ThemeLogo/ThemeDefinition all resolve to the spec's schemas. Drift the mirrors had accumulated, now picked up from the spec: ViewDataSchema gains the provider:'schema' variant; HttpRequest.method, Selection.type and Pagination.pageSize apply spec defaults on parse; ListColumn.summary accepts the full spec aggregation vocabulary but no longer free strings; Animation.timing keys are the spec's snake_case (what usePageTransition reads); ThemeDefinition gains the spec's density/wcagContrast/rtl/touchTarget/keyboardNavigation and its mode default follows the spec ('light'). A new drift-guard (spec-subschema-parity.test.ts) asserts reference identity for every re-export — a faithful copy fails too: a copy is a fork. Verified: types tsc 0 errors; types unit tests 14 files / 212 tests green; consumer unit tests (app-shell view-schema, plugin-grid, plugin-list, core) 65 files / 1155 tests green; changed files lint-clean. Closes nothing — #2231 stays open for the legacy-vocabulary migration and the per-view-type config unification (kanban/calendar/gantt/gallery/ timeline), audited and documented in the PR. Claude-Session: https://claude.ai/code/session_01V3EDGLKNvbcpDTLzyiAd3Q Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9b53d72 commit d147a13

5 files changed

Lines changed: 304 additions & 225 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@object-ui/types": patch
3+
---
4+
5+
refactor(types): retire the hand-written @objectstack/spec/ui sub-schema mirrors (#2231 phase 2)
6+
7+
The zod schemas that carried a "Mirrors @objectstack/spec/ui X" header are now the
8+
spec's schemas **by reference** instead of hand-maintained copies, closing the
9+
double-maintenance / silent-divergence gap the same way #2622 did for `ListViewSchema`:
10+
11+
- `objectql.zod.ts``HttpMethodSchema`, `HttpRequestSchema`, `ViewDataSchema`,
12+
`SelectionConfigSchema`, `PaginationConfigSchema` are direct re-exports.
13+
`ListColumnSchema` derives from the spec base plus the two sanctioned
14+
objectui-only extensions: `prefix` (ObjectGrid compound cells) and a broadened
15+
`summary` (the spec `ColumnSummarySchema` enum ∪ the `{ type, field }` object
16+
form `useColumnSummary` supports).
17+
- `theme.zod.ts``ColorPaletteSchema`, `TypographySchema`, `SpacingSchema`,
18+
`BorderRadiusSchema`, `ShadowSchema`, `BreakpointsSchema`, `AnimationSchema`,
19+
`ZIndexSchema`, `ThemeModeSchema`, `ThemeLogoSchema`, `ThemeDefinitionSchema`
20+
all resolve to the spec's schemas.
21+
22+
Validation deltas picked up from the spec (drift the mirrors had accumulated):
23+
`ViewDataSchema` gains the `provider: 'schema'` variant; `HttpRequestSchema.method`,
24+
`SelectionConfigSchema.type` and `PaginationConfigSchema.pageSize` now apply spec
25+
defaults on parse; `ListColumnSchema.summary` accepts the full spec aggregation
26+
vocabulary but no longer accepts arbitrary strings; `AnimationSchema.timing` keys are
27+
the spec's snake_case (`ease_in` — what the runtime reads) instead of the mirror's
28+
camelCase; `ThemeDefinitionSchema` gains `density`/`wcagContrast`/`rtl`/`touchTarget`/
29+
`keyboardNavigation` and its `mode` default follows the spec (`'light'`).
30+
31+
A new drift-guard (`spec-subschema-parity.test.ts`) asserts reference identity for
32+
every re-export, so re-forking — including a faithful copy — fails CI.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Sub-schema ↔ @objectstack/spec drift guard (issue #2231, phase 2).
11+
*
12+
* The former hand-written mirrors in `zod/objectql.zod.ts` and `zod/theme.zod.ts`
13+
* are now the spec's schemas **by reference** — re-forking one (replacing the
14+
* re-export with a local copy that can drift) is exactly the failure mode that
15+
* produced the original ListViewSchema divergence. These tests pin:
16+
*
17+
* 1. Reference identity for every direct re-export. `toBe` — not structural
18+
* equality — so a "faithful copy" fails too: a copy is a fork.
19+
* 2. The one *derived* schema (`ListColumnSchema`): every spec field flows in,
20+
* the local-extension set is exactly the sanctioned one, and the `summary`
21+
* broadening keeps the spec enum as its first (by-reference) union arm.
22+
*
23+
* When one of these fails, do NOT fork the schema back to make it green — the
24+
* schema belongs upstream in `@objectstack/spec`. Extend locally via `.extend()`
25+
* on the spec base (and sanction the field here) only for genuinely
26+
* objectui-only renderer concerns. See #2231.
27+
*/
28+
import { describe, it, expect } from 'vitest';
29+
import {
30+
HttpMethodSchema as SpecHttpMethodSchema,
31+
HttpRequestSchema as SpecHttpRequestSchema,
32+
ViewDataSchema as SpecViewDataSchema,
33+
ListColumnSchema as SpecListColumnSchema,
34+
ColumnSummarySchema as SpecColumnSummarySchema,
35+
SelectionConfigSchema as SpecSelectionConfigSchema,
36+
PaginationConfigSchema as SpecPaginationConfigSchema,
37+
ColorPaletteSchema as SpecColorPaletteSchema,
38+
TypographySchema as SpecTypographySchema,
39+
SpacingSchema as SpecSpacingSchema,
40+
BorderRadiusSchema as SpecBorderRadiusSchema,
41+
ShadowSchema as SpecShadowSchema,
42+
BreakpointsSchema as SpecBreakpointsSchema,
43+
AnimationSchema as SpecAnimationSchema,
44+
ZIndexSchema as SpecZIndexSchema,
45+
ThemeModeSchema as SpecThemeModeSchema,
46+
ThemeSchema as SpecThemeSchema,
47+
} from '@objectstack/spec/ui';
48+
import {
49+
HttpMethodSchema,
50+
HttpRequestSchema,
51+
ViewDataSchema,
52+
ListColumnSchema,
53+
SelectionConfigSchema,
54+
PaginationConfigSchema,
55+
} from '../zod/objectql.zod.js';
56+
import {
57+
ColorPaletteSchema,
58+
TypographySchema,
59+
SpacingSchema,
60+
BorderRadiusSchema,
61+
ShadowSchema,
62+
BreakpointsSchema,
63+
AnimationSchema,
64+
ZIndexSchema,
65+
ThemeModeSchema,
66+
ThemeLogoSchema,
67+
ThemeDefinitionSchema,
68+
} from '../zod/theme.zod.js';
69+
70+
describe('spec sub-schema re-exports are the spec objects (by reference)', () => {
71+
const pairs: Array<[string, unknown, unknown]> = [
72+
['HttpMethodSchema', HttpMethodSchema, SpecHttpMethodSchema],
73+
['HttpRequestSchema', HttpRequestSchema, SpecHttpRequestSchema],
74+
['ViewDataSchema', ViewDataSchema, SpecViewDataSchema],
75+
['SelectionConfigSchema', SelectionConfigSchema, SpecSelectionConfigSchema],
76+
['PaginationConfigSchema', PaginationConfigSchema, SpecPaginationConfigSchema],
77+
['ColorPaletteSchema', ColorPaletteSchema, SpecColorPaletteSchema],
78+
['TypographySchema', TypographySchema, SpecTypographySchema],
79+
['SpacingSchema', SpacingSchema, SpecSpacingSchema],
80+
['BorderRadiusSchema', BorderRadiusSchema, SpecBorderRadiusSchema],
81+
['ShadowSchema', ShadowSchema, SpecShadowSchema],
82+
['BreakpointsSchema', BreakpointsSchema, SpecBreakpointsSchema],
83+
['AnimationSchema', AnimationSchema, SpecAnimationSchema],
84+
['ZIndexSchema', ZIndexSchema, SpecZIndexSchema],
85+
['ThemeModeSchema', ThemeModeSchema, SpecThemeModeSchema],
86+
['ThemeDefinitionSchema', ThemeDefinitionSchema, SpecThemeSchema],
87+
];
88+
89+
it.each(pairs.map(([name]) => [name] as const))('%s', (name) => {
90+
const [, oui, spec] = pairs.find(([n]) => n === name)!;
91+
expect(oui, `${name} must be the spec schema itself, not a copy`).toBe(spec);
92+
});
93+
});
94+
95+
describe('ThemeLogoSchema is the spec Theme logo shape (unwrapped by reference)', () => {
96+
it('accepts and round-trips the spec logo keys', () => {
97+
const logo = { light: '/l.svg', dark: '/d.svg', favicon: '/f.ico' };
98+
expect(ThemeLogoSchema.parse(logo)).toEqual(logo);
99+
});
100+
101+
it('has exactly the spec inline-logo key set', () => {
102+
const specLogoShape = (SpecThemeSchema.shape.logo.unwrap() as { shape: Record<string, unknown> }).shape;
103+
const ouiLogoShape = (ThemeLogoSchema as unknown as { shape: Record<string, unknown> }).shape;
104+
expect(Object.keys(ouiLogoShape).sort()).toEqual(Object.keys(specLogoShape).sort());
105+
});
106+
});
107+
108+
describe('ListColumnSchema derives from the spec (extend, not fork)', () => {
109+
const specShape = (SpecListColumnSchema as unknown as { shape: Record<string, unknown> }).shape;
110+
const ouiShape = (ListColumnSchema as unknown as { shape: Record<string, unknown> }).shape;
111+
112+
/**
113+
* objectui-only ListColumn fields — sanctioned local extensions. Each should be
114+
* promoted into `@objectstack/spec` rather than grown here.
115+
*/
116+
const SANCTIONED_LOCAL = new Set<string>([
117+
// Airtable-style compound-cell prefix — read by ObjectGrid's cell renderer.
118+
'prefix',
119+
]);
120+
// `summary` is intentionally overridden (spec enum ∪ { type, field } object form
121+
// supported by plugin-grid's useColumnSummary) — asserted separately below.
122+
const OVERRIDDEN = new Set<string>(['summary']);
123+
124+
it('every spec field is present (spec fields flow in by reference)', () => {
125+
const missing = Object.keys(specShape).filter((k) => !(k in ouiShape));
126+
expect(missing, 'spec ListColumn fields missing from objectui — the derivation dropped them').toEqual([]);
127+
});
128+
129+
it('spec fields are the spec schemas by reference (except sanctioned overrides)', () => {
130+
for (const k of Object.keys(specShape)) {
131+
if (OVERRIDDEN.has(k)) continue;
132+
expect(ouiShape[k], `ListColumnSchema.${k} must flow in from the spec by reference`).toBe(specShape[k]);
133+
}
134+
});
135+
136+
it('objectui-only fields are exactly the sanctioned set', () => {
137+
const localOnly = Object.keys(ouiShape).filter((k) => !(k in specShape));
138+
expect(localOnly.sort()).toEqual([...SANCTIONED_LOCAL].sort());
139+
});
140+
141+
it('summary keeps the spec enum as its by-reference base arm', () => {
142+
// ZodOptional<ZodUnion<[SpecColumnSummarySchema, ZodObject]>>
143+
const summary = ouiShape.summary as { unwrap(): { def: { options: unknown[] } } };
144+
const arms = summary.unwrap().def.options;
145+
expect(arms[0], 'summary union arm 0 must be the spec ColumnSummarySchema by reference').toBe(
146+
SpecColumnSummarySchema,
147+
);
148+
});
149+
150+
it('summary accepts every spec aggregation and the renderer object form', () => {
151+
for (const agg of ['none', 'count', 'count_unique', 'percent_filled', 'sum', 'avg', 'min', 'max']) {
152+
expect(ListColumnSchema.shape.summary.safeParse(agg).success, `summary "${agg}"`).toBe(true);
153+
}
154+
expect(ListColumnSchema.shape.summary.safeParse({ type: 'sum', field: 'amount' }).success).toBe(true);
155+
// The old mirror's free-string arm is gone — unknown aggregations fail loudly.
156+
expect(ListColumnSchema.shape.summary.safeParse('median').success).toBe(false);
157+
});
158+
});

packages/types/src/zod/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,27 @@ is `z.infer<typeof ListViewSchema> & ListViewRuntimeProps`. A drift-guard test
2525
(`__tests__/list-view-spec-parity.test.ts`) fails if the spec grows a field objectui
2626
hasn't triaged. **Do not hand-add spec-owned fields here** — import them from the spec.
2727

28+
### Spec sub-schemas are re-exported by reference (not mirrored) — #2231
29+
30+
The schemas that used to be hand-written "mirrors" of `@objectstack/spec/ui` are now the
31+
spec's schemas **by reference**, so they cannot drift:
32+
33+
- `objectql.zod.ts`: `HttpMethodSchema`, `HttpRequestSchema`, `ViewDataSchema`,
34+
`SelectionConfigSchema`, `PaginationConfigSchema` are direct re-exports;
35+
`ListColumnSchema` is the spec base plus two sanctioned objectui-only extensions
36+
(`prefix`, and a broadened `summary` that also accepts the `{ type, field }` object
37+
form the grid renderer supports).
38+
- `theme.zod.ts`: `ColorPaletteSchema`, `TypographySchema`, `SpacingSchema`,
39+
`BorderRadiusSchema`, `ShadowSchema`, `BreakpointsSchema`, `AnimationSchema`,
40+
`ZIndexSchema`, `ThemeModeSchema`, `ThemeLogoSchema`, `ThemeDefinitionSchema` all
41+
resolve to the spec's schemas.
42+
43+
A drift-guard test (`__tests__/spec-subschema-parity.test.ts`) asserts reference
44+
identity — a faithful copy fails it too, because a copy is a fork. **Do not re-fork
45+
these**: fix or extend the schema upstream in `@objectstack/spec`, or (for genuinely
46+
objectui-only renderer concerns) extend locally via `.extend()` on the spec base and
47+
sanction the field in the parity test.
48+
2849
## Installation
2950

3051
The Zod schemas are included in the `@object-ui/types` package:

packages/types/src/zod/objectql.zod.ts

Lines changed: 42 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -17,66 +17,54 @@
1717
*/
1818

1919
import { z } from 'zod';
20-
import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui';
20+
import {
21+
ListViewSchema as SpecListViewSchema,
22+
HttpMethodSchema as SpecHttpMethodSchema,
23+
HttpRequestSchema as SpecHttpRequestSchema,
24+
ViewDataSchema as SpecViewDataSchema,
25+
ListColumnSchema as SpecListColumnSchema,
26+
ColumnSummarySchema as SpecColumnSummarySchema,
27+
SelectionConfigSchema as SpecSelectionConfigSchema,
28+
PaginationConfigSchema as SpecPaginationConfigSchema,
29+
} from '@objectstack/spec/ui';
2130
import { BaseSchema } from './base.zod.js';
2231

2332
/**
24-
* HTTP Method Schema
25-
* Mirrors @objectstack/spec/ui HttpMethodSchema.
33+
* HTTP Method Schema — `@objectstack/spec/ui` schema re-exported by reference
34+
* (issue #2231; formerly a hand-written mirror).
2635
*/
27-
export const HttpMethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
36+
export const HttpMethodSchema = SpecHttpMethodSchema;
2837

2938
/**
30-
* HTTP Request Schema
31-
* Mirrors @objectstack/spec/ui HttpRequestSchema.
39+
* HTTP Request Schema — `@objectstack/spec/ui` schema re-exported by reference
40+
* (issue #2231; formerly a hand-written mirror). Differences vs the old mirror:
41+
* `body` is the spec's `z.unknown()` (a superset of the old record/string/FormData/
42+
* Blob union) and `method` now defaults to `'GET'` on parse.
3243
*/
33-
export const HttpRequestSchema = z.object({
34-
url: z.string().describe('API endpoint URL'),
35-
method: HttpMethodSchema.optional().describe('HTTP method'),
36-
headers: z.record(z.string(), z.string()).optional().describe('Custom HTTP headers'),
37-
params: z.record(z.string(), z.unknown()).optional().describe('Query parameters'),
38-
body: z.union([z.record(z.string(), z.unknown()), z.string(), z.instanceof(FormData), z.instanceof(Blob)]).optional().describe('Request body'),
39-
});
44+
export const HttpRequestSchema = SpecHttpRequestSchema;
4045

4146
/**
42-
* View Data Source Schema
43-
* Mirrors @objectstack/spec/ui ViewDataSchema.
47+
* View Data Source Schema — `@objectstack/spec/ui` schema re-exported by reference
48+
* (issue #2231; formerly a hand-written mirror that had drifted behind the spec's
49+
* fourth `provider: 'schema'` variant for schema-bound forms).
4450
*/
45-
export const ViewDataSchema = z.union([
46-
z.object({
47-
provider: z.literal('object'),
48-
object: z.string().describe('Target object name'),
49-
}),
50-
z.object({
51-
provider: z.literal('api'),
52-
read: HttpRequestSchema.optional().describe('Read configuration'),
53-
write: HttpRequestSchema.optional().describe('Write configuration'),
54-
}),
55-
z.object({
56-
provider: z.literal('value'),
57-
items: z.array(z.unknown()).describe('Static data array'),
58-
}),
59-
]);
51+
export const ViewDataSchema = SpecViewDataSchema;
6052

6153
/**
62-
* List Column Schema
63-
* Mirrors @objectstack/spec/ui ListColumnSchema.
54+
* List Column Schema — derived from `@objectstack/spec/ui` `ListColumnSchema`
55+
* (issue #2231): spec fields flow in by reference; objectui-only extensions are
56+
* declared locally on top via `.extend()`.
57+
* - `summary` is broadened: the spec's `ColumnSummarySchema` enum plus the
58+
* `{ type, field }` object form the grid renderer supports (per-column field
59+
* override — see `useColumnSummary` in `@object-ui/plugin-grid`). The old
60+
* mirror's free-string arm is gone: unknown aggregation names now fail
61+
* validation instead of silently rendering nothing.
62+
* - `prefix` is objectui-only compound-cell rendering (read by `ObjectGrid`);
63+
* promote it into the spec rather than growing this extension.
6464
*/
65-
export const ListColumnSchema = z.object({
66-
field: z.string().describe('Field name'),
67-
label: z.string().optional().describe('Display label'),
68-
width: z.number().optional().describe('Column width'),
69-
align: z.enum(['left', 'center', 'right']).optional().describe('Text alignment'),
70-
hidden: z.boolean().optional().describe('Hide column by default'),
71-
sortable: z.boolean().optional().describe('Allow sorting'),
72-
resizable: z.boolean().optional().describe('Allow resizing'),
73-
wrap: z.boolean().optional().describe('Allow text wrapping'),
74-
type: z.string().optional().describe('Renderer type override'),
75-
link: z.boolean().optional().describe('Functions as the primary navigation link (triggers View navigation)'),
76-
action: z.string().optional().describe('Registered Action ID to execute when clicked'),
77-
pinned: z.enum(['left', 'right']).optional().describe('Pin column to left or right edge'),
65+
export const ListColumnSchema = SpecListColumnSchema.extend({
7866
summary: z.union([
79-
z.string(),
67+
SpecColumnSummarySchema,
8068
z.object({
8169
type: z.enum(['count', 'sum', 'avg', 'min', 'max']).describe('Aggregation type'),
8270
field: z.string().optional().describe('Field to aggregate (defaults to column field)'),
@@ -89,21 +77,18 @@ export const ListColumnSchema = z.object({
8977
});
9078

9179
/**
92-
* Selection Config Schema
93-
* Mirrors @objectstack/spec/ui SelectionConfigSchema.
80+
* Selection Config Schema — `@objectstack/spec/ui` schema re-exported by reference
81+
* (issue #2231; formerly a hand-written mirror). `type` now defaults to `'none'`
82+
* on parse instead of staying undefined.
9483
*/
95-
export const SelectionConfigSchema = z.object({
96-
type: z.enum(['none', 'single', 'multiple']).optional().describe('Selection mode'),
97-
});
84+
export const SelectionConfigSchema = SpecSelectionConfigSchema;
9885

9986
/**
100-
* Pagination Config Schema
101-
* Mirrors @objectstack/spec/ui PaginationConfigSchema.
87+
* Pagination Config Schema — `@objectstack/spec/ui` schema re-exported by reference
88+
* (issue #2231; formerly a hand-written mirror). `pageSize` is now the spec's
89+
* positive-int with a default of 25 on parse.
10290
*/
103-
export const PaginationConfigSchema = z.object({
104-
pageSize: z.number().optional().describe('Page size'),
105-
pageSizeOptions: z.array(z.number()).optional().describe('Page size options'),
106-
});
91+
export const PaginationConfigSchema = SpecPaginationConfigSchema;
10792

10893
/**
10994
* Sort Config Schema

0 commit comments

Comments
 (0)