diff --git a/.changeset/17053-list-view-sort-string-clause-retired.md b/.changeset/17053-list-view-sort-string-clause-retired.md new file mode 100644 index 0000000000..e0f886baea --- /dev/null +++ b/.changeset/17053-list-view-sort-string-clause-retired.md @@ -0,0 +1,68 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec)!: `ListViewSchema.sort` retires the bare string clause — the PRODUCER half of the sort seam, so the contract stops minting documents its own consumer refuses (#17053; objectui#8221, decision batch #77 option B) + + + +**BREAKING** accept-set narrowing at `view.sort` — the list-view doors +(`ListViewSchema`, and the `ObjectListViewSchema` copy behind `object.list` / +`object.listViews.*`) — shipped as `minor` under this repo's launch-window +convention for breaking changes, the same grade its sibling +`object-block-sort-item-array` took for the two `ComponentPropsMap` doors. The +mechanical prescription is registered under protocol major 18 as +`list-view-sort-string-clause-to-array`. + +**Why this is graded on the seam, not on the string.** objectui ruled one sort +orthography platform-wide — the array (objectui#8221, decision batch #77, +2026-09-07, option B) — and objectui PR #8758 executes it: `convertSortToQueryParams` +refuses a runtime string and its diagnostic names the array form. `ListViewSchema` +is the producer of exactly those documents: `object.list.sort` is what +`deriveRelatedLists` reads. So until this release a view authored with +`sort: 'created_at desc'` **validated here, cleanly, and then failed downstream** — +the contract minting a shape its consumer rejects, with the author told off by +the wrong layer. Re-measured on this tree before the change, with `bogusProp` +refused by name on the same call as the firing control: `'name desc'`, `'-name'` +and the array form all returned `success: true`, and only a bare number was +refused (`sort/invalid_union`). + +`sort` survives as a key, one union arm lighter, so this is a VALUE narrowing with +no `retiredKey()` tombstone to hang a prescription on. The surviving array member's +own `error` map carries it, keyed on `issue.input` being a string — the same shape +`view.type`'s retired `'page'` value and `view.exportOptions`' retired `'pdf'` value +already use in this schema. Every other invalid value (a number, an object, a +string reaching a *descendant* such as a misspelled `order`) keeps zod's default +report, so nobody is told a clause they never wrote "was removed". + +**Migration** (`list-view-sort-string-clause-to-array`, a D2 conversion, not a +semantic TODO — the rewrite is lossless and wholly mechanical): +`sort: 'created_at desc'` becomes `sort: [{ field: 'created_at', order: 'desc' }]`; +a bare field name meant ascending, so `sort: 'created_at'` becomes +`sort: [{ field: 'created_at', order: 'asc' }]` — `order` is required on the entry +and is written out rather than omitted; a comma-separated clause becomes one array +entry per key, in the same order. `os migrate meta --from 17` lists these edits for +author sources, and stored rows replay them through `applyConversionsToStoredItem`. + +**The narrowing was not free, and the population was measured rather than assumed.** +A tree-wide census over both the TS and JSON spellings of a string-valued `sort`, +read as STRUCTURES rather than counted as tokens, found the clause authored on +three live in-tree sites, all converted here: the shipped showcase list view +`examples/app-showcase/src/ui/views/task.view.ts` (`'estimate_hours desc'`, carried +since objectui#2601 as a deliberate live coverage fixture for the string form), the +frozen `packages/lint` snapshot of that same shipped shape, and the published +`skills/objectstack-ui` list-view rule. The census fired: it *found* documents, and +`tsc` independently reds on the first two the moment the arm is removed. Sites +deliberately NOT converted, having been read rather than grepped: ObjectQL +`query.sort` and the wire `normalizeSortNodes` (different doors, different +dialects), `packages/spec`'s `book`/`doc` field-mapping records whose `sort: 'order'` +is an unrelated key of the same name, and the `packages/lint` rule fixtures, which +feed the PRE-parse walker and never reach this schema. + +**Not moved by this release.** `RecordRelatedListProps.sort` keeps its declared +string arm. That string is the `'field'` / `'-field'` dialect normalised by +objectui's own `RelatedList.normalizeSortSpec`; it never reaches +`convertSortToQueryParams`, and retiring it was not ruled. For the same reason the +conversion above declines any clause that does not parse as ` [asc|desc]`: +guessing a direction for `'-name'` would invent an ordering the author never wrote, +so on a list view it meets the door's prescription instead. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index efe38cc223..3aecb3609d 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1632,7 +1632,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | -| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | | **resizable** | `boolean` | optional | Enable column resizing | @@ -1717,7 +1717,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | -| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | | **resizable** | `boolean` | optional | Enable column resizing | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 4572821e29..3cb6df48db 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -366,7 +366,7 @@ const result = ApiMethod.parse(data); | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | -| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | | **resizable** | `boolean` | optional | Enable column resizing | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 9394710c28..c75b71b694 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -789,7 +789,7 @@ Map view configuration | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | -| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | | **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields | @@ -1195,7 +1195,7 @@ Tab configuration for multi-tab view interface | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | -| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | | **resizable** | `boolean` | optional | Enable column resizing | @@ -1791,7 +1791,7 @@ Tab configuration for multi-tab view interface | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | -| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | | **resizable** | `boolean` | optional | Enable column resizing | @@ -1876,7 +1876,7 @@ Tab configuration for multi-tab view interface | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | -| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | | **resizable** | `boolean` | optional | Enable column resizing | @@ -2116,7 +2116,7 @@ This schema accepts one of the following structures: | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | -| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | | **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields | @@ -2292,7 +2292,7 @@ This schema accepts one of the following structures: | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | | **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | -| **sort** | `string \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | +| **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | | **userFilters** | `{ element?: Enum<'dropdown' \| 'tabs' \| 'toggle'>; fields?: object[]; tabs?: object[]; showAllRecords?: boolean; … }` | optional | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields | diff --git a/examples/app-showcase/src/ui/views/task.view.ts b/examples/app-showcase/src/ui/views/task.view.ts index 660ca568f0..a83cbee476 100644 --- a/examples/app-showcase/src/ui/views/task.view.ts +++ b/examples/app-showcase/src/ui/views/task.view.ts @@ -200,12 +200,12 @@ export const TaskViews = defineView({ { field: 'estimate_hours' }, ], - // @objectstack/spec ListViewSchema.sort accepts a bare STRING - // ("field [asc|desc]"), not only the {field,order}[] array form. This - // is the exact shape that used to crash the renderer with - // "schema.sort.map is not a function" (objectui#2601) — kept here as a - // live coverage fixture so a real list view exercises the string form. - sort: 'estimate_hours desc', + // [#17053] Was the bare STRING clause `'estimate_hours desc'`, kept here + // as the live coverage fixture for the shape that once crashed the + // renderer (objectui#2601). That arm is RETIRED — one sort spelling + // platform-wide, the array (objectui#8221 decision batch #77) — so this + // is the same ordering in the surviving form. + sort: [{ field: 'estimate_hours', order: 'desc' }], // ADR-0053 — NO `userFilters` here: on an object list view ("views" // mode) the console suppresses them by design (the view switcher is diff --git a/packages/lint/src/showcase-shape.fixtures.ts b/packages/lint/src/showcase-shape.fixtures.ts index e58664a926..04b92178f1 100644 --- a/packages/lint/src/showcase-shape.fixtures.ts +++ b/packages/lint/src/showcase-shape.fixtures.ts @@ -182,8 +182,12 @@ export const SnapshotTaskViews = defineView({ { field: 'status' }, { field: 'estimate_hours' }, ], - // The bare-string `sort` spelling (objectui#2601), kept as authored. - sort: 'estimate_hours desc', + // [#17053] The bare-string `sort` spelling (objectui#2601) was RETIRED + // from `ListViewSchema`; this snapshot tracks the shipped shape through + // `defineView`, so it carries the same ordering in the array form the + // showcase now authors. The snapshot's SUBJECT is the three nameless + // form sections below — untouched. + sort: [{ field: 'estimate_hours', order: 'desc' }], }, grid: { label: 'Grid', diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 0372e16c7c..a1f162e7da 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -39,7 +39,7 @@ for both corollaries. | `skill` | 16 | 0 | 0 | 1 | 0 | 17 | | `dataset` | 27 | 0 | 0 | 0 | 0 | 27 | | `page` | 23 | 0 | 0 | 0 | 1 | 24 | -| `view` | 77 | 0 | 0 | 11 | 0 | 88 | +| `view` | 78 | 0 | 0 | 11 | 0 | 89 | | `report` | 21 | 0 | 0 | 0 | 0 | 21 | | `dashboard` | 45 | 0 | 0 | 10 | 0 | 55 | | `webhook` | 19 | 0 | 0 | 0 | 0 | 19 | @@ -63,4 +63,4 @@ for both corollaries. | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | -| **total** | **862** | **5** | **1** | **95** | **10** | **973** | +| **total** | **863** | **5** | **1** | **95** | **10** | **974** | diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index 0fc10f95ac..8b05bc9eed 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -48,10 +48,7 @@ "status": "live", "note": "objectui: ListView.tsx client-side filter shaping (audit L15); no list-render prop reaches the server query (audit data-flow note)." }, - "sort": { - "status": "live", - "note": "objectui: ListView.tsx (audit L15)." - }, + "sort": {"status": "live", "note": "objectui: ListView.tsx (audit L15).", "children": {"field": {"status": "live", "verifiedAt": "2026-09-13", "evidenceScope": "in-repo", "note": "Read in this repo by `normalizeSortNodes` (packages/metadata-protocol/src/protocol.ts:3468, `fromElement`), which reads `el.field` off every sort entry and is the funnel every metadata ingress passes through; it lowers onto the engine `orderBy`. Drilled here rather than inherited because #17053 retired the bare string arm of this union, which is what made the entry's own keys visible to the walk."}, "order": {"status": "live", "verifiedAt": "2026-09-13", "evidenceScope": "in-repo", "note": "Read in this repo by `normalizeSortNodes` (packages/metadata-protocol/src/protocol.ts:3468) alongside `field`, and validated by its `direction()` helper (:3447) which refuses anything but 'asc'/'desc'. Divergence from `field` would be the signal worth recording; there is none \u2014 a sort entry is inert without both."}}}, "searchableFields": { "status": "live", "note": "objectui: ListView.tsx (audit L15)." diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 0095849f94..99a9f87f3e 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -9376,6 +9376,126 @@ const viewPageMountRemoved: MetadataConversion = { }, }; +/** + * [#17053] The bare string `sort` clause on a list-view payload becomes the + * `{ field, order }[]` array — one sort orthography platform-wide. + * + * The ruling is objectui#8221's decision batch #77 (2026-09-07, option B), the + * same one `object-block-sort-item-array` carries for the two + * `ComponentPropsMap` doors. Its consumer half, objectui PR #8758, makes + * `convertSortToQueryParams` REFUSE a runtime string — which is what made this + * a p2 rather than a spelling preference: `ListViewSchema` was the PRODUCER of + * those documents, so a view authored with `sort: 'created_at desc'` validated + * upstream and failed downstream, and the author was told off by the wrong + * layer. + * + * The rewrite is lossless and wholly mechanical, which is why this is a D2 + * conversion rather than a semantic TODO: `'created_at desc'` carries exactly + * the tuple `{ field: 'created_at', order: 'desc' }`; a bare field name meant + * ASCENDING, so it is written out as `order: 'asc'` rather than omitted + * (`order` is required on the entry); and the comma-separated multi-key form + * the wire normalizer splits on becomes one entry per key, in the same order. + * + * ⚠️ A string that does NOT parse as that grammar is left ALONE and emits + * nothing — the `'-field'` OData-ish dialect above all. That dialect belongs to + * `RecordRelatedListProps.sort`, is normalised by objectui's own + * `RelatedList.normalizeSortSpec`, never reaches `convertSortToQueryParams`, + * and retiring it was NOT ruled; guessing a direction for it here would invent + * an ordering the author never wrote. On a list view it now meets + * {@link LIST_VIEW_SORT_STRING_RETIRED} at the door instead, which is the + * honest outcome for a clause this conversion cannot lower. + * + * `retiredFromLoadPath`: the union refuses the string by name with a + * prescription, so a LIVE author is taught at parse rather than silently + * rewritten. The entry exists so stored 17.x rows replay clean through + * `applyConversionsToStoredItem`, and so `os migrate meta --from 17` lists the + * mechanical edits for author sources — which it has real work to do on: the + * in-tree census found the clause authored on a shipped showcase list view. + * + * ⚠️ Coverage boundary, stated rather than left to be discovered: this walks + * `stack.views[]` in all three persisted spellings ({@link mapViewPayloads}) — + * the same reach `view-page-mount-removed` and `view-export-options-pdf-removed` + * have, and the same reach the conversion walk offers. `objects[].listViews.*` + * is NOT reached by any conversion in this registry, so an object body carrying + * a string clause is refused at its own door rather than converted. + */ +const listViewSortStringClauseToArray: MetadataConversion = { + id: 'list-view-sort-string-clause-to-array', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'view.list.sort / view.listViews.*.sort — the bare string sort clause', + summary: + 'the bare string list-view `sort` clause becomes the `{ field, order }[]` array (#17053 — ' + + 'one sort orthography platform-wide, so the schema stops minting documents its own ' + + 'consumer refuses; objectui#8221 decision batch #77 option B)', + apply(stack, emit) { + /** `'a desc, b'` -> `[{field:'a',order:'desc'},{field:'b',order:'asc'}]`, or `null`. */ + const lower = (clause: string): Array<{ field: string; order: 'asc' | 'desc' }> | null => { + const parts = clause.split(',').map((p) => p.trim()).filter((p) => p.length > 0); + if (parts.length === 0) return null; + const entries: Array<{ field: string; order: 'asc' | 'desc' }> = []; + for (const part of parts) { + // `` or ` `, and nothing else — a leading `-` + // is the related-list dialect this conversion deliberately declines. + const m = /^([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)(?:\s+(asc|desc))?$/i + .exec(part); + if (!m) return null; + entries.push({ field: m[1]!, order: (m[2]?.toLowerCase() as 'asc' | 'desc') ?? 'asc' }); + } + return entries; + }; + + return mapViewPayloads(stack, (payload, kind, path) => { + if (kind !== 'list' || typeof payload.sort !== 'string') return payload; + const lowered = lower(payload.sort); + if (!lowered) return payload; + emit({ from: payload.sort, to: JSON.stringify(lowered), path: `${path}.sort` }); + return { ...payload, sort: lowered }; + }); + }, + fixture: { + before: { + views: [{ + object: 'crm_opportunity', + // The single-key clause, the shape the showcase shipped. + list: { sort: 'created_at desc', columns: ['name'] }, + listViews: { + // A bare field name: ascending, written out rather than omitted. + bare: { type: 'grid', sort: 'amount', columns: ['name'] }, + // The comma-separated multi-key form, one entry per key, in order. + multi: { type: 'grid', sort: 'stage asc, amount desc', columns: ['name'] }, + // Already canonical -> untouched, by reference. + canonical: { + type: 'grid', + sort: [{ field: 'amount', order: 'desc' }], + columns: ['name'], + }, + }, + }], + }, + after: { + views: [{ + object: 'crm_opportunity', + list: { sort: [{ field: 'created_at', order: 'desc' }], columns: ['name'] }, + listViews: { + bare: { type: 'grid', sort: [{ field: 'amount', order: 'asc' }], columns: ['name'] }, + multi: { + type: 'grid', + sort: [{ field: 'stage', order: 'asc' }, { field: 'amount', order: 'desc' }], + columns: ['name'], + }, + canonical: { + type: 'grid', + sort: [{ field: 'amount', order: 'desc' }], + columns: ['name'], + }, + }, + }], + }, + expectedNotices: 3, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -9474,6 +9594,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { expect(() => ListViewSchema.parse(listView)).not.toThrow(); }); - it('should accept legacy string sort format', () => { - const listView: ListView = { - columns: ['name'], - sort: 'created_at desc', - }; + /** + * [#17053] The bare string `sort` clause is RETIRED — objectui#8221's + * decision batch #77 (2026-09-07, option B: one spelling, the array), whose + * consumer half is objectui PR #8758 (`convertSortToQueryParams` refuses a + * runtime string). Until this landed, `ListViewSchema` was the PRODUCER of + * the documents that consumer refuses. + * + * This replaces the accept pin that used to live here. Both directions are + * pinned, because a refusal pin alone cannot tell "the string is refused" + * from "the whole key is broken": + * + * - NEGATIVE — the string is refused AT `sort`, with the prescription that + * names the surviving array form (not a bare `invalid_type` report); + * - POSITIVE — the array form parses unchanged, value-for-value. + * + * The `unrecognized_keys` control on the same call is what makes the + * negative a verdict rather than a schema reporting nothing. + */ + describe('[#17053] the legacy string `sort` clause is retired', () => { + const withSort = (sort: unknown) => ({ columns: ['name'], sort }); - expect(() => ListViewSchema.parse(listView)).not.toThrow(); + it('REFUSES the bare string clause at `sort`, prescribing the array form', () => { + const result = ListViewSchema.safeParse(withSort('created_at desc')); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'sort'); + expect(issue, JSON.stringify(result.error!.issues)).toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + // The prescription, not a bare "expected array": it names what was + // removed, and the spelling that replaces it. + expect(issue!.message).toMatch(/bare string `sort` clause was removed.*`sort: \[\{ field: 'created_at', order: 'desc' \}\]`/s); + }); + + it("REFUSES the '-field' spelling at `sort` too — same clause, same door", () => { + const result = ListViewSchema.safeParse(withSort('-created_at')); + expect(result.success).toBe(false); + expect(result.error!.issues.some((i) => i.path.join('.') === 'sort')).toBe(true); + }); + + it('still ACCEPTS the array form, value-for-value', () => { + const sort = [ + { field: 'created_at', order: 'desc' as const }, + { field: 'name', order: 'asc' as const }, + ]; + const result = ListViewSchema.safeParse(withSort(sort)); + expect(result.success, JSON.stringify(result.success ? {} : result.error.issues)).toBe(true); + expect(result.data!.sort).toEqual(sort); + }); + + it('keeps zod\'s default report for every OTHER invalid value', () => { + // The prescription is keyed on `issue.input` being a string, so a number + // is NOT told a clause it never wrote "was removed"… + const num = ListViewSchema.safeParse(withSort(42)); + expect(num.success).toBe(false); + expect(num.error!.issues.find((i) => i.path.join('.') === 'sort')!.message) + .not.toMatch(/was removed/); + // …and neither is a string reaching a DESCENDANT of the array. + const bad = ListViewSchema.safeParse(withSort([{ field: 'a', order: 'descending' }])); + expect(bad.success).toBe(false); + expect(bad.error!.issues.some((i) => i.message.includes('was removed'))).toBe(false); + }); + + it('CONTROL: an undeclared key is still refused BY NAME on the same call', () => { + const result = ListViewSchema.safeParse({ ...withSort('created_at desc'), bogusProp: 1 }); + expect(result.success).toBe(false); + expect(result.error!.issues.some( + (i) => i.code === 'unrecognized_keys' && JSON.stringify(i).includes('bogusProp'), + )).toBe(true); + }); + + it('the derived `ObjectListViewSchema` inherits the refusal', () => { + // `object.list` / `object.listViews.*` is the slot that actually feeds + // objectui's `deriveRelatedLists`, i.e. the producer end of the seam. + expect(ObjectListViewSchema.safeParse(withSort('created_at desc')).success).toBe(false); + expect(ObjectListViewSchema.safeParse( + withSort([{ field: 'created_at', order: 'desc' }]), + ).success).toBe(true); + }); }); it('should accept list view with searchable fields', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 046557c80e..6053ffc205 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1745,6 +1745,46 @@ const LIST_VIEW_PAGE_NAME_RETIRED = + 'different surface and is the page mount that has always rendered. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; +/** + * [#17053] Prescription for the retired bare-string `sort` clause on the + * list-view `sort` union. + * + * The seam this closes, and the reason the grade sat on the ASYMMETRY rather + * than on the string: objectui ruled one sort spelling platform-wide — the + * array (decision batch #77, 2026-09-07, option B) — and objectui PR #8758 + * executes it, so `convertSortToQueryParams` REFUSES a runtime string and + * names the array form in its diagnostic. Until this change `ListViewSchema` + * was the PRODUCER of exactly the documents that consumer refuses: a view + * authored with `sort: 'created_at desc'` validated here, cleanly, and then + * failed downstream — the contract minting a shape its consumer rejects, with + * the author told off by the wrong layer. + * + * Like {@link LIST_VIEW_TYPE_PAGE_RETIRED} and + * {@link LIST_VIEW_EXPORT_PDF_RETIRED}, this retires a VALUE spelling, not a + * key: `sort` survives, one union arm lighter, so there is no `retiredKey()` + * tombstone to hang a prescription on. The array member's own `error` map + * carries it, keyed on `typeof issue.input === 'string'` so only the spelling + * that used to be legal gets the "was removed" message — every other invalid + * value keeps zod's default report. + * + * ⛔ `RecordRelatedListProps.sort` is deliberately NOT moved with it: that + * string is the `'field'` / `'-field'` dialect normalised by objectui's own + * `RelatedList.normalizeSortSpec`, it never reaches + * `convertSortToQueryParams`, and retiring it was not ruled. + */ +const LIST_VIEW_SORT_STRING_RETIRED = + 'The bare string `sort` clause was removed from `view.sort` in @objectstack/spec 17.5.0 ' + + '(ADR-0049 enforce-or-remove) — the platform converged on ONE sort orthography, the array, ' + + 'and the consumer that lowers a list view\'s sort into a query now refuses a runtime string ' + + 'outright, so a view authored with the clause validated here and then failed at the renderer. ' + + 'Rewrite the clause as the structured array: `sort: \'created_at desc\'` becomes ' + + '`sort: [{ field: \'created_at\', order: \'desc\' }]`, and a bare field name ' + + '`sort: \'created_at\'` meant ascending, so it becomes ' + + '`sort: [{ field: \'created_at\', order: \'asc\' }]` — `order` is required on the entry and ' + + 'is written out rather than omitted; a comma-separated clause becomes one array entry per ' + + 'key, in the same order. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + const VIEW_CALENDAR_ALLOWED_NEEDS_START_DATE = "`appearance.allowedVisualizations` includes 'calendar', so end users can switch this view to a " + 'calendar — but no `calendar:` block says which field supplies the event date. There is no ' @@ -1915,12 +1955,16 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ ]).describe('Fields to display as columns'), filter: z.array(ViewFilterRuleSchema).optional().describe('Filter criteria (JSON Rules)'), /** - * Sort order. Prefer the structured `{ field, order }[]` form. + * Sort order — the structured `{ field, order }[]` array, and only that. * - * @deprecated The bare string form (`"field desc"`) is legacy and retained - * only for backward compatibility (it was the exact shape that crashed the - * renderer in objectui#2601 — kept covered by a live fixture). Removal will - * go through its own deprecation cycle; do not drop it here. + * [#17053] The bare string clause (`"field desc"`) was REMOVED here. It was + * carried "only for backward compatibility" pending its own deprecation + * cycle; objectui#8221's decision batch #77 (2026-09-07, option B — one + * spelling, the array) IS that cycle, and objectui PR #8758 already refuses + * the string at the consumer. Leaving the arm declared kept this schema + * minting documents its own consumer rejects. An enum-VALUE narrowing has no + * tombstone to hang a prescription on, so the array member's `error` map + * carries {@link LIST_VIEW_SORT_STRING_RETIRED}, keyed on `issue.input`. */ /** * ⚠️ [#5074] CLOSED — the entry is the authoring shape and rejects the @@ -1947,24 +1991,32 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ * put a UI artifact on the authorable surface and teach an AI author to emit * one (批 18 Q1, two-axis rejection on record). */ - sort: z.union([ - z.string(), //Legacy "field desc" - z.array(strictObject({ - surface: 'this sort entry', - history: VIEW_HISTORY, - aliases: { - // #4721: the same tuple under a different word. Edit distance cannot - // reach it, and getting it wrong reverses the sort silently. - direction: 'order', - }, - guidance: { - id: VIEW_CONSOLE_ROW_ID_GUIDANCE, - }, - }, { - field: z.string(), - order: z.enum(['asc', 'desc']) - })) - ]).optional(), + sort: z.array(strictObject({ + surface: 'this sort entry', + history: VIEW_HISTORY, + aliases: { + // #4721: the same tuple under a different word. Edit distance cannot + // reach it, and getting it wrong reverses the sort silently. + direction: 'order', + }, + guidance: { + id: VIEW_CONSOLE_ROW_ID_GUIDANCE, + }, + }, { + field: z.string(), + order: z.enum(['asc', 'desc']) + }), { + // Only the spelling that used to be legal gets the retirement message; a + // number, an object, anything else keeps zod's default `invalid_type`. + // Guarded on the ARRAY's own issue (`invalid_type` at this node) so a + // string reaching a DESCENDANT — a misspelled `order`, say — is never + // answered with a prescription about a clause the author did not write. + error: (issue) => ( + issue.code === 'invalid_type' && typeof issue.input === 'string' + ? LIST_VIEW_SORT_STRING_RETIRED + : undefined + ), + }).optional(), /** Search & Filter */ searchableFields: z.array(z.string()).optional().describe('Fields enabled for search'), diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index 86df7da99e..88cccfffd1 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -67,7 +67,7 @@ Server-connected object table with toolbar and switchable visualizations (grid/k | `onNavigate` | `(recordId, action: 'view' \| 'edit') => void` | callback | | Called for page-level navigation. | | `columns` | `string[] \| object[]` | data | ✓ | Fields to display as columns | | `type` | `'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'` | data | | | -| `sort` | `string \| object[]` | data | | | +| `sort` | `object[]` | data | | | | `searchableFields` | `string[]` | data | | Fields enabled for search | | `userFilters` | `object` | data | | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields | | `pagination` | `object` | data | | Pagination configuration | diff --git a/skills/objectstack-ui/rules/list-views.md b/skills/objectstack-ui/rules/list-views.md index f301ad6c4d..8ec806a544 100644 --- a/skills/objectstack-ui/rules/list-views.md +++ b/skills/objectstack-ui/rules/list-views.md @@ -243,10 +243,7 @@ that maintain it, and why a `formula` field cannot be the mirror — lives in ### Sorting ```typescript -// Simple -sort: 'created_at desc' - -// Multi-field +// `order` is required. sort: [ { field: 'priority', order: 'desc' }, { field: 'created_at', order: 'asc' }, @@ -271,7 +268,7 @@ top-level `groupBy`**. The top-level `columns` is required on every list view summarizeField: 'estimate_hours', // optional — summed at the top of each column columns: ['title', 'assignee', 'priority'], // fields shown on each card }, - sort: 'priority desc', + sort: [{ field: 'priority', order: 'desc' }], } ```