Skip to content

Commit 9a853f2

Browse files
os-warrenclaude
andauthored
feat(core)!: retire the legacy string sort clause — one spelling, the array (objectui#8221) (#8758)
* feat(core)!: retire the legacy string `sort` clause — one spelling, the array `convertSortToQueryParams` drops the string arm (objectui#8221, director ruling, decision batch #77, option B). Its signature narrows to `QuerySortEntry[]`, and a string that still reaches it at runtime is REFUSED with a `console.error` naming the array form, quoting what arrived and stating that the query carries no `$orderby` — never a silent drop. Every declaration that published a string arm narrows with it, TypeScript face and zod mirror together: `ObjectGridSchema.sort`, `ObjectMapSchema.sort`, `ObjectGanttSchema.sort`, plus the local `sort` inputs on `LineItemsPanel`, `ObjectTimeline` and `deriveRelatedLists`'s ListView reader. Docs teach the array only. Deliberately untouched, measured rather than assumed: `record:related_list`'s `'field'` / `'-field'` string is a DIFFERENT dialect, normalized by `RelatedList.normalizeSortSpec`, and never reaches this sink. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w * fix(examples,tooling): migrate the one authored string `sort` fixture and re-key the moved doc-example ledger row `examples/schema-catalog/src/schemas/plugin-view/object-view-list.json` authored `table.sort: "name asc"` — the retired clause. `pnpm check` reported it by name ("did not validate as an ObjectUI schema"), and `objectui validate` gave the reason: `table -> sort` expected array, received string. Migrated to `[{ "field": "name", "order": "asc" }]`; the warning count moved 4 -> 3, which is the firing control that the fixed file is the one that moved. `scripts/check-doc-example-types.mjs`'s ledger is keyed by FILE:LINE, and the `objectql.ts` edits shifted `ObjectFormSchema`'s example block 1604 -> 1607. Re-keyed; `scripts/__tests__/check-doc-example-types.test.ts` is green again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 33f4a19 commit 9a853f2

25 files changed

Lines changed: 551 additions & 125 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
'@object-ui/core': minor
3+
'@object-ui/types': minor
4+
'@object-ui/app-shell': minor
5+
'@object-ui/plugin-form': minor
6+
'@object-ui/plugin-timeline': minor
7+
'@object-ui/plugin-view': patch
8+
'@object-ui/plugin-map': patch
9+
---
10+
11+
Retire the legacy string `sort` clause: one spelling, the array
12+
(objectui#8221) — `convertSortToQueryParams` now REFUSES `"name desc"` with a
13+
diagnostic naming `[{ field: 'name', order: 'desc' }]`, instead of lowering it.
14+
15+
**BREAKING for `@object-ui/core` consumers — scored `minor`, not `major`, per
16+
AGENTS.md 版本号策略** (every package is in one fixed group, so a `major` here
17+
would carry all 39 off the `@objectstack` major this repo is pinned to). The
18+
breaking semantics are stated below rather than encoded in the version.
19+
20+
Director ruling, decision batch #77 (2026-09-07), option B. Three faces
21+
disagreed about one key: `@object-ui/core` implemented the string clause
22+
on purpose (`sort-query.ts`, docblock and all), `content/docs/plugins/plugin-map.mdx`
23+
taught it as `sort?: string | SortConfig[]`, and the html tier answered
24+
`type-mismatch` for it because all seven `sort` registrations publish
25+
`type: 'array'` alone — while `@objectstack/spec` refuses the string outright on
26+
`element-record-picker`. Option A (per-block string arms) was rejected by name:
27+
it would make one key mean different things on different blocks.
28+
29+
**What moves.** `convertSortToQueryParams(sort)` narrows from
30+
`string | QuerySortEntry[]` to `QuerySortEntry[]`, and the three declarations
31+
that published a string arm narrow with it — `ObjectGridSchema.sort`,
32+
`ObjectMapSchema.sort` and `ObjectGanttSchema.sort`, in the TypeScript face AND
33+
in the zod mirror, together, because a narrowing that left `z.string()` in the
34+
mirror is the declared-vs-enforced split this change exists to close. The local
35+
`sort` declarations on `LineItemsPanel`, `ObjectTimeline` and
36+
`deriveRelatedLists`'s ListView input narrow the same way.
37+
38+
**What a string does now.** Types are erased, so the signature stops a string
39+
only at compile time; authored JSON and stored `sys_metadata` rows still reach
40+
the sink carrying `"name desc"`. Such a value is REFUSED — the query carries no
41+
`$orderby` — and `console.error` names the array form, quotes what arrived and
42+
states the consequence, once per spelling. A silent `undefined` was the one
43+
outcome the ruling ruled out.
44+
45+
**Measured consequences you may see.** A related list that inherited its child
46+
object's default list-view sort in the legacy spelling stops inheriting it (the
47+
console says so). `@objectstack/spec@17.3.0` still ACCEPTS the string on
48+
`ListViewSchema.sort` and on `RecordRelatedListProps.sort`, so such metadata is
49+
still spec-legal today; the spec-side pull-back is its own card. Two surfaces
50+
are deliberately untouched, because they are a DIFFERENT string dialect that
51+
never reaches this sink: `record:related_list`'s `'field'` / `'-field'` form,
52+
normalized by `RelatedList.normalizeSortSpec`, and `ListView.parseSortConfig`,
53+
which reads the platform view record the spec still blesses.
54+
55+
Docs teach the array only: `content/docs/plugins/plugin-map.mdx`,
56+
`content/docs/plugins/plugin-view.mdx` and `packages/plugin-view/README.md`.

‎content/docs/plugins/plugin-map.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ const schema: ObjectMapSchema = {
109109
data?: ViewData, // Advanced data configuration (read first)
110110
// At least one of data / staticData / objectName is required
111111
filter?: Array<any>, // Query filter, sent as $filter
112-
sort?: string | SortConfig[], // Sort, sent as $orderby
112+
sort?: SortConfig[], // Sort, sent as $orderby
113113
map?: ObjectMapConfig, // Map-specific configuration
114114
enableClustering?: boolean, // Cluster nearby markers (auto past 100)
115115
navigation?: NavigationConfig, // Record navigation (drawer/dialog/page)

‎content/docs/plugins/plugin-view.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ the canonical one:
148148
| `pagination: { pageSize, pageSizeOptions? }` | `pageSize: number` |
149149
| `selection: { type: 'single' \| 'multiple' \| 'none' }` | `selectable: boolean \| 'single' \| 'multiple'` |
150150
| `filter: [{ field, operator, value }, …]` (same shape as a named view's `filter`) | `defaultFilters: Record<field, value>` (equality-only) |
151-
| `sort: 'field direction'` or `SortConfig[]` | `defaultSort: { field, order }` (**no string form** — that arity only exists on `sort`) |
151+
| `sort: SortConfig[]` (`[{ field, order }]`) | `defaultSort: { field, order }` (a single entry, not an array) |
152152

153153
Before objectui#5102, `pagination` / `selection` / `filter` / `sort` had **no
154154
read point at all** in this file: an author who wrote the canonical shape
@@ -269,7 +269,7 @@ const userDirectory: ObjectViewSchema = {
269269
defaultViewType: 'grid',
270270
table: {
271271
columns: ['name', 'email', 'role', 'created_at'],
272-
sort: 'created_at desc', // or [{ field: 'created_at', order: 'desc' }]
272+
sort: [{ field: 'created_at', order: 'desc' }],
273273
},
274274
};
275275
```

‎examples/schema-catalog/src/schemas/plugin-view/object-view-list.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"searchableFields": ["name", "email", "department"],
1212
"table": {
1313
"columns": ["name", "email", "role", "department", "status"],
14-
"sort": "name asc",
14+
"sort": [{ "field": "name", "order": "asc" }],
1515
"pagination": { "pageSize": 5 }
1616
}
1717
}

‎packages/app-shell/src/utils/__tests__/deriveRelatedLists.inheritSort.test.ts‎

Lines changed: 71 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
* ## The dialect trap this file exists to pin
2121
*
2222
* `ListView.sort` and `record:related_list.sort` declare the SAME union
23-
* (`string | Array<{field, order}>`) and mean DIFFERENT things by the string
24-
* arm:
23+
* (`string | Array<{field, order}>`) in `@objectstack/spec` and mean DIFFERENT
24+
* things by the string arm:
2525
*
2626
* - ListView's string is the legacy space-separated clause, `'seq_no desc'`
2727
* (`@objectstack/spec` `ui/view.zod.ts`, annotated `Legacy "field desc"`);
@@ -30,18 +30,38 @@
3030
*
3131
* Inheriting the string verbatim therefore does not produce "a sort in another
3232
* notation" — it produces `$orderby` on a field whose NAME is the seven
33-
* characters `seq_no desc`, which no object has. The route taken (and pinned
34-
* below) is to normalize at this boundary, always to the ARRAY arm, through
35-
* `@object-ui/core`'s `convertSortToQueryParams` — the repo's one definition of
36-
* both authored dialects — so no second parser of the legacy string exists to
37-
* drift from it.
33+
* characters `seq_no desc`, which no object has. The route taken is to resolve
34+
* that at this boundary, always to the ARRAY arm, through `@object-ui/core`'s
35+
* `convertSortToQueryParams`, so no second parser of the legacy string exists
36+
* to drift from it. `deriveRelatedLists` is the ONE place that knows it is
37+
* reading a ListView and writing a related list, which is why the resolution
38+
* belongs here and not as a tolerant reader on the consuming end
39+
* (AGENTS.md #0.1).
3840
*
39-
* `deriveRelatedLists` is the ONE place that knows it is reading a ListView and
40-
* writing a related list, which is why the translation belongs here and not as
41-
* a tolerant reader on the consuming end (AGENTS.md #0.1).
41+
* ## What objectui#8221 changed, and what it did NOT
42+
*
43+
* Decision batch #77 (option B) RETIRED the legacy space-separated clause:
44+
* `convertSortToQueryParams` no longer lowers it, it REFUSES it with a
45+
* diagnostic naming the array form. So this boundary no longer TRANSLATES a
46+
* ListView string — it drops it and says so, and the pins below moved with it.
47+
*
48+
* ⚠️ The trap the old translation prevented is still prevented, and that is the
49+
* assertion worth keeping: a legacy string must never reach the wire as a FIELD
50+
* NAME. "Refused, loudly" and "translated" both satisfy that; "forwarded
51+
* verbatim" does not, and is what a later well-meaning simplification here
52+
* would reintroduce.
53+
*
54+
* ⚠️ Measured, and the reason this is a behaviour change rather than a
55+
* tidy-up: `@objectstack/spec@17.3.0`'s `ListViewSchema.sort` STILL accepts the
56+
* string (`'name desc'` parses; `42` is refused `invalid_union`; a `bogusProp`
57+
* control is refused by name on the same call). A platform view carrying the
58+
* legacy clause is therefore still spec-legal and stops being inherited here.
59+
* The spec-side pull-back is its own card; until it lands, this diagnostic is
60+
* the only thing standing between an operator and a silently unordered list.
4261
*/
4362

44-
import { describe, it, expect } from 'vitest';
63+
import { describe, it, expect, vi } from 'vitest';
64+
import { resetRetiredSortSpellingReports } from '@object-ui/core';
4565
import { deriveRelatedLists } from '../deriveRelatedLists';
4666

4767
const PARENT = { name: 'task_version', label: 'Task Version', fields: {} };
@@ -89,23 +109,49 @@ describe('deriveRelatedLists — inherited default list-view sort (objectui#5795
89109
]);
90110
});
91111

92-
it('THE DIALECT PIN — normalizes the legacy space-separated string arm', () => {
93-
const entry = derive(childWithList({ sort: 'seq_no desc' }));
94-
expect(entry.sort).toEqual([{ field: 'seq_no', order: 'desc' }]);
95-
// Stated as its own assertion because it is the whole failure mode: an
96-
// un-normalized inherit yields a FIELD literally named `seq_no desc`.
97-
expect(entry.sort?.[0].field).toBe('seq_no');
98-
expect(entry.sort?.[0].field).not.toBe('seq_no desc');
99-
});
112+
it('THE RETIREMENT PIN — a legacy string arm is REFUSED, and refused OUT LOUD (objectui#8221)', () => {
113+
resetRetiredSortSpellingReports();
114+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
115+
try {
116+
const entry = derive(childWithList({ sort: 'seq_no desc' }));
100117

101-
it('reads a bare legacy string as ascending', () => {
102-
expect(derive(childWithList({ sort: 'seq_no' })).sort).toEqual([
103-
{ field: 'seq_no', order: 'asc' },
104-
]);
118+
// Nothing is inherited: the key is ABSENT, exactly as for a child that
119+
// declared no order at all.
120+
expect('sort' in entry).toBe(false);
121+
// The list itself is still derived — so the missing key means "this
122+
// order was refused", not "this derivation collapsed".
123+
expect(entry.childObject).toBe('check_item');
124+
125+
// The original failure mode stays impossible: the seven characters
126+
// `seq_no desc` must never travel as a FIELD NAME.
127+
expect(JSON.stringify(entry)).not.toContain('seq_no desc');
128+
129+
// And it is LOUD. A silent drop here is an operator's row order
130+
// disappearing with nothing in the console to explain it.
131+
expect(errorSpy).toHaveBeenCalledTimes(1);
132+
const message = String(errorSpy.mock.calls[0][0]);
133+
expect(message).toContain("[{ field: 'name', order: 'desc' }]");
134+
expect(message).toContain('"seq_no desc"');
135+
} finally {
136+
errorSpy.mockRestore();
137+
}
105138
});
106139

107-
it('is case-insensitive about the legacy direction word', () => {
108-
expect(derive(childWithList({ sort: 'seq_no DESC' })).sort).toEqual([
140+
it('the other legacy spellings are refused the same way', () => {
141+
for (const spelling of ['seq_no', 'seq_no DESC']) {
142+
resetRetiredSortSpellingReports();
143+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
144+
try {
145+
expect('sort' in derive(childWithList({ sort: spelling }))).toBe(false);
146+
expect(errorSpy).toHaveBeenCalledTimes(1);
147+
} finally {
148+
errorSpy.mockRestore();
149+
}
150+
}
151+
152+
// CONTROL — on the same derivation, the array arm still inherits, so the
153+
// refusals above are about the SPELLING and not a broken boundary.
154+
expect(derive(childWithList({ sort: [{ field: 'seq_no', order: 'desc' }] })).sort).toEqual([
109155
{ field: 'seq_no', order: 'desc' },
110156
]);
111157
});

‎packages/app-shell/src/utils/deriveRelatedLists.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ interface ObjectLike {
140140
* fresh array once the views merge, so the memo over this derivation
141141
* recomputes and the sort appears.
142142
*/
143-
list?: { sort?: string | Array<{ field?: string; order?: 'asc' | 'desc' }> };
143+
list?: { sort?: Array<{ field?: string; order?: 'asc' | 'desc' }> };
144144
}
145145

146146
/**

‎packages/app-shell/src/views/RecordDetailView.relatedListInheritedSort-5795.test.tsx‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6464
import { render, waitFor, cleanup } from '@testing-library/react';
6565
import { MemoryRouter } from 'react-router-dom';
6666
import { MetadataCtx } from '@object-ui/react';
67+
import { resetRetiredSortSpellingReports } from '@object-ui/core';
6768

6869
vi.mock('@object-ui/auth', async (importOriginal) => ({
6970
...(await importOriginal<Record<string, unknown>>()),
@@ -251,13 +252,31 @@ describe('derived related list — inherited $orderby on the wire (objectui#5795
251252
expect(params.$orderby).toEqual([{ field: 'seq_no', order: 'desc' }]);
252253
});
253254

254-
it('DIALECT — the legacy space-separated string arm reaches the wire normalized', async () => {
255-
const params = await childQueryParams({ sort: 'seq_no desc' });
256-
expect(params.$orderby).toEqual([{ field: 'seq_no', order: 'desc' }]);
257-
// The failure this leg exists for, stated so a regression reads plainly:
258-
// an un-normalized inherit orders by a FIELD NAMED `seq_no desc`.
259-
expect(params.$orderby[0].field).toBe('seq_no');
260-
expect(JSON.stringify(params.$orderby)).not.toContain('seq_no desc');
255+
it('RETIRED DIALECT — a legacy string arm sends NO $orderby, and never a field named for the clause (objectui#8221)', async () => {
256+
resetRetiredSortSpellingReports();
257+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
258+
try {
259+
const params = await childQueryParams({ sort: 'seq_no desc' });
260+
261+
// Refused end to end: the retirement reaches the wire, not just the
262+
// helper's unit test.
263+
expect('$orderby' in params).toBe(false);
264+
// The failure this leg has always existed for, stated so a regression
265+
// reads plainly: an un-normalized inherit orders by a FIELD NAMED
266+
// `seq_no desc`. Refusal prevents it just as translation did; forwarding
267+
// the string verbatim would not.
268+
expect(JSON.stringify(params)).not.toContain('seq_no desc');
269+
// LIVE CONTROL — the query really ran and really is the related list's
270+
// own, so the absent `$orderby` means "refused", not "nothing fetched".
271+
expect(params.$filter).toEqual({ [PARENT]: RECORD_ID });
272+
expect(params.$top).toBeGreaterThan(0);
273+
274+
// And the operator is told why their order vanished.
275+
expect(errorSpy).toHaveBeenCalled();
276+
expect(String(errorSpy.mock.calls[0][0])).toContain("[{ field: 'name', order: 'desc' }]");
277+
} finally {
278+
errorSpy.mockRestore();
279+
}
261280
});
262281

263282
it('COUNTER-PROBE — no declared sort sends NO $orderby, and the list still works', async () => {

0 commit comments

Comments
 (0)