From 3e63df4619d3f255b14f2bdc66a7653d73e360c8 Mon Sep 17 00:00:00 2001 From: os-bill Date: Thu, 10 Sep 2026 13:02:31 +0000 Subject: [PATCH 1/3] spec(ui): constrain object-grid / object-calendar `sort` to the SortItem array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- packages/spec/src/ui/component.test.ts | 102 +++++++++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 60 ++++++++++++++- 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 21e11472da..b7a599e9fc 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -2007,6 +2007,108 @@ describe('the four `object-*` `filter` doors — one filter orthography platform }); }); +describe('`object-grid` / `object-calendar` `sort` — one sort orthography, the array (objectui#8221, decision batch #77, option B)', () => { + const SORT_DOORS = ['object-grid', 'object-calendar'] as const; + const ARRAY_FORM = [{ field: 'created_at', order: 'desc' }]; + /** + * The legacy OData-ish clause `convertSortToQueryParams` honours at the + * objectui pin `53ded82b` (`core/src/utils/sort-query.ts:66-70`) and that + * `ObjectGrid.tsx:1845-1846` puts on `$orderby` verbatim. Retired by the + * ruling; refused here. + */ + const STRING_FORM = 'created_at desc'; + type ParseResult = { success: boolean; data?: { sort?: unknown }; error?: { issues: Array<{ path: PropertyKey[]; code: string }> } }; + type Door = { shape?: Record; safeParse: (v: unknown) => ParseResult }; + const door = (type: string) => ComponentPropsMap[type as keyof typeof ComponentPropsMap] as unknown as Door; + const issuesAtPath = (r: ParseResult, path: string) => + r.success ? [] : r.error!.issues.filter((i) => i.path.join('.') === path); + + it.each(SORT_DOORS)('%s accepts a SortItem[] and echoes it — the acceptance criterion', (type) => { + const r = door(type).safeParse({ objectName: 'showcase_task', sort: ARRAY_FORM }); + expect(r.success).toBe(true); + expect(r.data!.sort).toEqual(ARRAY_FORM); + }); + + it.each(SORT_DOORS)('%s carries the REAL SortItemSchema, not a lookalike: the direction enum and the required pair are checked', (type) => { + // `z.unknown()` echoed every one of these back with `success: true`. + const spelledOut = door(type).safeParse({ objectName: 'showcase_task', sort: [{ field: 'created_at', order: 'descending' }] }); + expect(issuesAtPath(spelledOut, 'sort.0.order').map((i) => i.code)).toEqual(['invalid_value']); + const noDirection = door(type).safeParse({ objectName: 'showcase_task', sort: [{ field: 'created_at' }] }); + expect(issuesAtPath(noDirection, 'sort.0.order').map((i) => i.code)).toEqual(['invalid_type']); + const noField = door(type).safeParse({ objectName: 'showcase_task', sort: [{ order: 'asc' }] }); + expect(issuesAtPath(noField, 'sort.0.field').map((i) => i.code)).toEqual(['invalid_type']); + }); + + it.each(SORT_DOORS)('%s REFUSES the legacy string clause at the `sort` path — the shape the ruling retires', (type) => { + // Reverse verification on the issue envelope: located at `sort`, kind + // named. Before this change the same value parsed with zero issues on + // both doors (the card's measurement on `@objectstack/spec` 17.2.0, and + // the ablation in the landing PR re-runs it against this tree). + const r = door(type).safeParse({ objectName: 'showcase_task', sort: STRING_FORM }); + expect(r.success).toBe(false); + const atSort = issuesAtPath(r, 'sort'); + expect(atSort).toHaveLength(1); + expect(atSort[0].code).toBe('invalid_type'); + expect(atSort[0]).toMatchObject({ expected: 'array' }); + }); + + it.each(SORT_DOORS)('%s REFUSES a bare number at `sort` — the other value `z.unknown()` receipted', (type) => { + const r = door(type).safeParse({ objectName: 'showcase_task', sort: 3 }); + expect(issuesAtPath(r, 'sort').map((i) => i.code)).toEqual(['invalid_type']); + }); + + it.each(SORT_DOORS)('%s still refuses an undeclared key BY NAME on the same call — the control the card keeps', (type) => { + // The control that makes the three readings above verdicts rather than a + // schema that reports nothing: key checking was never the thing that was + // missing on these doors, the VALUE was. + const r = door(type).safeParse({ objectName: 'showcase_task', sort: ARRAY_FORM, bogusProp: 1 }); + expect(r.success).toBe(false); + expect(issuesAtPath(r, 'sort')).toEqual([]); + const unrecognized = r.error!.issues.filter((i) => i.code === 'unrecognized_keys') as Array<{ keys?: string[] }>; + expect(unrecognized.flatMap((i) => i.keys ?? [])).toContain('bogusProp'); + }); + + it('`sort` agrees with `dataSource.sort` and with the picker shorthand — one shape, four doors', () => { + // The map's own copies are the same import (`SortItemSchema`), so this + // asks the question the copies could not: do the doors AGREE, value for + // value, with the binding every data-bound element already carries. + const viaBinding = ElementDataSourceSchema.parse({ object: 'showcase_task', sort: ARRAY_FORM }); + for (const type of [...SORT_DOORS, 'element:record_picker']) { + const value = type === 'element:record_picker' + ? { object: 'showcase_task', sort: ARRAY_FORM } + : { objectName: 'showcase_task', sort: ARRAY_FORM }; + const r = door(type).safeParse(value); + expect([type, r.success]).toEqual([type, true]); + expect([type, r.data!.sort]).toEqual([type, viaBinding.sort]); + const refused = door(type).safeParse({ ...value, sort: STRING_FORM }); + expect([type, issuesAtPath(refused, 'sort').map((i) => i.code)]).toEqual([type, ['invalid_type']]); + } + }); + + it('the census: no `sort` door in ComponentPropsMap takes a string except `record:related_list`, whose string is a DIFFERENT dialect and was not ruled', () => { + // Asked over the WHOLE map by shape rather than by the two names above, so + // a future entry declaring `sort` as `z.unknown()` is caught here by name. + // Guarded the same way as its `filter` twin: the doors pinned above must + // be found, or the shape read has gone wrong and the loop is vacuous. + const doors = (Object.entries(ComponentPropsMap) as Array<[string, unknown]>) + .filter(([, schema]) => { + const shape = (schema as Door).shape; + return !!shape && 'sort' in shape; + }) + .map(([type]) => type); + expect(doors).toEqual(expect.arrayContaining([...SORT_DOORS, 'element:record_picker', 'record:related_list'])); + const stringTakers = doors.filter((type) => issuesAtPath(door(type).safeParse({ sort: STRING_FORM }), 'sort').length === 0); + // ⚠️ `record:related_list` is the ONE deliberate exception and it is pinned + // as such, not tolerated: its string is the `'field'` / `'-field'` form + // read by `RelatedList.normalizeSortSpec`, a different dialect that never + // reaches `convertSortToQueryParams` — measured by objectui#8221's own + // implementing round, which narrowed it, established the dialect and then + // reverted the narrowing byte-identically. Retiring it was not ruled and + // would delete working, spec-legal behaviour. + expect(stringTakers).toEqual(['record:related_list']); + }); +}); + // --------------------------------------------------------------------------- // Interactive Elements — element:text_input // --------------------------------------------------------------------------- diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 93b810481d..16b6f5a870 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2485,7 +2485,45 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ .describe('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: z.unknown().optional() .describe('Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter`'), - sort: z.unknown().optional().describe('Initial sort (array of { field, order })'), + /** + * Initial row order — the `SortItem` ARRAY form, `[{ field, order }, ...]`, + * the one sort orthography every DECLARED `sort` door on this platform + * carries: `ElementDataSourceSchema.sort` and `ListPageSchema.sort` + * (page.zod.ts) and `element:record_picker`'s flat shorthand above. One + * shared schema rather than a third copy — all of them are + * `SortItemSchema`, already imported at the top of this file for the picker. + * + * objectui#8221, decision batch #77, 2026-09-07, maintainer verbatim + * 「其他同意」, option B: one `sort` spelling, the array; the legacy string + * clause is retired from `@object-ui/core`. Item 4 of that ruling is this + * declaration and `object-calendar`'s below — 「`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()` this door carried was a read-point record (#7751), the + * same vintage as its `filter` neighbour above and not an exception to the + * ruling: it receipted an array, a string and a bare NUMBER alike with + * `success: true`, while `plugin-grid/src/index.tsx:222` has published + * `type: 'array'` all along — so the html tier answered `type-mismatch` on a + * value this schema had just accepted. + * + * Sequenced measurement-first, as this family has to be. Measured at the + * objectui pin `53ded82b`: `ObjectGrid.tsx:1457` reads `schema.sort` and the + * fetch path at `:1844-1851` carries an explicit `typeof === 'string'` arm + * putting the clause on `$orderby` verbatim, beside the array arm that folds + * `[{ field, order }]` onto the same parameter. ⚠️ At THIS pin the string is + * therefore still lowered, and this door refuses a spelling the pinned + * renderer honours — the ruled sequence, not an oversight: objectui#8221's + * PR #8758 (merged 2026-09-09, after this pin) drops the string arm from + * `convertSortToQueryParams`, and the next pin bump carries it in. The array + * is the spelling both ends already agree on today; the header-arrow read at + * `:3998` hands `schemaSort` to `parseSchemaSort` as `TableSortItem[]`, the + * array shape and not the string. + */ + sort: z.array(SortItemSchema).optional() + .describe('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 — objectui#8221 decision batch #77, option B, retired it'), /** * REMOVED (#11805, maintainer ruling 2026-08-25, decision-inbox batch 4: * 「#11805 退役 defaultSort,不需要major」 — the ADR-0049 enforce-or-remove @@ -2836,7 +2874,25 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({ */ filter: z.array(ViewFilterRuleSchema).optional() .describe('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: z.unknown().optional().describe('Sort for the fetched events'), + /** + * Row order for the fetched events — the same `SortItem` ARRAY form + * `object-grid` declares above, and for the same ruling (objectui#8221, + * decision batch #77, option B; the `object-grid` entry carries the verbatim + * text). One sort orthography, one shared `SortItemSchema`. + * + * Measured at the objectui pin `53ded82b`: `ObjectCalendar.tsx:431` hands + * `schema.sort` to the shared sink `convertSortToQueryParams` + * (`core/src/utils/sort-query.ts`) as the fetch's `$orderby`. ⚠️ That sink + * still honours the legacy string clause at this pin — `sort-query.ts:66-70` + * — so, exactly as on `object-grid`, this declaration lands ahead of the + * consumer-side retirement (objectui#8221's PR #8758, merged 2026-09-09) and + * refuses a spelling the pinned helper still lowers. The array arm is + * unaffected: the sink folds `[{ field, order }]` into the field-direction + * map either way. Unlike the grid, `plugin-calendar/src/index.tsx` declares + * no `sort` input at all, so nothing on the registry side moves. + */ + sort: z.array(SortItemSchema).optional() + .describe('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 — objectui#8221 decision batch #77, option B, retired it'), data: z.array(z.unknown()).optional().describe('Pre-fetched records — skips the internal fetch'), staticData: z.array(z.unknown()).optional().describe('Static inline records'), locale: z.string().optional().describe('Locale override for the calendar chrome'), From 99097e82151da47ae1b2a001ff7aa8f8d0100b3f Mon Sep 17 00:00:00 2001 From: os-bill Date: Thu, 10 Sep 2026 13:10:06 +0000 Subject: [PATCH 2/3] 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 --- .changeset/object-block-sort-item-array.md | 69 +++++++++++++++++ .../18.object-block-sort-item-array.ts | 74 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 70 ++++++++++++++++++ packages/spec/src/ui/component.test.ts | 5 +- 4 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 .changeset/object-block-sort-item-array.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.object-block-sort-item-array.ts diff --git a/.changeset/object-block-sort-item-array.md b/.changeset/object-block-sort-item-array.md new file mode 100644 index 0000000000..4e09034f60 --- /dev/null +++ b/.changeset/object-block-sort-item-array.md @@ -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) + + + +**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). diff --git a/packages/spec/src/migrations/entries/semantic/18.object-block-sort-item-array.ts b/packages/spec/src/migrations/entries/semantic/18.object-block-sort-item-array.ts new file mode 100644 index 0000000000..ec773be04e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-block-sort-item-array.ts @@ -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: }).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.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index f64cc93d98..20e0605ec1 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8702,6 +8702,76 @@ const step18: MigrationStep = { + 'set stays exactly `DEFAULT_METADATA_TYPE_REGISTRY` plus item-population growth, ' + 'before and after.', }, + { + 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: }).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.', + }, { id: 'object-grid-data-view-data-converged', surface: diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index b7a599e9fc..c72a8d34c6 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -2033,8 +2033,11 @@ describe('`object-grid` / `object-calendar` `sort` — one sort orthography, the // `z.unknown()` echoed every one of these back with `success: true`. const spelledOut = door(type).safeParse({ objectName: 'showcase_task', sort: [{ field: 'created_at', order: 'descending' }] }); expect(issuesAtPath(spelledOut, 'sort.0.order').map((i) => i.code)).toEqual(['invalid_value']); + // Same code as the misspelling above, and deliberately so: `order` is a + // required enum, so an ABSENT direction and a wrong one are one verdict at + // one path — the pair is what the schema asks for. const noDirection = door(type).safeParse({ objectName: 'showcase_task', sort: [{ field: 'created_at' }] }); - expect(issuesAtPath(noDirection, 'sort.0.order').map((i) => i.code)).toEqual(['invalid_type']); + expect(issuesAtPath(noDirection, 'sort.0.order').map((i) => i.code)).toEqual(['invalid_value']); const noField = door(type).safeParse({ objectName: 'showcase_task', sort: [{ order: 'asc' }] }); expect(issuesAtPath(noField, 'sort.0.field').map((i) => i.code)).toEqual(['invalid_type']); }); From b19b41eeeca9c11ed1859d4175032e524221c94e Mon Sep 17 00:00:00 2001 From: os-bill Date: Thu, 10 Sep 2026 13:44:02 +0000 Subject: [PATCH 3/3] docs(spec): regenerate the ui component reference; keep the issue id out of customer text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- content/docs/references/ui/component.mdx | 22 ++++++++++++++++++++-- packages/spec/src/ui/component.zod.ts | 4 ++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 07fcd74f14..2e3c935754 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -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 | @@ -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 | + --- @@ -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 | @@ -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 | diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 16b6f5a870..1de632a2b2 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -2523,7 +2523,7 @@ export const ObjectGridPropsSchema = lazySchema(() => strictObject({ * array shape and not the string. */ sort: z.array(SortItemSchema).optional() - .describe('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 — objectui#8221 decision batch #77, option B, retired it'), + .describe('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`'), /** * REMOVED (#11805, maintainer ruling 2026-08-25, decision-inbox batch 4: * 「#11805 退役 defaultSort,不需要major」 — the ADR-0049 enforce-or-remove @@ -2892,7 +2892,7 @@ export const ObjectCalendarPropsSchema = lazySchema(() => strictObject({ * no `sort` input at all, so nothing on the registry side moves. */ sort: z.array(SortItemSchema).optional() - .describe('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 — objectui#8221 decision batch #77, option B, retired it'), + .describe('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: z.array(z.unknown()).optional().describe('Pre-fetched records — skips the internal fetch'), staticData: z.array(z.unknown()).optional().describe('Static inline records'), locale: z.string().optional().describe('Locale override for the calendar chrome'),