Skip to content

Commit b755269

Browse files
committed
fix(spec): bound the object-grid arm's page sizes to the accept set the view arm rules
The `object-grid` props door declared `pagination: z.unknown()` and `pageSize: z.number()`, so the same authored member carried two accept sets and renderers read the looser one: `PaginationConfigSchema` refuses `pageSize: 0` and pins that refusal by name, while this door receipted it `success: true`. objectui#9853 measured an authored `pagination.pageSize: 0` reaching `ObjectGrid`, going out as `$top: 0` and rendering zero rows, through this arm. `pagination` becomes a `z.looseObject` that bounds `pageSize` and `pageSizeOptions` to positive integers and passes every other key through unvalidated — the bag stays open on purpose, because closing it would refuse sibling keys this door has accepted since it was written, which is a wider narrowing than the measured defect. Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh Co-authored-by: Claude <noreply@anthropic.com>
1 parent 362035c commit b755269

2 files changed

Lines changed: 252 additions & 3 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19046] The `object-grid` arm and the view arm must agree on what a PAGE
5+
* SIZE is — and must keep disagreeing about whether the bag is closed.
6+
*
7+
* ## The defect this file closes
8+
*
9+
* Two arms of this package declared the same authoring member with different
10+
* accept sets, and renderers read the looser one:
11+
*
12+
* | arm | declaration before #19046 | accepted `pageSize: 0`? |
13+
* |:--|:--|:--|
14+
* | view — `PaginationConfigSchema` (`view.zod.ts`) | `z.number().int().positive().default(25)` | no |
15+
* | grid component — `ComponentPropsMap['object-grid']` | `pagination: z.unknown()`, `pageSize: z.number()` | YES, both |
16+
*
17+
* The view arm pins its refusals BY NAME ('should reject negative pageSize' /
18+
* 'should reject zero pageSize', `view.test.ts`), and every other `pageSize`
19+
* this package declares is bounded with its own throwing pin
20+
* (`kernel/metadata-plugin.zod.ts`, `marketplace/marketplace.zod.ts`) — so the
21+
* component arm was the outlier, not the norm. It was not theoretical:
22+
* objectui#9853 measured an authored `pagination.pageSize: 0` reaching
23+
* `ObjectGrid`, going out on the wire as `$top: 0` and rendering ZERO ROWS,
24+
* through this arm. objectui#9896 repaired the consumer half; this is the
25+
* declaration half.
26+
*
27+
* ## The two halves of this pin, and why the second one is not optional
28+
*
29+
* 1. **The page-size accept sets are now one set.** §1 and §2 assert the
30+
* refusals by name on the component arm, each beside a LIT CONTROL that a
31+
* legal value still parses — a refusal pin with no lit control passes just
32+
* as well when the door has stopped accepting anything at all.
33+
* 2. **The bag is still OPEN.** §3 asserts a sibling key inside `pagination`
34+
* still parses and survives byte-identically. The honest fix for §1 is a
35+
* bound on two members; closing the bag would refuse every sibling key this
36+
* door has accepted since it was written — the `…` in its own describe says
37+
* authors write them — which is a WIDER narrowing than the measured defect
38+
* and a different decision. Without §3 that widening lands silently, since
39+
* every §1 assertion passes under it too.
40+
*
41+
* §4 states the cross-arm agreement and the cross-arm asymmetry as one table:
42+
* on a page-size VALUE the two arms answer identically; on an unknown KEY they
43+
* deliberately answer differently (`PaginationConfigSchema` is a `strictObject`
44+
* — `view-union-retirement-prescription.test.ts` §3 pins its `unrecognized_keys`
45+
* — and the component bag is a `z.looseObject`). A future author harmonising
46+
* the two arms 'for consistency' reds §3 and §4 rather than discovering the
47+
* consequence in a renderer.
48+
*/
49+
50+
import { describe, it, expect } from 'vitest';
51+
import type { z } from 'zod';
52+
53+
import { ComponentPropsMap } from './component.zod';
54+
import { PaginationConfigSchema } from './view.zod';
55+
56+
const grid = () => ComponentPropsMap['object-grid'];
57+
58+
/** The issue codes and paths a refusal carries, so a refusal for the WRONG reason reds. */
59+
function issues(result: z.ZodSafeParseResult<unknown>): { code: string; path: string }[] {
60+
if (result.success) return [];
61+
return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') }));
62+
}
63+
64+
/** Every value that is not a page size, with the issue code each one must raise. */
65+
const NOT_A_PAGE_SIZE: ReadonlyArray<readonly [label: string, value: unknown, code: string]> = [
66+
['zero', 0, 'too_small'],
67+
['negative', -10, 'too_small'],
68+
['non-integer', 25.5, 'invalid_type'],
69+
];
70+
71+
// ───────────────────────────────────────────────────────────────────────────
72+
// §1 `pagination.pageSize` — the member the measured defect came through.
73+
// ───────────────────────────────────────────────────────────────────────────
74+
75+
describe('§1 object-grid `pagination.pageSize` refuses what the view arm refuses', () => {
76+
it('should reject zero pageSize', () => {
77+
const r = grid().safeParse({ pagination: { pageSize: 0 } });
78+
expect(r.success).toBe(false);
79+
expect(issues(r)).toContainEqual({ code: 'too_small', path: 'pagination.pageSize' });
80+
});
81+
82+
it('should reject negative pageSize', () => {
83+
const r = grid().safeParse({ pagination: { pageSize: -10 } });
84+
expect(r.success).toBe(false);
85+
expect(issues(r)).toContainEqual({ code: 'too_small', path: 'pagination.pageSize' });
86+
});
87+
88+
it('should reject non-integer pageSize', () => {
89+
const r = grid().safeParse({ pagination: { pageSize: 25.5 } });
90+
expect(r.success).toBe(false);
91+
expect(issues(r)).toContainEqual({ code: 'invalid_type', path: 'pagination.pageSize' });
92+
});
93+
94+
it('LIT CONTROL — a legal pageSize still parses and is preserved', () => {
95+
const r = grid().safeParse({ objectName: 'showcase_task', pagination: { pageSize: 50 } });
96+
expect(issues(r)).toEqual([]);
97+
expect(r.success && r.data.pagination).toStrictEqual({ pageSize: 50 });
98+
});
99+
100+
it('should reject zero values in pageSizeOptions', () => {
101+
const r = grid().safeParse({ pagination: { pageSize: 25, pageSizeOptions: [10, 0, 50] } });
102+
expect(r.success).toBe(false);
103+
expect(issues(r)).toContainEqual({ code: 'too_small', path: 'pagination.pageSizeOptions.1' });
104+
});
105+
106+
it('should reject negative values in pageSizeOptions', () => {
107+
const r = grid().safeParse({ pagination: { pageSize: 25, pageSizeOptions: [10, -25, 50] } });
108+
expect(r.success).toBe(false);
109+
expect(issues(r)).toContainEqual({ code: 'too_small', path: 'pagination.pageSizeOptions.1' });
110+
});
111+
112+
it('LIT CONTROL — the whole ruled bag parses, options included', () => {
113+
const bag = { pageSize: 50, pageSizeOptions: [25, 50, 100] };
114+
const r = grid().safeParse({ pagination: bag });
115+
expect(issues(r)).toEqual([]);
116+
expect(r.success && r.data.pagination).toStrictEqual(bag);
117+
});
118+
});
119+
120+
// ───────────────────────────────────────────────────────────────────────────
121+
// §2 the FLAT shorthand — the second door onto the same renderer read.
122+
// ───────────────────────────────────────────────────────────────────────────
123+
124+
describe('§2 object-grid flat `pageSize` shorthand carries the same accept set', () => {
125+
for (const [label, value, code] of NOT_A_PAGE_SIZE) {
126+
it(`should reject ${label} pageSize on the flat shorthand`, () => {
127+
const r = grid().safeParse({ pageSize: value });
128+
expect(r.success).toBe(false);
129+
expect(issues(r)).toContainEqual({ code, path: 'pageSize' });
130+
});
131+
}
132+
133+
it('LIT CONTROL — the flat shorthand still parses and keeps its value', () => {
134+
const r = grid().safeParse({ objectName: 'showcase_task', pageSize: 25, showPagination: true });
135+
expect(issues(r)).toEqual([]);
136+
expect(r.success && r.data.pageSize).toBe(25);
137+
});
138+
});
139+
140+
// ───────────────────────────────────────────────────────────────────────────
141+
// §3 THE OPENNESS PIN — what #19046 deliberately did NOT narrow.
142+
// ───────────────────────────────────────────────────────────────────────────
143+
144+
describe('§3 the `pagination` bag stays open — the narrowing did not reach sibling keys', () => {
145+
it('a sibling key inside the bag parses, with no `unrecognized_keys` issue', () => {
146+
const bag = { pageSize: 25, position: 'bottom' };
147+
const r = grid().safeParse({ pagination: bag });
148+
expect(issues(r)).toEqual([]);
149+
expect(r.success).toBe(true);
150+
});
151+
152+
it('and it survives the parse byte-identically — passed through, not stripped', () => {
153+
const bag = { pageSize: 25, position: 'bottom', mode: { server: true } };
154+
const r = grid().safeParse({ pagination: bag });
155+
expect(r.success && r.data.pagination).toStrictEqual(bag);
156+
});
157+
158+
it('a bag carrying ONLY sibling keys parses — no page-size member is required', () => {
159+
const bag = { position: 'bottom' };
160+
const r = grid().safeParse({ pagination: bag });
161+
expect(issues(r)).toEqual([]);
162+
expect(r.success && r.data.pagination).toStrictEqual(bag);
163+
});
164+
});
165+
166+
// ───────────────────────────────────────────────────────────────────────────
167+
// §4 CROSS-ARM — one accept set for the VALUE, two verdicts for a KEY.
168+
// ───────────────────────────────────────────────────────────────────────────
169+
170+
describe('§4 the two arms agree on a page size and disagree on openness, both on purpose', () => {
171+
for (const [label, value] of NOT_A_PAGE_SIZE) {
172+
it(`both arms refuse a ${label} pageSize — the disagreement #19046 closes`, () => {
173+
expect(PaginationConfigSchema.safeParse({ pageSize: value }).success, 'view arm').toBe(false);
174+
expect(grid().safeParse({ pagination: { pageSize: value } }).success, 'component arm').toBe(false);
175+
});
176+
}
177+
178+
it('LIT CONTROL — both arms accept the same legal page size', () => {
179+
expect(PaginationConfigSchema.safeParse({ pageSize: 50 }).success, 'view arm').toBe(true);
180+
expect(grid().safeParse({ pagination: { pageSize: 50 } }).success, 'component arm').toBe(true);
181+
});
182+
183+
it('an unknown KEY is refused by the view arm and accepted by the component bag', () => {
184+
const bag = { pageSize: 25, position: 'bottom' };
185+
const view = PaginationConfigSchema.safeParse(bag);
186+
expect(view.success, 'the view arm is a strictObject and stays closed').toBe(false);
187+
expect(issues(view).map((i) => i.code)).toContain('unrecognized_keys');
188+
expect(grid().safeParse({ pagination: bag }).success, 'the component bag stays open').toBe(true);
189+
});
190+
});

‎packages/spec/src/ui/component.zod.ts‎

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2501,6 +2501,21 @@ const objectBlockHistory = (type: string) =>
25012501
*/
25022502
const FILTERS_TO_FILTER = { filters: 'filter' } as const;
25032503

2504+
/**
2505+
* A page size — a positive integer, and nothing else.
2506+
*
2507+
* ONE spelling for a rule the rest of this package already carries, so the
2508+
* component arm cannot drift from it again: `PaginationConfigSchema`
2509+
* (`view.zod.ts`) declares `pageSize: z.number().int().positive()` and
2510+
* `pageSizeOptions: z.array(z.number().int().positive())`; `MetadataQuery`
2511+
* (`kernel/metadata-plugin.zod.ts`) and the two marketplace request schemas
2512+
* (`marketplace/marketplace.zod.ts`) say `z.number().int().min(1)`. Each of
2513+
* those pins its own refusal of `0` by name. Until #19046 the `object-grid`
2514+
* door below said `z.number()` and `z.unknown()`, and was the only
2515+
* page-size declaration in the package that accepted `0`.
2516+
*/
2517+
const GridPageSizeSchema = z.number().int().positive();
2518+
25042519
/**
25052520
* `object-grid` (objectui `plugin-grid/src/ObjectGrid.tsx` @ `eb7f586b`).
25062521
* Read points per key: `objectName` (throughout), `columns`/`fields` (:714-715),
@@ -2629,9 +2644,53 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({
26292644
+ 'becomes `sort: [{ field, order }]`); the pair itself is unchanged. '
26302645
+ 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.',
26312646
),
2632-
pagination: z.unknown().optional()
2633-
.describe('Pagination config ({ pageSize, pageSizeOptions, … }); its presence enables paging'),
2634-
pageSize: z.number().optional().describe('Flat page-size shorthand; `pagination.pageSize` wins when both are set'),
2647+
/**
2648+
* Pagination config — the two members whose value is a PAGE SIZE bounded to
2649+
* {@link GridPageSizeSchema}, the accept set the view arm has ruled all
2650+
* along, and the bag itself left OPEN.
2651+
*
2652+
* The `z.unknown()` this door carried until #19046 was a read-point record
2653+
* of the same #7751 vintage as its `filter` and `sort` neighbours above, and
2654+
* it made the SAME authored member carry two accept sets, of which renderers
2655+
* read the looser: `PaginationConfigSchema` refuses `pageSize: 0` and pins
2656+
* that refusal by name ('should reject zero pageSize' / 'should reject zero
2657+
* values in pageSizeOptions', `view.test.ts`), while this door receipted it
2658+
* `success: true`. Measured at objectui#9853: an authored
2659+
* `pagination.pageSize: 0` reached `ObjectGrid`, went out on the wire as
2660+
* `$top: 0` and rendered ZERO ROWS, with no grouping needed to trigger it,
2661+
* and it reached the renderer through THIS arm — the view arm would have
2662+
* refused it. objectui#9896 repaired the consumer half (a resolver at every
2663+
* read point); this is the declaration half.
2664+
*
2665+
* **`z.looseObject`, not `strictObject` — the bag stays open, deliberately.**
2666+
* `PaginationConfigSchema` is itself closed, but reusing it here would
2667+
* refuse every sibling key this door has accepted since it was written — the
2668+
* `…` in its own describe says authors pass them — which is a wider
2669+
* narrowing than the defect measured above and a different decision. So what
2670+
* narrows is the accept set of a page size; what does NOT narrow is which
2671+
* keys the bag may carry. `BuildProgressFrameSchema`
2672+
* (`ai/build-progress.zod.ts`) is the house precedent for a floor-not-ceiling
2673+
* shape, and `DashboardWidgetConfigSchema` for an open bag with declared
2674+
* members.
2675+
*
2676+
* Read points measured at objectui `d18322415`: `ObjectGrid.tsx:1209` and
2677+
* `:1628` read `(schema.pagination as any)?.pageSize ?? schema.pageSize`,
2678+
* `:4179` reads `schema.pagination?.pageSize` and `:4359`
2679+
* `schema.pagination?.pageSizeOptions` — those two are the only members any
2680+
* read point on this door names, and the objectui registry has published
2681+
* this input as `type: 'object'` all along (`plugin-grid/src/index.tsx:223`),
2682+
* so a non-object value here was already answered `type-mismatch` one tier
2683+
* down while this schema accepted it. `:4175` reads presence only
2684+
* (`schema.pagination !== undefined ? true : …`), which is why an authored
2685+
* `pagination: false` used to mean paging ON.
2686+
*/
2687+
pagination: z.looseObject({
2688+
pageSize: GridPageSizeSchema.optional(),
2689+
pageSizeOptions: z.array(GridPageSizeSchema).optional(),
2690+
}).optional()
2691+
.describe('Pagination config ({ pageSize, pageSizeOptions, … }); its presence enables paging. `pageSize` and every `pageSizeOptions` entry is a positive integer — the accept set the view arm\'s `PaginationConfigSchema` already rules; the bag stays open, so other keys pass through unvalidated'),
2692+
pageSize: GridPageSizeSchema.optional()
2693+
.describe('Flat page-size shorthand, a positive integer; `pagination.pageSize` wins when both are set'),
26352694
showPagination: z.boolean().optional().describe('Show the pager (read only when `pagination` is absent)'),
26362695
searchableFields: z.array(z.string()).optional()
26372696
.describe('Fields the toolbar search queries; a non-empty list enables search'),

0 commit comments

Comments
 (0)