Skip to content

Commit 9788f1e

Browse files
os-billclaude
andauthored
spec(ui): constrain the object-grid / object-calendar sort value to the SortItem array (#17439)
* spec(ui): constrain object-grid / object-calendar `sort` to the SortItem array `ComponentPropsMap` declared `sort` as `z.unknown()` on both blocks, so an array, the legacy string clause and a bare number all returned `success: true` while `bogusProp` was refused by name on the same call. objectui#8221 decision batch #77 (2026-09-07, option B) rules one `sort` spelling platform-wide — the array — and its item 4 names these two doors as the spec-side half. Both now declare `z.array(SortItemSchema)`, the shared schema `ElementDataSourceSchema.sort`, `ListPageSchema.sort` and `element:record_picker` already import. `record:related_list`'s string arm is deliberately untouched: it is the `'field'` / `'-field'` dialect read by `RelatedList.normalizeSortSpec`, never reaching `convertSortToQueryParams`, and retiring it was not ruled. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * spec(ui): pin the sort orthography, register the ADR-0087 entry, add the changeset Pins on both doors: the SortItem array is accepted and echoed; the legacy string clause and a bare number are refused at `sort` (`invalid_type`, expected array); a misspelled or absent direction at `sort.0.order`; `bogusProp` is still refused by name on the same call (the control the card keeps). A census over the whole map pins that `record:related_list` is the ONE `sort` door still taking a string, and why that is deliberate. Registers the semantic migration `object-block-sort-item-array` under protocol major 18 (registry.ts is regenerated by `gen:migration-registry`, never edited) and declares the breaking accept-set change as `minor` per the launch-window convention. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * docs(spec): regenerate the ui component reference; keep the issue id out of customer text `check:doc-authoring` refuses an internal issue id in `.describe()` prose — it projects into `content/docs/references/**` and the generated skill artifacts, where `#NNNN` resolves to nothing. Both `sort` describes now cite the migration id instead, which a reader can act on. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 45c2cf9 commit 9788f1e

6 files changed

Lines changed: 396 additions & 4 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `object-grid` and `object-calendar` constrain the `sort` VALUE to the `SortItem` array — one sort orthography platform-wide reaches the last two unconstrained doors (#16553; objectui#8221, decision batch #77 option B)
6+
7+
<!-- adr-0087: registered object-block-sort-item-array -->
8+
9+
**BREAKING** accept-set change at two doors — `ComponentPropsMap['object-grid'].sort`
10+
and `ComponentPropsMap['object-calendar'].sort` — shipped as `minor` under the
11+
repo's launch-window convention for breaking changes; the migration prescription
12+
is registered under protocol major 18 as `object-block-sort-item-array`.
13+
14+
One `sort` spelling platform-wide, the array (objectui#8221, decision batch #77,
15+
2026-09-07, maintainer verbatim 「其他同意」, option B; the consumer half is
16+
objectui PR #8758, which drops the legacy string arm from
17+
`convertSortToQueryParams`). Item 4 of that ruling is this release's subject:
18+
「`ComponentPropsMap` for `object-calendar` and `object-grid` constrains the
19+
`sort` value to the array shape (today it accepts anything), so the spec, the
20+
registrations and the helper agree; that is a pull-back to the declared contract,
21+
ordinary tier」.
22+
23+
Until this release both doors declared `z.unknown()` — no orthography at all.
24+
Measured on `@objectstack/spec` 17.2.0 and re-measured on this tree before the
25+
change: an array, the legacy string clause and a bare NUMBER all returned
26+
`success: true`, while `bogusProp` was refused by name on the same call. So key
27+
checking was live and only the VALUE was unheld, and an author following
28+
objectui's own registrations (`plugin-grid/src/index.tsx:222` has published
29+
`type: 'array'` all along) and an author following the legacy string each got a
30+
silent success receipt for a different shape — while objectui's html tier
31+
answered `type-mismatch` on the second one. Both doors now declare
32+
`z.array(SortItemSchema)`, the array `ElementDataSourceSchema.sort`,
33+
`ListPageSchema.sort` and `element:record_picker`'s flat `sort` shorthand already
34+
carry: one shared schema, not a third copy.
35+
36+
Sequenced measurement-first, as this family has to be. At the objectui pin this
37+
repo builds against (`53ded82b`) the string is still lowered —
38+
`ObjectGrid.tsx:1844-1851` carries an explicit `typeof === 'string'` arm onto
39+
`$orderby` beside the array arm, and `ObjectCalendar.tsx:431` hands `schema.sort`
40+
to `convertSortToQueryParams`, whose string arm is still present at
41+
`sort-query.ts:66-70`. This declaration therefore lands ahead of the pinned
42+
consumer, which the ruling permits explicitly — either order, since the
43+
registrations already declare the array — and the next pin bump carries the
44+
retirement in.
45+
46+
**Migration** (`object-block-sort-item-array`): `sort: 'created_at desc'` becomes
47+
`sort: [{ field: 'created_at', order: 'desc' }]`; a bare field name
48+
`sort: 'created_at'` meant ascending and becomes
49+
`sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required in
50+
`SortItemSchema`, so it is written out rather than omitted; a comma-separated
51+
clause becomes one array entry per key, in the same order. The string is refused
52+
at `sort` (`invalid_type`, expected array), as is a bare number; a misspelled or
53+
absent direction is refused at `sort.0.order`. Metadata AT REST is not rewritten
54+
and this disposition adds no D2 conversion — a stored page carrying a string
55+
`sort` keeps loading and still renders at the pinned `.objectui-sha`; what
56+
changes is that RE-SAVING it is refused at the `sort` door.
57+
58+
**Not moved by this release.** `record:related_list.sort` keeps its declared
59+
string arm: that string is the `'field'` / `'-field'` dialect read by
60+
`RelatedList.normalizeSortSpec`, it never reaches `convertSortToQueryParams`, and
61+
retiring it was not ruled — objectui#8221's own implementing round narrowed it,
62+
established the dialect and reverted the narrowing byte-identically.
63+
`object-grid.defaultSort` is a different key, already retired by #11805. Zero
64+
authored `sort` values on either block exist in this repo (the two showcase pages
65+
that author `object-grid` declare none), so nothing in-tree was converted.
66+
67+
Type aliases are unchanged: `SortItemSchema`'s input equals its infer, so neither
68+
block's parsed state moves for this key, and both already take the
69+
`…PropsParsed` route for `filter` (ADR-0122).

‎content/docs/references/ui/component.mdx‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ Sort field and direction pair
312312
| **calendar** | `any` | optional | Calendar field config: `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }` |
313313
| **defaultView** | `Enum<'month' \| 'week' \| 'day'>` | optional | Initial view mode |
314314
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` |
315-
| **sort** | `any` | optional | Sort for the fetched events |
315+
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Row order for the fetched events — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` |
316316
| **data** | `any[]` | optional | Pre-fetched records — skips the internal fetch |
317317
| **staticData** | `any[]` | optional | Static inline records |
318318
| **locale** | `string` | optional | Locale override for the calendar chrome |
@@ -328,6 +328,15 @@ View filter rule
328328
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator |
329329
| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. |
330330

331+
### Nested Shape: `ObjectCalendarProps.sort[number]`
332+
333+
Sort field and direction pair
334+
335+
| Property | Type | Required | Description |
336+
| :--- | :--- | :--- | :--- |
337+
| **field** | `string` | ✅ | Field name to sort by |
338+
| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction |
339+
331340

332341
---
333342

@@ -393,7 +402,7 @@ View filter rule
393402
| **fields** | `any[]` | optional | Field list fallback used when `columns` is absent |
394403
| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. THE key, singular — not the plural misspelling. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` |
395404
| **defaultFilters** | `any` | optional | Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter` |
396-
| **sort** | `any` | optional | Initial sort (array of `{ field, order }`) |
405+
| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Initial row order — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` |
397406
| **defaultSort** | `never` | optional | [REMOVED] `object-grid` property `defaultSort` was removed in @objectstack/spec 17 (ADR-0049) — it was the legacy second spelling of `sort`: a single `{ field, order }` pair read only when `sort` was absent, so one intent had two spellings and a grid authoring both silently ignored this one. Rename the key to `sort` and wrap the value in an array (`defaultSort: { field, order }` becomes `sort: [{ field, order }]`); the pair itself is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
398407
| **pagination** | `any` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging |
399408
| **pageSize** | `number` | optional | Flat page-size shorthand; `pagination.pageSize` wins when both are set |
@@ -434,6 +443,15 @@ View filter rule
434443
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator |
435444
| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. |
436445

446+
### Nested Shape: `ObjectGridProps.sort[number]`
447+
448+
Sort field and direction pair
449+
450+
| Property | Type | Required | Description |
451+
| :--- | :--- | :--- | :--- |
452+
| **field** | `string` | ✅ | Field name to sort by |
453+
| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction |
454+
437455
### Nested Shape: `ObjectGridProps.data[provider='object']`
438456

439457
| Property | Type | Required | Description |
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'object-block-sort-item-array',
7+
surface:
8+
'The `sort` prop of `object-grid` and `object-calendar` in `ComponentPropsMap` '
9+
+ '(the FORM: the accept-anything `z.unknown()` at both block doors, vs the '
10+
+ '`SortItem` array `[{ field, order }, ...]`)',
11+
replacement:
12+
'`z.array(SortItemSchema)` at both doors — the array `ElementDataSourceSchema.sort`, '
13+
+ '`ListPageSchema.sort` and `element:record_picker`\'s flat `sort` shorthand already '
14+
+ 'carry. The legacy OData-ish clause `sort: \'created_at desc\'` becomes '
15+
+ '`sort: [{ field: \'created_at\', order: \'desc\' }]`; a bare field name '
16+
+ '`sort: \'created_at\'` meant ascending and becomes '
17+
+ '`sort: [{ field: \'created_at\', order: \'asc\' }]` — `order` is required in '
18+
+ '`SortItemSchema`, so it is written out rather than omitted. A comma-separated '
19+
+ 'clause becomes one array entry per key, in the same order. `record:related_list` '
20+
+ 'is NOT moved by this entry: its string is the `\'field\'` / `\'-field\'` dialect '
21+
+ 'read by `RelatedList.normalizeSortSpec`, which never reaches '
22+
+ '`convertSortToQueryParams`, and retiring it was not ruled. '
23+
+ '`object-grid.defaultSort` is a different key, retired separately by the '
24+
+ '`ui__ObjectGridProps__defaultSort` entry.',
25+
reason:
26+
'One `sort` spelling platform-wide, the array (objectui#8221, decision batch #77, '
27+
+ '2026-09-07, maintainer verbatim 「其他同意」, option B; the consumer half is '
28+
+ 'objectui PR #8758, which drops the string arm from `convertSortToQueryParams`). '
29+
+ 'Item 4 of that ruling is this entry\'s subject: 「`ComponentPropsMap` for '
30+
+ '`object-calendar` and `object-grid` constrains the `sort` value to the array shape '
31+
+ '(today it accepts anything), so the spec, the registrations and the helper agree; '
32+
+ 'that is a pull-back to the declared contract, ordinary tier」. The `z.unknown()` at '
33+
+ 'both doors was a read-point record (#7751), the same vintage as the `filter` doors '
34+
+ 'the `element-data-source-and-object-block-filter-rule-array` entry moved, and not an '
35+
+ 'exception to the ruling: measured on `@objectstack/spec` 17.2.0 an array, a string '
36+
+ 'and a bare NUMBER all returned `success: true` while `bogusProp` was refused by name '
37+
+ 'on the same call, so key checking was live and only the VALUE was unheld. Meanwhile '
38+
+ 'objectui\'s own html tier has published `type: \'array\'` for the grid all along '
39+
+ '(`plugin-grid/src/index.tsx:222`) and answered `type-mismatch` on the string — a '
40+
+ 'spelling `@object-ui/core` implemented, the docs taught and the validator refused, '
41+
+ 'which is what made this a ruling rather than a mechanical widening. '
42+
+ 'Sequenced measurement-first: at the objectui pin this repo builds against '
43+
+ '(`53ded82b`) the string is still lowered — `ObjectGrid.tsx:1844-1851` carries an '
44+
+ 'explicit `typeof === \'string\'` arm onto `$orderby`, and `ObjectCalendar.tsx:431` '
45+
+ 'hands `schema.sort` to `convertSortToQueryParams`, whose string arm is still present '
46+
+ 'at `sort-query.ts:66-70`. So this declaration lands AHEAD of the pinned consumer, '
47+
+ 'which the ruling permits explicitly (either order; the registrations already declare '
48+
+ 'the array). The in-repo sweep found ZERO authored `sort` on either block — the two '
49+
+ 'showcase pages that author `object-grid` (`command-center.page.ts`, '
50+
+ '`my-work.page.ts`) declare none — with the same grep shape finding 40+ string `sort` '
51+
+ 'values at OTHER doors (view definitions, ObjectQL `query.sort`) as the control that '
52+
+ 'the sweep fires; so this entry carries the prescription for authors outside the repo. '
53+
+ '⚠️ Metadata AT REST is deliberately NOT rewritten and this disposition adds no D2 '
54+
+ 'conversion: `os migrate meta --stored` replays D2 conversions only, and the read path '
55+
+ 'does not re-validate stored rows (`applyConversionsToStoredItem` replays the chain '
56+
+ 'without validating, by its own contract), so a stored page carrying a string `sort` '
57+
+ 'keeps loading and is still rendered by objectui at the pinned `.objectui-sha`. What '
58+
+ 'changes is that RE-SAVING it is refused at the `sort` door, on its next save and not '
59+
+ 'before. ADR-0049, ADR-0087.',
60+
acceptanceCriteria:
61+
'`ComponentPropsMap[\'object-grid\' | \'object-calendar\'].safeParse({ objectName, '
62+
+ 'sort: [{ field: \'created_at\', order: \'desc\' }] })` succeeds and the parsed `sort` '
63+
+ 'is that same array, equal value-for-value to '
64+
+ '`ElementDataSourceSchema.parse({ object, sort: <that array> }).sort`. The legacy '
65+
+ 'string clause is refused at the `sort` path on both doors (`invalid_type`, expected '
66+
+ 'array), and so is a bare number; a misspelled or ABSENT direction is refused at '
67+
+ '`sort.0.order` (`invalid_value` — `order` is a required enum, so both take one '
68+
+ 'verdict) and a missing field at `sort.0.field` (`invalid_type`). An undeclared key '
69+
+ 'is still refused BY NAME on the same call (`unrecognized_keys` naming it), the '
70+
+ 'control that makes those refusals verdicts rather than a schema reporting nothing. '
71+
+ 'No `sort` door in `ComponentPropsMap` accepts a string except `record:related_list`, '
72+
+ 'which is the one deliberate exception. At runtime each block orders exactly as the '
73+
+ 'array orders — the same `$orderby` the string lowered to.',
74+
};

0 commit comments

Comments
 (0)