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
68 changes: 68 additions & 0 deletions .changeset/17053-list-view-sort-string-clause-retired.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered list-view-sort-string-clause-to-array -->

**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 `<field> [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.
4 changes: 2 additions & 2 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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 |
Expand Down Expand Up @@ -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<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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 |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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 |
Expand Down
12 changes: 6 additions & 6 deletions content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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 |
Expand Down Expand Up @@ -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<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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 |
Expand Down Expand Up @@ -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<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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 |
Expand Down Expand Up @@ -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<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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 |
Expand Down Expand Up @@ -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<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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 |
Expand Down Expand Up @@ -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<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; 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 |
Expand Down
12 changes: 6 additions & 6 deletions examples/app-showcase/src/ui/views/task.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions packages/lint/src/showcase-shape.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/liveness/state-counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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** |
Loading
Loading