Skip to content

Commit 99097e8

Browse files
os-billclaude
andcommitted
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>
1 parent 3e63df4 commit 99097e8

4 files changed

Lines changed: 217 additions & 1 deletion

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).
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+
};

packages/spec/src/migrations/registry.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8702,6 +8702,76 @@ const step18: MigrationStep = {
87028702
+ 'set stays exactly `DEFAULT_METADATA_TYPE_REGISTRY` plus item-population growth, '
87038703
+ 'before and after.',
87048704
},
8705+
{
8706+
id: 'object-block-sort-item-array',
8707+
surface:
8708+
'The `sort` prop of `object-grid` and `object-calendar` in `ComponentPropsMap` '
8709+
+ '(the FORM: the accept-anything `z.unknown()` at both block doors, vs the '
8710+
+ '`SortItem` array `[{ field, order }, ...]`)',
8711+
replacement:
8712+
'`z.array(SortItemSchema)` at both doors — the array `ElementDataSourceSchema.sort`, '
8713+
+ '`ListPageSchema.sort` and `element:record_picker`\'s flat `sort` shorthand already '
8714+
+ 'carry. The legacy OData-ish clause `sort: \'created_at desc\'` becomes '
8715+
+ '`sort: [{ field: \'created_at\', order: \'desc\' }]`; a bare field name '
8716+
+ '`sort: \'created_at\'` meant ascending and becomes '
8717+
+ '`sort: [{ field: \'created_at\', order: \'asc\' }]` — `order` is required in '
8718+
+ '`SortItemSchema`, so it is written out rather than omitted. A comma-separated '
8719+
+ 'clause becomes one array entry per key, in the same order. `record:related_list` '
8720+
+ 'is NOT moved by this entry: its string is the `\'field\'` / `\'-field\'` dialect '
8721+
+ 'read by `RelatedList.normalizeSortSpec`, which never reaches '
8722+
+ '`convertSortToQueryParams`, and retiring it was not ruled. '
8723+
+ '`object-grid.defaultSort` is a different key, retired separately by the '
8724+
+ '`ui__ObjectGridProps__defaultSort` entry.',
8725+
reason:
8726+
'One `sort` spelling platform-wide, the array (objectui#8221, decision batch #77, '
8727+
+ '2026-09-07, maintainer verbatim 「其他同意」, option B; the consumer half is '
8728+
+ 'objectui PR #8758, which drops the string arm from `convertSortToQueryParams`). '
8729+
+ 'Item 4 of that ruling is this entry\'s subject: 「`ComponentPropsMap` for '
8730+
+ '`object-calendar` and `object-grid` constrains the `sort` value to the array shape '
8731+
+ '(today it accepts anything), so the spec, the registrations and the helper agree; '
8732+
+ 'that is a pull-back to the declared contract, ordinary tier」. The `z.unknown()` at '
8733+
+ 'both doors was a read-point record (#7751), the same vintage as the `filter` doors '
8734+
+ 'the `element-data-source-and-object-block-filter-rule-array` entry moved, and not an '
8735+
+ 'exception to the ruling: measured on `@objectstack/spec` 17.2.0 an array, a string '
8736+
+ 'and a bare NUMBER all returned `success: true` while `bogusProp` was refused by name '
8737+
+ 'on the same call, so key checking was live and only the VALUE was unheld. Meanwhile '
8738+
+ 'objectui\'s own html tier has published `type: \'array\'` for the grid all along '
8739+
+ '(`plugin-grid/src/index.tsx:222`) and answered `type-mismatch` on the string — a '
8740+
+ 'spelling `@object-ui/core` implemented, the docs taught and the validator refused, '
8741+
+ 'which is what made this a ruling rather than a mechanical widening. '
8742+
+ 'Sequenced measurement-first: at the objectui pin this repo builds against '
8743+
+ '(`53ded82b`) the string is still lowered — `ObjectGrid.tsx:1844-1851` carries an '
8744+
+ 'explicit `typeof === \'string\'` arm onto `$orderby`, and `ObjectCalendar.tsx:431` '
8745+
+ 'hands `schema.sort` to `convertSortToQueryParams`, whose string arm is still present '
8746+
+ 'at `sort-query.ts:66-70`. So this declaration lands AHEAD of the pinned consumer, '
8747+
+ 'which the ruling permits explicitly (either order; the registrations already declare '
8748+
+ 'the array). The in-repo sweep found ZERO authored `sort` on either block — the two '
8749+
+ 'showcase pages that author `object-grid` (`command-center.page.ts`, '
8750+
+ '`my-work.page.ts`) declare none — with the same grep shape finding 40+ string `sort` '
8751+
+ 'values at OTHER doors (view definitions, ObjectQL `query.sort`) as the control that '
8752+
+ 'the sweep fires; so this entry carries the prescription for authors outside the repo. '
8753+
+ '⚠️ Metadata AT REST is deliberately NOT rewritten and this disposition adds no D2 '
8754+
+ 'conversion: `os migrate meta --stored` replays D2 conversions only, and the read path '
8755+
+ 'does not re-validate stored rows (`applyConversionsToStoredItem` replays the chain '
8756+
+ 'without validating, by its own contract), so a stored page carrying a string `sort` '
8757+
+ 'keeps loading and is still rendered by objectui at the pinned `.objectui-sha`. What '
8758+
+ 'changes is that RE-SAVING it is refused at the `sort` door, on its next save and not '
8759+
+ 'before. ADR-0049, ADR-0087.',
8760+
acceptanceCriteria:
8761+
'`ComponentPropsMap[\'object-grid\' | \'object-calendar\'].safeParse({ objectName, '
8762+
+ 'sort: [{ field: \'created_at\', order: \'desc\' }] })` succeeds and the parsed `sort` '
8763+
+ 'is that same array, equal value-for-value to '
8764+
+ '`ElementDataSourceSchema.parse({ object, sort: <that array> }).sort`. The legacy '
8765+
+ 'string clause is refused at the `sort` path on both doors (`invalid_type`, expected '
8766+
+ 'array), and so is a bare number; a misspelled or ABSENT direction is refused at '
8767+
+ '`sort.0.order` (`invalid_value` — `order` is a required enum, so both take one '
8768+
+ 'verdict) and a missing field at `sort.0.field` (`invalid_type`). An undeclared key '
8769+
+ 'is still refused BY NAME on the same call (`unrecognized_keys` naming it), the '
8770+
+ 'control that makes those refusals verdicts rather than a schema reporting nothing. '
8771+
+ 'No `sort` door in `ComponentPropsMap` accepts a string except `record:related_list`, '
8772+
+ 'which is the one deliberate exception. At runtime each block orders exactly as the '
8773+
+ 'array orders — the same `$orderby` the string lowered to.',
8774+
},
87058775
{
87068776
id: 'object-grid-data-view-data-converged',
87078777
surface:

packages/spec/src/ui/component.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2033,8 +2033,11 @@ describe('`object-grid` / `object-calendar` `sort` — one sort orthography, the
20332033
// `z.unknown()` echoed every one of these back with `success: true`.
20342034
const spelledOut = door(type).safeParse({ objectName: 'showcase_task', sort: [{ field: 'created_at', order: 'descending' }] });
20352035
expect(issuesAtPath(spelledOut, 'sort.0.order').map((i) => i.code)).toEqual(['invalid_value']);
2036+
// Same code as the misspelling above, and deliberately so: `order` is a
2037+
// required enum, so an ABSENT direction and a wrong one are one verdict at
2038+
// one path — the pair is what the schema asks for.
20362039
const noDirection = door(type).safeParse({ objectName: 'showcase_task', sort: [{ field: 'created_at' }] });
2037-
expect(issuesAtPath(noDirection, 'sort.0.order').map((i) => i.code)).toEqual(['invalid_type']);
2040+
expect(issuesAtPath(noDirection, 'sort.0.order').map((i) => i.code)).toEqual(['invalid_value']);
20382041
const noField = door(type).safeParse({ objectName: 'showcase_task', sort: [{ order: 'asc' }] });
20392042
expect(issuesAtPath(noField, 'sort.0.field').map((i) => i.code)).toEqual(['invalid_type']);
20402043
});

0 commit comments

Comments
 (0)