Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .changeset/object-block-sort-item-array.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
"@objectstack/spec": minor
---

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)

<!-- adr-0087: registered object-block-sort-item-array -->

**BREAKING** accept-set change at two doors — `ComponentPropsMap['object-grid'].sort`
and `ComponentPropsMap['object-calendar'].sort` — shipped as `minor` under the
repo's launch-window convention for breaking changes; the migration prescription
is registered under protocol major 18 as `object-block-sort-item-array`.

One `sort` spelling platform-wide, the array (objectui#8221, decision batch #77,
2026-09-07, maintainer verbatim 「其他同意」, option B; the consumer half is
objectui PR #8758, which drops the legacy string arm from
`convertSortToQueryParams`). Item 4 of that ruling is this release's subject:
「`ComponentPropsMap` for `object-calendar` and `object-grid` constrains the
`sort` value to the array shape (today it accepts anything), so the spec, the
registrations and the helper agree; that is a pull-back to the declared contract,
ordinary tier」.

Until this release both doors declared `z.unknown()` — no orthography at all.
Measured on `@objectstack/spec` 17.2.0 and re-measured on this tree before the
change: an array, the legacy string clause and a bare NUMBER all returned
`success: true`, while `bogusProp` was refused by name on the same call. So key
checking was live and only the VALUE was unheld, and an author following
objectui's own registrations (`plugin-grid/src/index.tsx:222` has published
`type: 'array'` all along) and an author following the legacy string each got a
silent success receipt for a different shape — while objectui's html tier
answered `type-mismatch` on the second one. Both doors now declare
`z.array(SortItemSchema)`, the array `ElementDataSourceSchema.sort`,
`ListPageSchema.sort` and `element:record_picker`'s flat `sort` shorthand already
carry: one shared schema, not a third copy.

Sequenced measurement-first, as this family has to be. At the objectui pin this
repo builds against (`53ded82b`) the string is still lowered —
`ObjectGrid.tsx:1844-1851` carries an explicit `typeof === 'string'` arm onto
`$orderby` beside the array arm, and `ObjectCalendar.tsx:431` hands `schema.sort`
to `convertSortToQueryParams`, whose string arm is still present at
`sort-query.ts:66-70`. This declaration therefore lands ahead of the pinned
consumer, which the ruling permits explicitly — either order, since the
registrations already declare the array — and the next pin bump carries the
retirement in.

**Migration** (`object-block-sort-item-array`): `sort: 'created_at desc'` becomes
`sort: [{ field: 'created_at', order: 'desc' }]`; a bare field name
`sort: 'created_at'` meant ascending and becomes
`sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required in
`SortItemSchema`, so it is written out rather than omitted; a comma-separated
clause becomes one array entry per key, in the same order. The string is refused
at `sort` (`invalid_type`, expected array), as is a bare number; a misspelled or
absent direction is refused at `sort.0.order`. Metadata AT REST is not rewritten
and this disposition adds no D2 conversion — a stored page carrying a string
`sort` keeps loading and still renders at the pinned `.objectui-sha`; what
changes is that RE-SAVING it is refused at the `sort` door.

**Not moved by this release.** `record:related_list.sort` keeps its declared
string arm: that string is the `'field'` / `'-field'` dialect read by
`RelatedList.normalizeSortSpec`, it never reaches `convertSortToQueryParams`, and
retiring it was not ruled — objectui#8221's own implementing round narrowed it,
established the dialect and reverted the narrowing byte-identically.
`object-grid.defaultSort` is a different key, already retired by #11805. Zero
authored `sort` values on either block exist in this repo (the two showcase pages
that author `object-grid` declare none), so nothing in-tree was converted.

Type aliases are unchanged: `SortItemSchema`'s input equals its infer, so neither
block's parsed state moves for this key, and both already take the
`…PropsParsed` route for `filter` (ADR-0122).
22 changes: 20 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ Sort field and direction pair
| **calendar** | `any` | optional | Calendar field config: `{ startDateField, endDateField?, titleField?, colorField?, allDayField? }` |
| **defaultView** | `Enum<'month' \| 'week' \| 'day'>` | optional | Initial view mode |
| **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` |
| **sort** | `any` | optional | Sort for the fetched events |
| **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` |
| **data** | `any[]` | optional | Pre-fetched records — skips the internal fetch |
| **staticData** | `any[]` | optional | Static inline records |
| **locale** | `string` | optional | Locale override for the calendar chrome |
Expand All @@ -328,6 +328,15 @@ View filter rule
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator |
| **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. |

### Nested Shape: `ObjectCalendarProps.sort[number]`

Sort field and direction pair

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **field** | `string` | ✅ | Field name to sort by |
| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction |


---

Expand Down Expand Up @@ -393,7 +402,7 @@ View filter rule
| **fields** | `any[]` | optional | Field list fallback used when `columns` is absent |
| **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` |
| **defaultFilters** | `any` | optional | Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter` |
| **sort** | `any` | optional | Initial sort (array of `{ field, order }`) |
| **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` |
| **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. |
| **pagination** | `any` | optional | Pagination config (`{ pageSize, pageSizeOptions, … }`); its presence enables paging |
| **pageSize** | `number` | optional | Flat page-size shorthand; `pagination.pageSize` wins when both are set |
Expand Down Expand Up @@ -434,6 +443,15 @@ View filter rule
| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator |
| **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. |

### Nested Shape: `ObjectGridProps.sort[number]`

Sort field and direction pair

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **field** | `string` | ✅ | Field name to sort by |
| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction |

### Nested Shape: `ObjectGridProps.data[provider='object']`

| Property | Type | Required | Description |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

export const entry: SemanticMigration = {
id: 'object-block-sort-item-array',
surface:
'The `sort` prop of `object-grid` and `object-calendar` in `ComponentPropsMap` '
+ '(the FORM: the accept-anything `z.unknown()` at both block doors, vs the '
+ '`SortItem` array `[{ field, order }, ...]`)',
replacement:
'`z.array(SortItemSchema)` at both doors — the array `ElementDataSourceSchema.sort`, '
+ '`ListPageSchema.sort` and `element:record_picker`\'s flat `sort` shorthand already '
+ 'carry. The legacy OData-ish clause `sort: \'created_at desc\'` becomes '
+ '`sort: [{ field: \'created_at\', order: \'desc\' }]`; a bare field name '
+ '`sort: \'created_at\'` meant ascending and becomes '
+ '`sort: [{ field: \'created_at\', order: \'asc\' }]` — `order` is required in '
+ '`SortItemSchema`, so it is written out rather than omitted. A comma-separated '
+ 'clause becomes one array entry per key, in the same order. `record:related_list` '
+ 'is NOT moved by this entry: its string is the `\'field\'` / `\'-field\'` dialect '
+ 'read by `RelatedList.normalizeSortSpec`, which never reaches '
+ '`convertSortToQueryParams`, and retiring it was not ruled. '
+ '`object-grid.defaultSort` is a different key, retired separately by the '
+ '`ui__ObjectGridProps__defaultSort` entry.',
reason:
'One `sort` spelling platform-wide, the array (objectui#8221, decision batch #77, '
+ '2026-09-07, maintainer verbatim 「其他同意」, option B; the consumer half is '
+ 'objectui PR #8758, which drops the string arm from `convertSortToQueryParams`). '
+ 'Item 4 of that ruling is this entry\'s subject: 「`ComponentPropsMap` for '
+ '`object-calendar` and `object-grid` constrains the `sort` value to the array shape '
+ '(today it accepts anything), so the spec, the registrations and the helper agree; '
+ 'that is a pull-back to the declared contract, ordinary tier」. The `z.unknown()` at '
+ 'both doors was a read-point record (#7751), the same vintage as the `filter` doors '
+ 'the `element-data-source-and-object-block-filter-rule-array` entry moved, and not an '
+ 'exception to the ruling: measured on `@objectstack/spec` 17.2.0 an array, a string '
+ 'and a bare NUMBER all returned `success: true` while `bogusProp` was refused by name '
+ 'on the same call, so key checking was live and only the VALUE was unheld. Meanwhile '
+ 'objectui\'s own html tier has published `type: \'array\'` for the grid all along '
+ '(`plugin-grid/src/index.tsx:222`) and answered `type-mismatch` on the string — a '
+ 'spelling `@object-ui/core` implemented, the docs taught and the validator refused, '
+ 'which is what made this a ruling rather than a mechanical widening. '
+ 'Sequenced measurement-first: at the objectui pin this repo builds against '
+ '(`53ded82b`) the string is still lowered — `ObjectGrid.tsx:1844-1851` carries an '
+ 'explicit `typeof === \'string\'` arm onto `$orderby`, and `ObjectCalendar.tsx:431` '
+ 'hands `schema.sort` to `convertSortToQueryParams`, whose string arm is still present '
+ 'at `sort-query.ts:66-70`. So this declaration lands AHEAD of the pinned consumer, '
+ 'which the ruling permits explicitly (either order; the registrations already declare '
+ 'the array). The in-repo sweep found ZERO authored `sort` on either block — the two '
+ 'showcase pages that author `object-grid` (`command-center.page.ts`, '
+ '`my-work.page.ts`) declare none — with the same grep shape finding 40+ string `sort` '
+ 'values at OTHER doors (view definitions, ObjectQL `query.sort`) as the control that '
+ 'the sweep fires; so this entry carries the prescription for authors outside the repo. '
+ '⚠️ Metadata AT REST is deliberately NOT rewritten and this disposition adds no D2 '
+ 'conversion: `os migrate meta --stored` replays D2 conversions only, and the read path '
+ 'does not re-validate stored rows (`applyConversionsToStoredItem` replays the chain '
+ 'without validating, by its own contract), so a stored page carrying a string `sort` '
+ 'keeps loading and is still rendered by objectui at the pinned `.objectui-sha`. What '
+ 'changes is that RE-SAVING it is refused at the `sort` door, on its next save and not '
+ 'before. ADR-0049, ADR-0087.',
acceptanceCriteria:
'`ComponentPropsMap[\'object-grid\' | \'object-calendar\'].safeParse({ objectName, '
+ 'sort: [{ field: \'created_at\', order: \'desc\' }] })` succeeds and the parsed `sort` '
+ 'is that same array, equal value-for-value to '
+ '`ElementDataSourceSchema.parse({ object, sort: <that array> }).sort`. The legacy '
+ 'string clause is refused at the `sort` path on both doors (`invalid_type`, expected '
+ 'array), and so is a bare number; a misspelled or ABSENT direction is refused at '
+ '`sort.0.order` (`invalid_value` — `order` is a required enum, so both take one '
+ 'verdict) and a missing field at `sort.0.field` (`invalid_type`). An undeclared key '
+ 'is still refused BY NAME on the same call (`unrecognized_keys` naming it), the '
+ 'control that makes those refusals verdicts rather than a schema reporting nothing. '
+ 'No `sort` door in `ComponentPropsMap` accepts a string except `record:related_list`, '
+ 'which is the one deliberate exception. At runtime each block orders exactly as the '
+ 'array orders — the same `$orderby` the string lowered to.',
};
Loading
Loading