From 89fe39fa148a392a6787bf34fa177b1ed80eae35 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 03:37:51 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(core)!:=20retire=20the=20legacy=20stri?= =?UTF-8?q?ng=20`sort`=20clause=20=E2=80=94=20one=20spelling,=20the=20arra?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .changeset/8221-retire-legacy-string-sort.md | 56 +++++++++ content/docs/plugins/plugin-map.mdx | 2 +- content/docs/plugins/plugin-view.mdx | 4 +- .../deriveRelatedLists.inheritSort.test.ts | 96 +++++++++++---- .../app-shell/src/utils/deriveRelatedLists.ts | 2 +- ...iew.relatedListInheritedSort-5795.test.tsx | 33 +++-- .../src/utils/__tests__/sort-query.test.ts | 113 +++++++++++++++--- packages/core/src/utils/sort-query.ts | 103 +++++++++++++--- .../LineItemsPanel.elementDataSource.test.tsx | 2 +- packages/plugin-form/src/LineItemsPanel.tsx | 7 +- .../src/ObjectMap.schemaAlignment.test.tsx | 2 +- .../ObjectTimeline.elementDataSource.test.tsx | 7 +- .../plugin-timeline/src/ObjectTimeline.tsx | 7 +- packages/plugin-view/README.md | 4 +- packages/plugin-view/src/ObjectView.tsx | 10 +- .../ObjectView.canonicalTableKeys.test.tsx | 27 +++-- .../ObjectView.filterSources.test.tsx | 2 +- .../ObjectView.namedViewSortArity.test.tsx | 11 +- .../__tests__/ObjectView.sortSink.test.tsx | 44 +++++-- .../gantt-flat-config-declared-keys.test.ts | 5 +- .../sort-string-arm-retired-8221.test.ts | 110 +++++++++++++++++ packages/types/src/objectql.ts | 19 +-- packages/types/src/zod/objectql.zod.ts | 6 +- 23 files changed, 549 insertions(+), 123 deletions(-) create mode 100644 .changeset/8221-retire-legacy-string-sort.md create mode 100644 packages/types/src/__tests__/sort-string-arm-retired-8221.test.ts diff --git a/.changeset/8221-retire-legacy-string-sort.md b/.changeset/8221-retire-legacy-string-sort.md new file mode 100644 index 0000000000..0fbc0f237c --- /dev/null +++ b/.changeset/8221-retire-legacy-string-sort.md @@ -0,0 +1,56 @@ +--- +'@object-ui/core': minor +'@object-ui/types': minor +'@object-ui/app-shell': minor +'@object-ui/plugin-form': minor +'@object-ui/plugin-timeline': minor +'@object-ui/plugin-view': patch +'@object-ui/plugin-map': patch +--- + +Retire the legacy string `sort` clause: one spelling, the array +(objectui#8221) — `convertSortToQueryParams` now REFUSES `"name desc"` with a +diagnostic naming `[{ field: 'name', order: 'desc' }]`, instead of lowering it. + +**BREAKING for `@object-ui/core` consumers — scored `minor`, not `major`, per +AGENTS.md 版本号策略** (every package is in one fixed group, so a `major` here +would carry all 39 off the `@objectstack` major this repo is pinned to). The +breaking semantics are stated below rather than encoded in the version. + +Director ruling, decision batch #77 (2026-09-07), option B. Three faces +disagreed about one key: `@object-ui/core` implemented the string clause +on purpose (`sort-query.ts`, docblock and all), `content/docs/plugins/plugin-map.mdx` +taught it as `sort?: string | SortConfig[]`, and the html tier answered +`type-mismatch` for it because all seven `sort` registrations publish +`type: 'array'` alone — while `@objectstack/spec` refuses the string outright on +`element-record-picker`. Option A (per-block string arms) was rejected by name: +it would make one key mean different things on different blocks. + +**What moves.** `convertSortToQueryParams(sort)` narrows from +`string | QuerySortEntry[]` to `QuerySortEntry[]`, and the three declarations +that published a string arm narrow with it — `ObjectGridSchema.sort`, +`ObjectMapSchema.sort` and `ObjectGanttSchema.sort`, in the TypeScript face AND +in the zod mirror, together, because a narrowing that left `z.string()` in the +mirror is the declared-vs-enforced split this change exists to close. The local +`sort` declarations on `LineItemsPanel`, `ObjectTimeline` and +`deriveRelatedLists`'s ListView input narrow the same way. + +**What a string does now.** Types are erased, so the signature stops a string +only at compile time; authored JSON and stored `sys_metadata` rows still reach +the sink carrying `"name desc"`. Such a value is REFUSED — the query carries no +`$orderby` — and `console.error` names the array form, quotes what arrived and +states the consequence, once per spelling. A silent `undefined` was the one +outcome the ruling ruled out. + +**Measured consequences you may see.** A related list that inherited its child +object's default list-view sort in the legacy spelling stops inheriting it (the +console says so). `@objectstack/spec@17.3.0` still ACCEPTS the string on +`ListViewSchema.sort` and on `RecordRelatedListProps.sort`, so such metadata is +still spec-legal today; the spec-side pull-back is its own card. Two surfaces +are deliberately untouched, because they are a DIFFERENT string dialect that +never reaches this sink: `record:related_list`'s `'field'` / `'-field'` form, +normalized by `RelatedList.normalizeSortSpec`, and `ListView.parseSortConfig`, +which reads the platform view record the spec still blesses. + +Docs teach the array only: `content/docs/plugins/plugin-map.mdx`, +`content/docs/plugins/plugin-view.mdx` and `packages/plugin-view/README.md`. diff --git a/content/docs/plugins/plugin-map.mdx b/content/docs/plugins/plugin-map.mdx index 8f260024bb..61e1ef6576 100644 --- a/content/docs/plugins/plugin-map.mdx +++ b/content/docs/plugins/plugin-map.mdx @@ -109,7 +109,7 @@ const schema: ObjectMapSchema = { data?: ViewData, // Advanced data configuration (read first) // At least one of data / staticData / objectName is required filter?: Array, // Query filter, sent as $filter - sort?: string | SortConfig[], // Sort, sent as $orderby + sort?: SortConfig[], // Sort, sent as $orderby map?: ObjectMapConfig, // Map-specific configuration enableClustering?: boolean, // Cluster nearby markers (auto past 100) navigation?: NavigationConfig, // Record navigation (drawer/dialog/page) diff --git a/content/docs/plugins/plugin-view.mdx b/content/docs/plugins/plugin-view.mdx index b83c1ca9d7..d1c6e421ab 100644 --- a/content/docs/plugins/plugin-view.mdx +++ b/content/docs/plugins/plugin-view.mdx @@ -148,7 +148,7 @@ the canonical one: | `pagination: { pageSize, pageSizeOptions? }` | `pageSize: number` | | `selection: { type: 'single' \| 'multiple' \| 'none' }` | `selectable: boolean \| 'single' \| 'multiple'` | | `filter: [{ field, operator, value }, …]` (same shape as a named view's `filter`) | `defaultFilters: Record` (equality-only) | -| `sort: 'field direction'` or `SortConfig[]` | `defaultSort: { field, order }` (**no string form** — that arity only exists on `sort`) | +| `sort: SortConfig[]` (`[{ field, order }]`) | `defaultSort: { field, order }` (a single entry, not an array) | Before objectui#5102, `pagination` / `selection` / `filter` / `sort` had **no read point at all** in this file: an author who wrote the canonical shape @@ -269,7 +269,7 @@ const userDirectory: ObjectViewSchema = { defaultViewType: 'grid', table: { columns: ['name', 'email', 'role', 'created_at'], - sort: 'created_at desc', // or [{ field: 'created_at', order: 'desc' }] + sort: [{ field: 'created_at', order: 'desc' }], }, }; ``` diff --git a/packages/app-shell/src/utils/__tests__/deriveRelatedLists.inheritSort.test.ts b/packages/app-shell/src/utils/__tests__/deriveRelatedLists.inheritSort.test.ts index 72ebc2640c..00f1855798 100644 --- a/packages/app-shell/src/utils/__tests__/deriveRelatedLists.inheritSort.test.ts +++ b/packages/app-shell/src/utils/__tests__/deriveRelatedLists.inheritSort.test.ts @@ -20,8 +20,8 @@ * ## The dialect trap this file exists to pin * * `ListView.sort` and `record:related_list.sort` declare the SAME union - * (`string | Array<{field, order}>`) and mean DIFFERENT things by the string - * arm: + * (`string | Array<{field, order}>`) in `@objectstack/spec` and mean DIFFERENT + * things by the string arm: * * - ListView's string is the legacy space-separated clause, `'seq_no desc'` * (`@objectstack/spec` `ui/view.zod.ts`, annotated `Legacy "field desc"`); @@ -30,18 +30,38 @@ * * Inheriting the string verbatim therefore does not produce "a sort in another * notation" — it produces `$orderby` on a field whose NAME is the seven - * characters `seq_no desc`, which no object has. The route taken (and pinned - * below) is to normalize at this boundary, always to the ARRAY arm, through - * `@object-ui/core`'s `convertSortToQueryParams` — the repo's one definition of - * both authored dialects — so no second parser of the legacy string exists to - * drift from it. + * characters `seq_no desc`, which no object has. The route taken is to resolve + * that at this boundary, always to the ARRAY arm, through `@object-ui/core`'s + * `convertSortToQueryParams`, so no second parser of the legacy string exists + * to drift from it. `deriveRelatedLists` is the ONE place that knows it is + * reading a ListView and writing a related list, which is why the resolution + * belongs here and not as a tolerant reader on the consuming end + * (AGENTS.md #0.1). * - * `deriveRelatedLists` is the ONE place that knows it is reading a ListView and - * writing a related list, which is why the translation belongs here and not as - * a tolerant reader on the consuming end (AGENTS.md #0.1). + * ## What objectui#8221 changed, and what it did NOT + * + * Decision batch #77 (option B) RETIRED the legacy space-separated clause: + * `convertSortToQueryParams` no longer lowers it, it REFUSES it with a + * diagnostic naming the array form. So this boundary no longer TRANSLATES a + * ListView string — it drops it and says so, and the pins below moved with it. + * + * ⚠️ The trap the old translation prevented is still prevented, and that is the + * assertion worth keeping: a legacy string must never reach the wire as a FIELD + * NAME. "Refused, loudly" and "translated" both satisfy that; "forwarded + * verbatim" does not, and is what a later well-meaning simplification here + * would reintroduce. + * + * ⚠️ Measured, and the reason this is a behaviour change rather than a + * tidy-up: `@objectstack/spec@17.3.0`'s `ListViewSchema.sort` STILL accepts the + * string (`'name desc'` parses; `42` is refused `invalid_union`; a `bogusProp` + * control is refused by name on the same call). A platform view carrying the + * legacy clause is therefore still spec-legal and stops being inherited here. + * The spec-side pull-back is its own card; until it lands, this diagnostic is + * the only thing standing between an operator and a silently unordered list. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { resetRetiredSortSpellingReports } from '@object-ui/core'; import { deriveRelatedLists } from '../deriveRelatedLists'; const PARENT = { name: 'task_version', label: 'Task Version', fields: {} }; @@ -89,23 +109,49 @@ describe('deriveRelatedLists — inherited default list-view sort (objectui#5795 ]); }); - it('THE DIALECT PIN — normalizes the legacy space-separated string arm', () => { - const entry = derive(childWithList({ sort: 'seq_no desc' })); - expect(entry.sort).toEqual([{ field: 'seq_no', order: 'desc' }]); - // Stated as its own assertion because it is the whole failure mode: an - // un-normalized inherit yields a FIELD literally named `seq_no desc`. - expect(entry.sort?.[0].field).toBe('seq_no'); - expect(entry.sort?.[0].field).not.toBe('seq_no desc'); - }); + it('THE RETIREMENT PIN — a legacy string arm is REFUSED, and refused OUT LOUD (objectui#8221)', () => { + resetRetiredSortSpellingReports(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const entry = derive(childWithList({ sort: 'seq_no desc' })); - it('reads a bare legacy string as ascending', () => { - expect(derive(childWithList({ sort: 'seq_no' })).sort).toEqual([ - { field: 'seq_no', order: 'asc' }, - ]); + // Nothing is inherited: the key is ABSENT, exactly as for a child that + // declared no order at all. + expect('sort' in entry).toBe(false); + // The list itself is still derived — so the missing key means "this + // order was refused", not "this derivation collapsed". + expect(entry.childObject).toBe('check_item'); + + // The original failure mode stays impossible: the seven characters + // `seq_no desc` must never travel as a FIELD NAME. + expect(JSON.stringify(entry)).not.toContain('seq_no desc'); + + // And it is LOUD. A silent drop here is an operator's row order + // disappearing with nothing in the console to explain it. + expect(errorSpy).toHaveBeenCalledTimes(1); + const message = String(errorSpy.mock.calls[0][0]); + expect(message).toContain("[{ field: 'name', order: 'desc' }]"); + expect(message).toContain('"seq_no desc"'); + } finally { + errorSpy.mockRestore(); + } }); - it('is case-insensitive about the legacy direction word', () => { - expect(derive(childWithList({ sort: 'seq_no DESC' })).sort).toEqual([ + it('the other legacy spellings are refused the same way', () => { + for (const spelling of ['seq_no', 'seq_no DESC']) { + resetRetiredSortSpellingReports(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect('sort' in derive(childWithList({ sort: spelling }))).toBe(false); + expect(errorSpy).toHaveBeenCalledTimes(1); + } finally { + errorSpy.mockRestore(); + } + } + + // CONTROL — on the same derivation, the array arm still inherits, so the + // refusals above are about the SPELLING and not a broken boundary. + expect(derive(childWithList({ sort: [{ field: 'seq_no', order: 'desc' }] })).sort).toEqual([ { field: 'seq_no', order: 'desc' }, ]); }); diff --git a/packages/app-shell/src/utils/deriveRelatedLists.ts b/packages/app-shell/src/utils/deriveRelatedLists.ts index dc8d1ede20..c669a444fd 100644 --- a/packages/app-shell/src/utils/deriveRelatedLists.ts +++ b/packages/app-shell/src/utils/deriveRelatedLists.ts @@ -140,7 +140,7 @@ interface ObjectLike { * fresh array once the views merge, so the memo over this derivation * recomputes and the sort appears. */ - list?: { sort?: string | Array<{ field?: string; order?: 'asc' | 'desc' }> }; + list?: { sort?: Array<{ field?: string; order?: 'asc' | 'desc' }> }; } /** diff --git a/packages/app-shell/src/views/RecordDetailView.relatedListInheritedSort-5795.test.tsx b/packages/app-shell/src/views/RecordDetailView.relatedListInheritedSort-5795.test.tsx index 0510db7ab6..12c775aa0d 100644 --- a/packages/app-shell/src/views/RecordDetailView.relatedListInheritedSort-5795.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.relatedListInheritedSort-5795.test.tsx @@ -64,6 +64,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, waitFor, cleanup } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { MetadataCtx } from '@object-ui/react'; +import { resetRetiredSortSpellingReports } from '@object-ui/core'; vi.mock('@object-ui/auth', async (importOriginal) => ({ ...(await importOriginal>()), @@ -251,13 +252,31 @@ describe('derived related list — inherited $orderby on the wire (objectui#5795 expect(params.$orderby).toEqual([{ field: 'seq_no', order: 'desc' }]); }); - it('DIALECT — the legacy space-separated string arm reaches the wire normalized', async () => { - const params = await childQueryParams({ sort: 'seq_no desc' }); - expect(params.$orderby).toEqual([{ field: 'seq_no', order: 'desc' }]); - // The failure this leg exists for, stated so a regression reads plainly: - // an un-normalized inherit orders by a FIELD NAMED `seq_no desc`. - expect(params.$orderby[0].field).toBe('seq_no'); - expect(JSON.stringify(params.$orderby)).not.toContain('seq_no desc'); + it('RETIRED DIALECT — a legacy string arm sends NO $orderby, and never a field named for the clause (objectui#8221)', async () => { + resetRetiredSortSpellingReports(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const params = await childQueryParams({ sort: 'seq_no desc' }); + + // Refused end to end: the retirement reaches the wire, not just the + // helper's unit test. + expect('$orderby' in params).toBe(false); + // The failure this leg has always existed for, stated so a regression + // reads plainly: an un-normalized inherit orders by a FIELD NAMED + // `seq_no desc`. Refusal prevents it just as translation did; forwarding + // the string verbatim would not. + expect(JSON.stringify(params)).not.toContain('seq_no desc'); + // LIVE CONTROL — the query really ran and really is the related list's + // own, so the absent `$orderby` means "refused", not "nothing fetched". + expect(params.$filter).toEqual({ [PARENT]: RECORD_ID }); + expect(params.$top).toBeGreaterThan(0); + + // And the operator is told why their order vanished. + expect(errorSpy).toHaveBeenCalled(); + expect(String(errorSpy.mock.calls[0][0])).toContain("[{ field: 'name', order: 'desc' }]"); + } finally { + errorSpy.mockRestore(); + } }); it('COUNTER-PROBE — no declared sort sends NO $orderby, and the list still works', async () => { diff --git a/packages/core/src/utils/__tests__/sort-query.test.ts b/packages/core/src/utils/__tests__/sort-query.test.ts index 72b047c15a..5ccee79252 100644 --- a/packages/core/src/utils/__tests__/sort-query.test.ts +++ b/packages/core/src/utils/__tests__/sort-query.test.ts @@ -10,25 +10,29 @@ * read sites and would otherwise have made a fifth and sixth private copy of the * conversion `ObjectGantt` / `ObjectMap` / `ObjectCalendar` each inline. * - * The last two cases pin the two places this function is deliberately MORE + * Two of the cases below pin the two places this function is deliberately MORE * faithful to the declared contract than those copies: `SortConfig.order` is * optional (an entry without it means ascending, not "drop this key"), and * nothing usable yields `undefined` rather than a truthy-but-empty `{}`. + * + * The rest pin objectui#8221 (director ruling, decision batch #77, option B): + * the legacy string clause is RETIRED, and — this is the load-bearing half — + * it is refused OUT LOUD. Types are erased, so the narrowed signature stops a + * string only at compile time; authored JSON and stored metadata rows still + * reach this function carrying `"name desc"`. A silent `undefined` there is an + * authored row order that quietly stops applying, which is precisely the + * failure this repository keeps measuring. Every refusal below is therefore + * paired with a well-formed control that still lowers: a sink that refused + * everything would satisfy the refusal assertions on its own. */ -import { describe, it, expect } from 'vitest'; -import { convertSortToQueryParams } from '../sort-query'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { convertSortToQueryParams, resetRetiredSortSpellingReports } from '../sort-query'; -describe('convertSortToQueryParams', () => { - it('lowers the legacy string clause', () => { - expect(convertSortToQueryParams('name desc')).toEqual({ name: 'desc' }); - expect(convertSortToQueryParams('name asc')).toEqual({ name: 'asc' }); - // A bare field name is ascending — same as an omitted direction. - expect(convertSortToQueryParams('name')).toEqual({ name: 'asc' }); - // Direction casing is not the author's problem. - expect(convertSortToQueryParams('name DESC')).toEqual({ name: 'desc' }); - }); +/** The retired spelling, reached the only way it still can be: at runtime. */ +const asRuntimeValue = (value: unknown) => value as unknown as Parameters[0]; +describe('convertSortToQueryParams', () => { it('lowers a SortConfig[] preserving key order', () => { expect( convertSortToQueryParams([ @@ -60,15 +64,92 @@ describe('convertSortToQueryParams', () => { it('returns undefined — never an empty object — when nothing is orderable', () => { expect(convertSortToQueryParams(undefined)).toBeUndefined(); expect(convertSortToQueryParams(null)).toBeUndefined(); - expect(convertSortToQueryParams('')).toBeUndefined(); - expect(convertSortToQueryParams(' ')).toBeUndefined(); expect(convertSortToQueryParams([])).toBeUndefined(); // Entries with no usable field name contribute nothing, and an all-unusable // array must not produce a truthy `{}` that a caller would send as $orderby. expect(convertSortToQueryParams([{ order: 'desc' }])).toBeUndefined(); expect(convertSortToQueryParams([{ field: '' }])).toBeUndefined(); // Shapes the schema types do not declare are refused, not guessed at. - expect(convertSortToQueryParams(42 as any)).toBeUndefined(); - expect(convertSortToQueryParams({ name: 'desc' } as any)).toBeUndefined(); + expect(convertSortToQueryParams(asRuntimeValue(42))).toBeUndefined(); + expect(convertSortToQueryParams(asRuntimeValue({ name: 'desc' }))).toBeUndefined(); + }); +}); + +describe('convertSortToQueryParams — the retired string clause (objectui#8221)', () => { + let errorSpy: ReturnType; + + beforeEach(() => { + // The reporter dedupes per spelling in module state, so without this the + // second test to assert a diagnostic would observe silence and pass for + // entirely the wrong reason. + resetRetiredSortSpellingReports(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + it('REFUSES every string spelling the retired arm used to lower', () => { + // The four readings the retired arm implemented, one per line. Each now + // yields no ordering at all rather than the map it used to build. + for (const spelling of ['name desc', 'name asc', 'name', 'name DESC']) { + resetRetiredSortSpellingReports(); + expect(convertSortToQueryParams(asRuntimeValue(spelling))).toBeUndefined(); + } + + // CONTROL — the array arm still lowers on the same function, so the + // assertions above are a refusal of the string and not a dead sink. + expect(convertSortToQueryParams([{ field: 'name', order: 'desc' }])).toEqual({ name: 'desc' }); + }); + + it('names the array form in the diagnostic — the refusal is legible, not silent', () => { + expect(convertSortToQueryParams(asRuntimeValue('name desc'))).toBeUndefined(); + + expect(errorSpy).toHaveBeenCalledTimes(1); + const message = String(errorSpy.mock.calls[0][0]); + // The fix, not just the complaint: the message has to carry the spelling + // the author must switch to, or it sends them looking for one. + expect(message).toContain("[{ field: 'name', order: 'desc' }]"); + expect(message).toContain('retired'); + // It quotes what actually arrived, so the author can find it in their JSON. + expect(message).toContain('"name desc"'); + // And it says the consequence out loud, because "refused" without "so your + // rows are unordered" reads as a style note. + expect(message).toContain('$orderby'); + }); + + it('reports once per spelling — a render loop must not bury its own message', () => { + convertSortToQueryParams(asRuntimeValue('name desc')); + convertSortToQueryParams(asRuntimeValue('name desc')); + convertSortToQueryParams(asRuntimeValue('name desc')); + expect(errorSpy).toHaveBeenCalledTimes(1); + + // CONTROL — a DIFFERENT retired spelling is a different authoring mistake + // and still gets its own line, so the dedupe is per spelling and not a + // one-message-ever latch. + convertSortToQueryParams(asRuntimeValue('amount asc')); + expect(errorSpy).toHaveBeenCalledTimes(2); + }); + + it('stays silent for values that were never the retired spelling', () => { + // An empty / absent `sort` is "the author asked for nothing", not "the + // author used the retired clause" — reporting it would train readers to + // ignore the message. + expect(convertSortToQueryParams(asRuntimeValue(''))).toBeUndefined(); + expect(convertSortToQueryParams(undefined)).toBeUndefined(); + expect(convertSortToQueryParams(null)).toBeUndefined(); + // Nor for shapes that were never declared in either arm. + expect(convertSortToQueryParams(asRuntimeValue(42))).toBeUndefined(); + expect(convertSortToQueryParams(asRuntimeValue({ name: 'desc' }))).toBeUndefined(); + // Nor for the arm that still works. + expect(convertSortToQueryParams([{ field: 'name' }])).toEqual({ name: 'asc' }); + expect(errorSpy).not.toHaveBeenCalled(); + + // CONTROL — the spy IS wired to this function: one retired spelling on the + // same spy makes it fire, so the silence above is a reading and not a + // disconnected mock. + expect(convertSortToQueryParams(asRuntimeValue(' '))).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/core/src/utils/sort-query.ts b/packages/core/src/utils/sort-query.ts index 2960bbdfda..f01f6d1ac5 100644 --- a/packages/core/src/utils/sort-query.ts +++ b/packages/core/src/utils/sort-query.ts @@ -10,12 +10,21 @@ * sort-query — lower a block's authored `sort` to the `$orderby` value a * `DataSource.find` query carries. * - * Two authored spellings reach every object-bound block, because both are - * declared: the legacy OData-ish string (`ObjectGridSchema.sort: "name desc"`) - * and the spec's `SortConfig[]` (`[{ field, order }]`). A read site that hands - * either straight to `$orderby` works only because `QueryParams.$orderby` - * accepts four shapes; normalizing here means one shape reaches the wire and a - * block does not have to know which spelling its author used. + * ONE authored spelling reaches every object-bound block: the spec's + * `SortConfig[]` (`[{ field, order }]`). The legacy OData-ish string clause + * (`"name desc"`) that this function also honoured until objectui#8221 is + * RETIRED — director ruling, decision batch #77, 2026-09-07 (objectui#8221, + * maintainer approved), option B: "The platform has one `sort` spelling, the + * array, everywhere." + * + * Why the string had to go rather than be declared alongside the array: every + * `sort` input in the registry publishes `type: 'array'` alone, so the html + * tier already answered `type-mismatch` for the string, and + * `@objectstack/spec` refuses it outright on `element-record-picker`. A + * spelling that core implements, the docs teach and the validator rejects is + * three faces disagreeing, and the ruling settled which one wins. Declaring + * per-block string arms instead (option A) was rejected by name: it would make + * one key mean different things on different blocks. * * This is now the ONLY definition in the repo. It was hoisted here because * objectstack#7137 added two more read sites (`object-timeline`, @@ -40,8 +49,15 @@ * serializer; `undefined` says it, so the query simply carries no `$orderby`. * * Not a lenient alias: an unusable input (a number, an object, an array of - * strings) yields `undefined` rather than a guess. Only the two spellings the - * schema types declare are honoured. + * strings) yields `undefined` rather than a guess. Only the ONE spelling the + * schema types declare is honoured. + * + * ⚠️ A retired string is REFUSED OUT LOUD, not dropped in silence. Types are + * erased, so the narrowed signature below cannot stop a string: authored JSON, + * a stored `sys_metadata` row and an `as any` bag all reach this function + * unparsed. Returning `undefined` and saying nothing would turn every one of + * those into a row order that quietly stopped applying — the exact failure this + * repository has measured over and over. See {@link convertSortToQueryParams}. */ /** A field name paired with a direction — the spec's `SortConfig`. */ @@ -50,23 +66,80 @@ export interface QuerySortEntry { order?: 'asc' | 'desc'; } +/** + * The canonical spelling, quoted in the refusal diagnostic so the message + * carries the fix and not just the complaint. + */ +const ARRAY_FORM_EXAMPLE = "[{ field: 'name', order: 'desc' }]"; + +/** + * Retired string clauses already reported, so one bad `sort` logs its + * prescription ONCE instead of once per render. The message is a fix + * instruction for an author, not a per-render event, and this sink runs inside + * the query memo of every object-bound block — a related-list derivation over + * an object with N lists would otherwise print N lines per pass. + * + * Module state, exactly as {@link resetRetiredSortSpellingReports}'s sibling + * `resetRetiredFieldTypeReports` keeps it for retired field types: the dedupe + * is per SPELLING, so two blocks that inherit the same bad view sort still + * print one line between them. + */ +const reportedRetiredSpellings = new Set(); + +/** + * Test seam — forget which retired spellings have been reported. + * + * Needed because the dedupe above is module state: without it the second test + * to assert the refusal diagnostic would observe silence and pass for the + * wrong reason. + */ +export function resetRetiredSortSpellingReports(): void { + reportedRetiredSpellings.clear(); +} + +/** + * Name a retired string `sort` clause, once per spelling. + * + * `console.error`, not `warn`, and not dev-gated: this call REFUSES an authored + * row order, so the page renders in a different order than the author asked + * for. That is the same severity class as `reportRetiredFieldType`, which is + * also unconditional, and the opposite of `warnOnUnknownActionKeys`, which only + * reports keys nothing was ever going to read. + */ +function reportRetiredSortSpelling(sort: string): void { + if (reportedRetiredSpellings.has(sort)) return; + reportedRetiredSpellings.add(sort); + console.error( + `[object-ui] convertSortToQueryParams: the legacy string \`sort\` clause is retired ` + + `(objectui#8221) and was REFUSED — received ${JSON.stringify(sort)}, so this query ` + + `carries no \`$orderby\`. Write the array form instead: ` + + `sort: ${ARRAY_FORM_EXAMPLE} (\`order\` is optional and means \`'asc'\`). ` + + `The array is the only spelling every \`sort\` input declares, and the only one ` + + `\`@objectstack/spec\` accepts.`, + ); +} + /** * Normalize an authored `sort` into the field→direction map used for * `QueryParams.$orderby`. * - * @param sort `"name desc"` (legacy string) or `SortConfig[]`. + * @param sort `SortConfig[]` — the one declared spelling. The legacy string + * clause (`"name desc"`) is retired (objectui#8221): a string that reaches here + * at runtime is refused with a diagnostic naming the array form, and this + * function returns `undefined` so the query carries no `$orderby`. * @returns The ordering map, or `undefined` when nothing orderable was authored. */ export function convertSortToQueryParams( - sort: string | QuerySortEntry[] | undefined | null, + sort: QuerySortEntry[] | undefined | null, ): Record | undefined { if (!sort) return undefined; - // Legacy string clause: "name desc" / "name" (a bare field means ascending). - if (typeof sort === 'string') { - const [field, direction] = sort.trim().split(/\s+/); - if (!field) return undefined; - return { [field]: direction?.toLowerCase() === 'desc' ? 'desc' : 'asc' }; + // Retired spelling — reachable only at runtime, since the signature above no + // longer admits it. Read through `unknown` on purpose: the check is about the + // VALUE that actually arrived, not about the type the caller promised. + if (typeof (sort as unknown) === 'string') { + reportRetiredSortSpelling(sort as unknown as string); + return undefined; } if (Array.isArray(sort)) { diff --git a/packages/plugin-form/src/LineItemsPanel.elementDataSource.test.tsx b/packages/plugin-form/src/LineItemsPanel.elementDataSource.test.tsx index 7711198cdc..6558195532 100644 --- a/packages/plugin-form/src/LineItemsPanel.elementDataSource.test.tsx +++ b/packages/plugin-form/src/LineItemsPanel.elementDataSource.test.tsx @@ -189,7 +189,7 @@ describe('record:line_items — parent scope AND the authored criteria (objectst parentId: 'inv-1', columns: COLUMNS, filter: [['billable', '=', true]], - sort: 'qty desc', + sort: [{ field: 'qty', order: 'desc' }], limit: 25, }, adapter, diff --git a/packages/plugin-form/src/LineItemsPanel.tsx b/packages/plugin-form/src/LineItemsPanel.tsx index 64ef8c364d..3329f9c94f 100644 --- a/packages/plugin-form/src/LineItemsPanel.tsx +++ b/packages/plugin-form/src/LineItemsPanel.tsx @@ -65,10 +65,11 @@ export interface LineItemsPanelSchema { */ filter?: any[] | Record; /** - * Load order for the child rows — the legacy `"line_no desc"` clause or the - * spec's `SortConfig[]`. Without one the rows arrive in storage order. + * Load order for the child rows — the spec's `SortConfig[]`. Without one the + * rows arrive in storage order. The legacy `"line_no desc"` clause is RETIRED + * (objectui#8221): `convertSortToQueryParams` refuses it out loud. */ - sort?: string | Array<{ field?: string; order?: 'asc' | 'desc' }>; + sort?: Array<{ field?: string; order?: 'asc' | 'desc' }>; /** * Row cap for the child fetch; defaults to {@link DEFAULT_LINE_ITEMS_LIMIT}. * A line-items grid has no pagination control — every loaded row is editable diff --git a/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx b/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx index 0806eb367d..d97590273e 100644 --- a/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx +++ b/packages/plugin-map/src/ObjectMap.schemaAlignment.test.tsx @@ -96,7 +96,7 @@ describe('ObjectMapSchema declares what the renderer reads (type pins)', () => { staticData: [{ id: '1' }], data: { provider: 'value', items: [{ id: '1' }] }, filter: [['status', '=', 'open']], - sort: 'name desc', + sort: [{ field: 'name', order: 'desc' }], map: { latitudeField: 'lat', longitudeField: 'lng', diff --git a/packages/plugin-timeline/src/ObjectTimeline.elementDataSource.test.tsx b/packages/plugin-timeline/src/ObjectTimeline.elementDataSource.test.tsx index 0761b20931..f85fe68abb 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.elementDataSource.test.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.elementDataSource.test.tsx @@ -168,7 +168,7 @@ describe('object-timeline — the view actually narrows the rail (objectstack#71 objectName: 'campaign', timeline: TIMELINE, filter: [['stage', '=', 'live']], - sort: 'end_date desc', + sort: [{ field: 'end_date', order: 'desc' }], limit: 12, }, adapter, @@ -177,8 +177,9 @@ describe('object-timeline — the view actually narrows the rail (objectstack#71 const [object, params] = await firstQuery(adapter); expect(object).toBe('campaign'); expect(params.$filter).toEqual([['stage', '=', 'live']]); - // The legacy string clause is lowered the same way the sibling object-bound - // blocks lower theirs (`convertSortToQueryParams`). + // The array arm — the ONE declared spelling since objectui#8221 — is lowered + // the same way the sibling object-bound blocks lower theirs + // (`convertSortToQueryParams`). expect(params.$orderby).toEqual({ end_date: 'desc' }); expect(params.$top).toBe(12); }); diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index 146c679343..b325feafa3 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -116,10 +116,11 @@ export interface ObjectTimelineProps { */ filter?: any[] | Record; /** - * Query ordering — the legacy `"name desc"` clause or the spec's - * `SortConfig[]`, both lowered through `convertSortToQueryParams`. + * Query ordering — the spec's `SortConfig[]`, lowered through + * `convertSortToQueryParams`. The legacy `"name desc"` clause is RETIRED + * (objectui#8221) and that sink refuses it out loud. */ - sort?: string | Array<{ field?: string; order?: 'asc' | 'desc' }>; + sort?: Array<{ field?: string; order?: 'asc' | 'desc' }>; /** * Row cap for the fetch. Defaults to {@link DEFAULT_TIMELINE_LIMIT}; a * timeline renders one rail with no pagination control, so this is the diff --git a/packages/plugin-view/README.md b/packages/plugin-view/README.md index fdb816b505..1075e02dc2 100644 --- a/packages/plugin-view/README.md +++ b/packages/plugin-view/README.md @@ -217,7 +217,7 @@ just no longer the one to reach for: | `pagination: { pageSize, pageSizeOptions? }` | `pageSize: number` | | `selection: { type: 'single' \| 'multiple' \| 'none' }` | `selectable: boolean \| 'single' \| 'multiple'` | | `filter: [{ field, operator, value }, …]` (same shape as a named view's `filter`) | `defaultFilters: Record` (equality-only) | -| `sort: 'field direction'` or `SortConfig[]` | `defaultSort: { field, order }` (**no string form** — that arity only exists on `sort`) | +| `sort: SortConfig[]` (`[{ field, order }]`) | `defaultSort: { field, order }` (a single entry, not an array) | **Precedence when a key is written both ways** — `table: { pagination: { pageSize: 10 }, pageSize: 50 }`, say — the canonical spelling wins. That is @@ -335,7 +335,7 @@ const schema: ObjectViewSchema = { defaultViewType: 'grid', table: { columns: ['name', 'email', 'role', 'created_at'], - sort: 'created_at desc', // or [{ field: 'created_at', order: 'desc' }] + sort: [{ field: 'created_at', order: 'desc' }], }, }; ``` diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index c987059e39..1d530c7076 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -1600,11 +1600,11 @@ export const ObjectView: React.FC = ({ // missing keys off an array and sends the literal string // `"undefined undefined"` as `$orderby`. // - // So the view's sort now rides the CANONICAL slot, which is declared - // `string | SortConfig[]` and therefore already holds the arity a view - // carries. That is also the shape the shared sort sink accepts - // (`convertSortToQueryParams`, `string | SortConfig[]` — objectui#4869), so - // this converges on the normalized dialect instead of introducing another. + // So the view's sort now rides the CANONICAL slot, `ObjectGridSchema.sort`, + // which holds the multi-key arity a view carries. That is also the shape the + // shared sort sink accepts (`convertSortToQueryParams`, `SortConfig[]` — + // objectui#4869, narrowed to the array alone by objectui#8221), so this + // converges on the normalized dialect instead of introducing another. // Precedence is unchanged: ObjectGrid resolves `sort ?? defaultSort`, so a // view sort still outranks a `table.defaultSort`, and `table.sort` still // outranks it too — the same order `mergedSort` and the non-grid fetch use. diff --git a/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx index f80db0a0a5..7067ded115 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.canonicalTableKeys.test.tsx @@ -172,10 +172,15 @@ describe('grid path: the canonical table keys reach ObjectGrid', () => { expect(grid.sort).toEqual([{ field: 'name', order: 'desc' }]); }); - it('forwards the string form of table.sort', () => { - // `ObjectGridSchema.sort` is `string | SortConfig[]`; the legacy - // `defaultSort` has no string form, so this arity is reachable only - // through the canonical key. + it('forwards table.sort VERBATIM — even the retired string form (objectui#8221)', () => { + // `ObjectGridSchema.sort` is `SortConfig[]` since decision batch #77; the + // string arm is retired. This delegation is nonetheless VALUE-AGNOSTIC and + // stays that way on purpose: a stored metadata row still reaches it + // carrying the old spelling, and the place that judges the value is the + // shared sink downstream (`ObjectView.sortSink.test.tsx` pins the refusal + // and its diagnostic). Coercing or dropping it here would put a second + // opinion about the sort dialect in the delegation path — the fourth + // dialect this whole chain exists to avoid. const grid = forwardedGridSchema({ table: { sort: 'name desc' } as any }); expect(grid.sort).toBe('name desc'); }); @@ -279,14 +284,13 @@ describe('grid path: a named view still outranks the table segment', () => { // `defaultSort: [{ field: 'name', order: 'desc' }]` and said in so many // words that the pin should be UPDATED, not deleted, by whoever fixed the // arity. Updated here: the array now rides the canonical slot — the only - // one of the two whose declared type (`string | SortConfig[]`) can hold - // more than one key — and the legacy slot carries the `table` segment - // alone. Precedence is what this block asserts and it is unchanged: + // one of the two whose declared type (`SortConfig[]`) can hold more than + // one key — and the legacy slot carries the `table` segment alone. Precedence is what this block asserts and it is unchanged: // ObjectGrid resolves `schemaSort ?? defaultSort`, so the named view wins // over `table.sort` because it is what reaches the canonical slot. const grid = forwardedGridSchema({ ...namedView, - table: { sort: 'created asc' } as any, + table: { sort: [{ field: 'created', order: 'asc' }] } as any, } as any); expect(grid.sort).toEqual([{ field: 'name', order: 'desc' }]); expect(grid.defaultSort).toBeUndefined(); @@ -398,11 +402,12 @@ describe('delegated renderListView: canonical first, alias still working', () => }); it('hands over a canonical table.sort and prefers it over table.defaultSort', () => { - expect(delegatedSchema({ table: { sort: 'name desc' } as any }).sort).toBe('name desc'); + const SORT = [{ field: 'name', order: 'desc' }]; + expect(delegatedSchema({ table: { sort: SORT } as any }).sort).toEqual(SORT); const s = delegatedSchema({ - table: { sort: 'name desc', defaultSort: { field: 'created', order: 'asc' } } as any, + table: { sort: SORT, defaultSort: { field: 'created', order: 'asc' } } as any, }); - expect(s.sort).toBe('name desc'); + expect(s.sort).toEqual(SORT); }); it('still hands over table.defaultSort alone — WRAPPED', () => { diff --git a/packages/plugin-view/src/__tests__/ObjectView.filterSources.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.filterSources.test.tsx index 5bc6b69540..978cfd7587 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.filterSources.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.filterSources.test.tsx @@ -167,7 +167,7 @@ describe('ObjectView hands the view filter to the delegated renderer', () => { // ⚠️ CONTROL, green in BOTH states — named so it is not read as evidence // for objectui#6235. `table.sort` is the slot that legitimately carries an - // array (`ObjectGridSchema.sort: string | SortConfig[]`), and it must reach + // array (`ObjectGridSchema.sort: SortConfig[]`), and it must reach // the delegated node UNWRAPPED. What this guards is the wrong shape the fix // could have taken: wrapping the chain's RESULT instead of its final branch, // which would re-wrap this into `[[{ field, order }]]` — verbatim diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx index 370b96b721..d40581b8a7 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx @@ -27,10 +27,13 @@ * .order}` `` reads two absent keys off an array, so the request * carried the literal string `"undefined undefined"`. * - * The fix routes the view segments into the CANONICAL `sort` slot, declared - * `string | SortConfig[]` — the arity a view actually carries, and the same - * spelling the shared sort sink `convertSortToQueryParams` accepts - * (objectui#4869), so no fourth dialect is introduced to make this work. + * The fix routes the view segments into the CANONICAL `sort` slot — the arity + * a view actually carries, and the same spelling the shared sort sink + * `convertSortToQueryParams` accepts (objectui#4869), so no fourth dialect is + * introduced to make this work. (That slot was declared + * `string | SortConfig[]` when this was written; objectui#8221 retired the + * string arm, which changes nothing here — the array was always the arity + * these tests are about.) * * These tests drive the REAL `ObjectGrid` rather than a probe that records the * forwarded schema: the defect was invisible in the forwarded object (the array diff --git a/packages/plugin-view/src/__tests__/ObjectView.sortSink.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.sortSink.test.tsx index afa344cf05..6331d57d7a 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.sortSink.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.sortSink.test.tsx @@ -57,7 +57,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, waitFor } from '@testing-library/react'; -import { convertSortToQueryParams } from '@object-ui/core'; +import { convertSortToQueryParams, resetRetiredSortSpellingReports } from '@object-ui/core'; import { ObjectView } from '../ObjectView'; import type { ObjectViewSchema } from '@object-ui/types'; @@ -141,15 +141,41 @@ describe('the legacy table.defaultSort no longer reaches $orderby as a map', () }); describe('every other member of the chain reaches $orderby normalized too', () => { - it('lowers the canonical string form of table.sort', async () => { - // `ObjectGridSchema.sort` is `string | SortConfig[]`. The string used to - // ride to the wire untouched (`name desc`); it now arrives as the one - // normalized shape, which the adapter serializes to `-name`. - expect(await orderbyFor({ table: { sort: 'name desc' } as any })).toEqual({ name: 'desc' }); + it('REFUSES the retired string form of table.sort, and says so (objectui#8221)', async () => { + // `ObjectGridSchema.sort` was `string | SortConfig[]`; decision batch #77 + // retired the string arm, so the shared sink now refuses it instead of + // lowering it. The type no longer admits it either — `as any` is how a + // JSON document or a stored metadata row still reaches this read site. + resetRetiredSortSpellingReports(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(await orderbyFor({ table: { sort: 'name desc' } as any })).toBeUndefined(); + expect(errorSpy).toHaveBeenCalled(); + const message = String(errorSpy.mock.calls[0][0]); + // The diagnostic names the array form — a refusal with no prescription + // just moves the author's problem. + expect(message).toContain("[{ field: 'name', order: 'desc' }]"); + expect(message).toContain('"name desc"'); + } finally { + errorSpy.mockRestore(); + } }); - it('lowers a bare field string to ascending', async () => { - expect(await orderbyFor({ table: { sort: 'name' } as any })).toEqual({ name: 'asc' }); + it('REFUSES a bare field string too — every retired spelling, not just the two-word one', async () => { + resetRetiredSortSpellingReports(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(await orderbyFor({ table: { sort: 'name' } as any })).toBeUndefined(); + expect(errorSpy).toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + + // CONTROL — the array arm of the SAME key on the SAME read site still + // reaches `$orderby`, so the two refusals above are about the spelling and + // not a read site that stopped reading `table.sort`. + expect(await orderbyFor({ table: { sort: [{ field: 'name', order: 'desc' }] } as any })) + .toEqual({ name: 'desc' }); }); it('lowers the canonical SortConfig[] form of table.sort', async () => { @@ -193,7 +219,7 @@ describe('precedence is unchanged by the lowering', () => { won: { label: 'Won', type: 'calendar', sort: [{ field: 'name', order: 'desc' }] }, }, defaultListView: 'won', - table: { sort: 'created asc', defaultSort: { field: 'created', order: 'asc' } } as any, + table: { sort: [{ field: 'created', order: 'asc' }], defaultSort: { field: 'created', order: 'asc' } } as any, } as any)).toEqual({ name: 'desc' }); }); }); diff --git a/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts b/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts index 6ff9a452b6..3145cdb7fb 100644 --- a/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts +++ b/packages/types/src/__tests__/gantt-flat-config-declared-keys.test.ts @@ -179,7 +179,7 @@ const GOOD = { dependencyTypes: false, staticData: [{ id: 1, name: 'Task' }], filter: [['name', '=', 'Task']], - sort: 'name desc', + sort: [{ field: 'name', order: 'desc' as const }], }; describe('ObjectGanttSchema — the flattened gantt config is declared (objectui#6051)', () => { @@ -394,7 +394,8 @@ describe('ObjectGanttSchema (TS) — compile-time pin on every declared key', () const staticData: ObjectGanttSchemaTS['staticData'] = { id: 1 }; // @ts-expect-error — `filter` is declared `any[] | undefined`. const filter: ObjectGanttSchemaTS['filter'] = 'name = 1'; - // @ts-expect-error — `sort` is declared `string | SortConfig[] | undefined`. + // @ts-expect-error — `sort` is declared `SortConfig[] | undefined` (the legacy + // string clause was retired in objectui#8221). const sort: ObjectGanttSchemaTS['sort'] = 5; expect([ diff --git a/packages/types/src/__tests__/sort-string-arm-retired-8221.test.ts b/packages/types/src/__tests__/sort-string-arm-retired-8221.test.ts new file mode 100644 index 0000000000..68b3f56258 --- /dev/null +++ b/packages/types/src/__tests__/sort-string-arm-retired-8221.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The legacy string `sort` clause is RETIRED on every declaration that used to + * publish it — objectui#8221, director ruling, decision batch #77, option B: + * "The platform has one `sort` spelling, the array, everywhere." + * + * ## Why the mirror is pinned and not only the interface + * + * The TypeScript face and its zod mirror are the DECLARED and the ENFORCED half + * of one contract. A narrowing that moved only the interface would leave + * `z.union([z.string(), …])` accepting the retired clause at parse — which is + * exactly the declared-vs-enforced split this card exists to close, and it + * would be invisible to a reader of the interface. So both halves are asserted + * here, on the same three nodes, in the same file. + * + * ## Non-vacuity + * + * Every refusal below is paired with a control on the SAME schema in the SAME + * shape: the array arm must still parse. A mirror that had simply stopped + * accepting anything would fail those controls, so "refused" is a verdict here + * and not silence. `bogusProp` is not usable as the control on these nodes — + * they extend a passthrough base — which is why the control is the array arm. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { + ObjectGridSchema, + ObjectMapSchema, + ObjectGanttSchema, +} from '../zod/objectql.zod'; +import type { + ObjectGridSchema as TsObjectGridSchema, + ObjectMapSchema as TsObjectMapSchema, + ObjectGanttSchema as TsObjectGanttSchema, + SortConfig, +} from '../objectql'; + +/* ── Type-level: the interface face carries the array alone ──────────────── */ + +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +export type _GridSortIsArrayOnly = Expect< + Equal< NonNullable< TsObjectGridSchema['sort'] >, SortConfig[] > +>; +export type _MapSortIsArrayOnly = Expect< + Equal< NonNullable< TsObjectMapSchema['sort'] >, SortConfig[] > +>; +export type _GanttSortIsArrayOnly = Expect< + Equal< NonNullable< TsObjectGanttSchema['sort'] >, SortConfig[] > +>; + +/** And the mirror's AUTHORING face agrees with the interface, key for key. */ +export type _MirrorGridFaceMatches = Expect< + Equal< NonNullable< z.input< typeof ObjectGridSchema >['sort'] >, SortConfig[] > +>; + +/* ── Runtime: the mirrors refuse the clause and accept the array ─────────── */ + +const NODES = [ + ['object-grid', ObjectGridSchema, { type: 'object-grid', objectName: 'task' }], + ['object-map', ObjectMapSchema, { type: 'object-map', objectName: 'store' }], + ['object-gantt', ObjectGanttSchema, { type: 'object-gantt', objectName: 'task' }], +] as const; + +const ARRAY_ARM = [{ field: 'name', order: 'desc' }] as const; + +describe('the retired string `sort` clause (objectui#8221)', () => { + it.each(NODES.map(([name]) => name))('%s refuses the legacy string clause', (name) => { + const [, schema, base] = NODES.find(([n]) => n === name)!; + for (const clause of ['name desc', 'name asc', 'name', 'name DESC']) { + const result = schema.safeParse({ ...base, sort: clause }); + expect(result.success, `${name} accepted \`${clause}\``).toBe(false); + expect( + result.success ? [] : result.error.issues.map((i) => i.path.join('.')), + ).toContain('sort'); + } + }); + + it.each(NODES.map(([name]) => name))( + 'CONTROL — %s still accepts the array arm on the same key', + (name) => { + const [, schema, base] = NODES.find(([n]) => n === name)!; + const result = schema.safeParse({ ...base, sort: ARRAY_ARM }); + expect( + result.success ? null : result.error.issues.map((i) => i.path.join('.') + '/' + i.code), + `${name} refused the array arm`, + ).toBeNull(); + }, + ); + + it('COUNTER-PROBE — a shape that was never in either arm is still refused', () => { + // So "refuses a string" is not the whole of what these nodes do: the key is + // typed, not merely string-hostile. + for (const [name, schema, base] of NODES) { + expect(schema.safeParse({ ...base, sort: 42 }).success, name).toBe(false); + } + }); + + it('the mirror declares `sort` at all — membership, not acceptance', () => { + // These nodes extend a passthrough base, so acceptance alone cannot tell + // "declared" from "admitted unexamined". Reading `.shape` can. + for (const [name, schema] of NODES) { + expect(Object.keys(schema.shape), name).toContain('sort'); + } + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index e601fe42a6..f9895d8453 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -680,11 +680,14 @@ export interface ObjectGridSchema extends BaseSchema { /** * Sort Configuration - * Can be either: - * - Legacy string format: "name desc" - * - Array of sort configs: [{ field: 'name', order: 'desc' }] + * + * `[{ field: 'name', order: 'desc' }]` — `order` is optional and means + * `'asc'`. The legacy string clause (`"name desc"`) is RETIRED + * (objectui#8221, decision batch #77): the array is the only spelling this + * key's declared input publishes (`type: 'array'`) and the only one + * `convertSortToQueryParams` lowers. */ - sort?: string | SortConfig[]; + sort?: SortConfig[]; /** * Fields enabled for search @@ -2335,8 +2338,8 @@ export interface ObjectMapSchema extends BaseSchema { staticData?: any[]; /** Query filter, forwarded verbatim as `$filter` */ filter?: any[]; - /** Sort configuration, forwarded as `$orderby` */ - sort?: string | SortConfig[]; + /** Sort configuration, forwarded as `$orderby`. Array only — the legacy string clause is retired (objectui#8221). */ + sort?: SortConfig[]; /** * Map configuration — the author face. See `ObjectMapConfig`. * @@ -2720,8 +2723,8 @@ export interface ObjectGanttSchema extends BaseSchema { staticData?: any[]; /** Query filter (JSON Rules format), forwarded verbatim as `$filter`. */ filter?: any[]; - /** Sort configuration, forwarded as `$orderby` via `convertSortToQueryParams`. */ - sort?: string | SortConfig[]; + /** Sort configuration, forwarded as `$orderby` via `convertSortToQueryParams`. Array only — the legacy string clause is retired (objectui#8221). */ + sort?: SortConfig[]; } /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index d8104f2a07..0f36035562 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -223,7 +223,7 @@ export const ObjectGridSchema = BaseSchema.extend({ data: ViewDataSchema.optional().describe('Data source configuration'), columns: z.union([z.array(z.string()), z.array(ListColumnSchema)]).optional().describe('Columns configuration'), filter: z.array(z.any()).optional().describe('Filter criteria'), - sort: z.union([z.string(), z.array(SortConfigSchema)]).optional().describe('Sort configuration'), + sort: z.array(SortConfigSchema).optional().describe('Sort configuration (array only; the legacy string clause is retired — objectui#8221)'), searchableFields: z.array(z.string()).optional().describe('Searchable fields'), resizable: z.boolean().optional().describe('Enable column resizing'), showColumnTypeIcons: z.boolean().optional().describe('Show column type icons (T/Tag/Calendar) in headers. Off by default — type is usually obvious from cell content; the icons add visual noise.'), @@ -855,7 +855,7 @@ export const ObjectMapSchema = BaseSchema.extend({ data: ViewDataSchema.optional().describe('Data source configuration — read FIRST by getDataConfig'), staticData: z.array(z.any()).optional().describe('Inline records — read SECOND by getDataConfig, wrapped into a { provider: value } config'), filter: z.array(z.any()).optional().describe('Query filter, forwarded as $filter'), - sort: z.union([z.string(), z.array(SortConfigSchema)]).optional().describe('Sort configuration, forwarded as $orderby'), + sort: z.array(SortConfigSchema).optional().describe('Sort configuration, forwarded as $orderby (array only; the legacy string clause is retired — objectui#8221)'), map: ObjectMapConfigSchema.optional().describe('Map configuration (the author face)'), enableClustering: z.boolean().optional().describe('Group nearby markers into clusters'), navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Record navigation behaviour (drawer/dialog/page)'), @@ -1050,7 +1050,7 @@ export const ObjectGanttSchema = BaseSchema.extend({ // objectui#5903 retyped it to `ObjectGanttSchema` — so they need declaring here. staticData: z.array(z.any()).optional().describe('Inline records, wrapped into a { provider: value } data config — read SECOND by getDataConfig'), filter: z.array(z.any()).optional().describe('Query filter, forwarded verbatim as $filter'), - sort: z.union([z.string(), z.array(SortConfigSchema)]).optional().describe('Sort configuration, forwarded as $orderby'), + sort: z.array(SortConfigSchema).optional().describe('Sort configuration, forwarded as $orderby (array only; the legacy string clause is retired — objectui#8221)'), }).superRefine(requireRecordSource('object-gantt')); /** From 1492fc30e4465b6defc3072df4ea291604ab12e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 03:57:44 +0000 Subject: [PATCH 2/2] fix(examples,tooling): migrate the one authored string `sort` fixture and re-key the moved doc-example ledger row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../src/schemas/plugin-view/object-view-list.json | 2 +- scripts/check-doc-example-types.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/schema-catalog/src/schemas/plugin-view/object-view-list.json b/examples/schema-catalog/src/schemas/plugin-view/object-view-list.json index 25343781a0..fe7d056b14 100644 --- a/examples/schema-catalog/src/schemas/plugin-view/object-view-list.json +++ b/examples/schema-catalog/src/schemas/plugin-view/object-view-list.json @@ -11,7 +11,7 @@ "searchableFields": ["name", "email", "department"], "table": { "columns": ["name", "email", "role", "department", "status"], - "sort": "name asc", + "sort": [{ "field": "name", "order": "asc" }], "pagination": { "pageSize": 5 } } } diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index e5c9bac341..cd738c88ca 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -978,7 +978,7 @@ export const UNGATED_EXAMPLES = { reason: 'usage fragment: references `save`, `storedPage`, which the example never declares', }, - 'packages/types/src/objectql.ts:1604 ObjectFormSchema': { + 'packages/types/src/objectql.ts:1607 ObjectFormSchema': { card: null, codes: [1005, 1109], reason: