Skip to content

Commit dbd4744

Browse files
os-elon-muskclaude
andauthored
docs(spec): cross-reference the two $orderby declarations and pin their disjoint accept sets (#19018)
Fixes #18977 Clause-②: no — no accept set moves, and no export is added, removed or renamed. The diff is two docblocks in published source, one new pin test, and the changeset. Measured, not asserted: `check:generated` reports all 16 generated artifacts up to date, `check:api-surface`, `check:api-surface-declarations`, `check:authorable-surface` and `check:docs` included. ## The card `$orderby` is declared twice in `packages/spec`, and the two declarations are **complementary refusals** — each accepts exactly what the other rejects — with no cross-reference in either direction. Re-measured on this branch's base (`43f4766889`) with `safeParse` against a fresh build of both schemas: | `$orderby` value | `ODataQuerySchema` (`src/api/odata.zod.ts`) | `QueryTransportParamsSchema` = `DataEngineSortSchema` (`src/data/data-engine.zod.ts`) | |:---|:---|:---| | `'name desc'` / `'-created_at'` | accepted | REFUSED | | `['name desc', 'email asc']` | accepted | REFUSED | | `[{field, order}]` | REFUSED | accepted | | the `asc`/`desc` record map | REFUSED | accepted | | the `1`/`-1` record map | REFUSED | accepted | The premise holds exactly as filed. ## The reading the card flagged as not re-derived, re-derived here The card said — explicitly as the filer's reading — that `ODataQuerySchema`'s only in-repo consumer is the `buildUrl` helper in its own file. **Instrument**: `git grep -n ODataQuerySchema` and `git grep -n '\bODataQuery\b'` over this worktree at `43f476688`. **Unit**: files naming the symbol. **Result**: the declaration is consumed by `OData.buildUrl` at the foot of its own file, by its own unit test `src/api/odata.test.ts`, and by `src/type-alias-convention.pin.test.ts` (a generic pin that names every schema in the module). Everything else is a generated artefact — `api-surface*`, `authorable-surface*`, `declaration-map`, `export-origins`, `json-schema.manifest` — or the generated reference page. **Zero** routes, ingress paths or normalizers. **Lit control, same instrument, same tree**: the same grep over `FindDataRequestSchema` lands on `packages/rest/src/rest-server.ts:9019`, the `POST /data/:object/query` handler that `safeParse`s its body against it; over `QueryTransportParamsSchema` it lands on `packages/rest/src/rest-server-canonical-query-ast.test.ts`. So the instrument does find consumers outside `packages/spec` when there are any — the zero is a reading, not a dead instrument. ⇒ **`ODataQuerySchema` grades no runtime door.** The declaration that grades a query bag is `QueryTransportParamsSchema`, reached from `FindDataRequestSchema.query` through `QueryWithTransportSchema`. ## Context the card predates: #18704 already settled which spelling is canonical `0b788da89` declared the query transport dialect as the flattened spelling of the QueryAST, and its own body names the OData sort expression among the shapes that now answer `400` at the ingress. The reason is in the source, verbatim: 「⛔ Three shapes are deliberately NOT declared, because lowering them means PARSING — and a second parser beside the door's is how one rule gets two implementations that disagree」. So option C on the card — widening `DataEngineSortSchema` to accept the string forms — is the thing that commit refused, and option B — widening the OData schema — moves a published accept set. Both are maintainer questions, not this PR. **And the string forms are not unserved**, which is the part neither declaration says. `normalizeSortNodes` (`packages/metadata-protocol/src/protocol.ts`) is the one shared ingress normalizer behind `GET /data/:object`, the export route and in-process `findData`, and it reads `'name desc'`, `'-created_at'` and the `string[]` form. Measured at the exact input shape `rest-server.ts` builds: | `POST /data/:object/query` body | `FindDataRequestSchema.safeParse` | |:---|:---| | `{"$orderby": "name desc"}` | `400 VALIDATION_FAILED` at `query.$orderby` | | `{"$orderby": ["name desc"]}` | `400 VALIDATION_FAILED` at `query.$orderby` | | `{"$orderby": {"created_at": "desc"}}` | 200, folds to `orderBy: [{field, order}]` | | `{"sort": "-created_at"}` | `400 VALIDATION_FAILED` at `query.sort` | The same querystring on the GET route works. The difference is the **door**, and neither door is `ODataQuerySchema`. ## What this PR changes Option A on the card, and nothing else — the reader's half of the defect: 1. **`src/api/odata.zod.ts`** — the docblock above `ODataQuerySchema` now says it grades no runtime door, names `QueryTransportParamsSchema` as the declaration that does, carries the complementary-refusal table, says why the gap is a decision rather than a defect, and says what actually parses the string forms. The `$orderby` member carries the same pointer at the point of use. 2. **`src/data/data-engine.zod.ts`** — the reciprocal pointer, inside the paragraph that states the refusal. It names `ODataQuerySchema.$orderby` as the second declaration, records that it grades nothing, and records the cost already paid: objectui#9554 was filed, triaged, graded and dispatched against a shipped `object-grid` producer that had been sending the canonical shape all along. 3. **`src/api/odata-orderby-dual-declaration.test.ts`** — 25 cases, the mechanical half of the cross-reference: each side's accept set, their disjointness (with the lit control that neither set is empty, since two schemas that accept nothing are also disjoint), and which of the two `FindDataRequestSchema.query` is graded by. ⛔ No `.describe()`, no Zod type, no export and no authorable key is touched. Every `safeParse` verdict on both declarations is the same before and after. ## Reverse verification — the pin is capable of failing One-off, committed first, mutated on disk through `scripts/ablation-replace.mjs` (anchor `1 -> 0`, blob `b25169449f69 -> 25040216bf74`), restored under a `trap`. No dist preflight was owed: the pin imports `./odata.zod` relatively, so it resolves to source and no build stands between the mutation and the verdict. - **Mutation**: add the `asc`/`desc` record arm to `ODataQuerySchema.$orderby`, so the two accept sets overlap on one value. - **Predicted direction**: turn red — the OData side's refusal case and both disjointness cases. - **Observed**: `3 failed | 22 passed (25)` — `refuses the asc/desc record map`, `no declared $orderby value parses under both` (`expected [ 'the asc/desc record map' ] to deeply equal []`) and `every declared $orderby value parses under exactly one of them` (`expected [ 1, 1, 1, 1, 2, 1, 1 ]`). - **Restore proven by state, not by exit code**: `blob after restore b251694 == blob at HEAD b251694`, `git diff HEAD` empty. ## Evidence, at `c7e22addb` - `pnpm --filter @objectstack/spec build` — exit 0, 34/34 declaration files emitted. - `pnpm --filter @objectstack/spec check:generated` — exit 0, **all 16 generated artifacts up to date**. - `pnpm --filter @objectstack/spec typecheck` — exit 0; the test layer compiles under `tsconfig.test.json` and `test-typecheck-debt.json` is unmoved at 54 files / 259 errors / 144 pinned signatures. - `pnpm --filter @objectstack/spec test` (project `local`) — **490 files / 14234 tests passed**, exit 0. - New pin alone: 25 passed. - Every heavy run went through `scripts/pm/os-verify-lock.sh`; the verdicts above are its `VERDICT command-exit` lines, not bare `$?`. ## Acceptance notes Two findings outside this card's scope. ⛔ Not filed by me and ⛔ not repaired here; they are in the report for the dispatching seat. 1. **`content/docs/api/data-api.mdx` teaches two `POST /data/:object/query` sort spellings that the route refuses.** It says sorts accept `{"orderBy": [{"field": "created_at", "order": "desc"}]}`, `{"orderBy": ["-created_at"]}` or `{"orderBy": {"created_at": "desc"}}`, "all equivalent". Measured at the shape `rest-server.ts` builds: the first is 200; `{"orderBy": ["-created_at"]}` is `400 VALIDATION_FAILED` at `query.orderBy.0` (`expected object, received string`) and `{"orderBy": {"created_at": "desc"}}` is `400 VALIDATION_FAILED` at `query.orderBy` (`expected array, received object`). Canonical `orderBy` is `z.array(SortNodeSchema)`; the record map and the shorthand array are transport-slot values, so they have to arrive on `$orderby` / `sort`. 2. **The `@example Programmatic Use` in `src/api/odata.zod.ts`'s file-level docblock parses to `{}`.** It writes `select` / `filter` / `orderby` / `top` / `skip` / `expand` / `count` — unprefixed — against the type `ODataQuery`, whose every key carries a `$`. `ODataQuerySchema.safeParse` on that bag verbatim succeeds and returns `{}`: every key is stripped. The block ships to `content/docs/references/api/odata.mdx`, so it is a published example. Left alone here on purpose: the file-level docblock is the one part of this file that feeds the generated reference page, and this lane fenced `content/docs/references/**` for the round. --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2d235bc commit dbd4744

4 files changed

Lines changed: 241 additions & 0 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`$orderby` is declared twice — `ODataQuerySchema.$orderby` and `QueryTransportParamsSchema.$orderby` now cross-reference each other, and a pin holds the two accept sets apart (#18977).
6+
7+
Clause-②: no. No accept set moves and no export is added, removed or renamed: the change is two docblocks in published source (`src/api/odata.zod.ts`, `src/data/data-engine.zod.ts`) plus a new pin test. Measured — `check:generated` reports all 16 generated artifacts up to date, `check:api-surface` and `check:authorable-surface` included.
8+
9+
The two declarations are **complementary refusals**: each accepts exactly what the other rejects, and neither pointed at the other, so reading one of them carefully and completely still produced the wrong answer about the other.
10+
11+
| `$orderby` value | `ODataQuerySchema` | `QueryTransportParamsSchema` (`DataEngineSortSchema`) |
12+
|:---|:---|:---|
13+
| `'name desc'` / `'-created_at'` | accepted | REFUSED |
14+
| `['name desc', 'email asc']` | accepted | REFUSED |
15+
| `[{field, order}]` | REFUSED | accepted |
16+
| `{name: 'asc'}` / `{name: 1}` | REFUSED | accepted |
17+
18+
- **Which one grades a query bag**: `QueryTransportParamsSchema`, reached from `FindDataRequestSchema.query` through `QueryWithTransportSchema` — the schema `POST /data/:object/query` parses its body against. `ODataQuerySchema` grades no runtime door: measured on this tree, its only consumers are the `OData.buildUrl` helper in its own file and its own unit test.
19+
- **The refusal on the transport side is deliberate and stays** — `#18704` settled it: lowering an OData sort *expression* means PARSING, and a second parser beside the door's is how one rule gets two implementations that disagree. Widening either side to close the gap is a decision, not a tidy-up, so this change closes the **reader's** half only.
20+
- **The string forms are not unserved.** `normalizeSortNodes` (`@objectstack/metadata-protocol`) reads `'name desc'`, `'-created_at'` and the `string[]` form at the shared ingress behind `GET /data/:object`, the export route and in-process `findData`. A querystring spelled the OData way works; the same bag sent as a `POST /data/:object/query` body answers `400 VALIDATION_FAILED`. The difference is the door, and neither door is `ODataQuerySchema`.
21+
- **The cost this repairs was already paid.** objectui#9554 was filed, triaged, graded and dispatched against a shipped `object-grid` producer that had been sending the canonical shape all along, because the filing seat read the OData declaration and quoted it correctly.
22+
23+
`src/api/odata-orderby-dual-declaration.test.ts` is the mechanical half: 25 cases pinning each side's accept set, their disjointness (with the lit control that neither set is empty), and which of the two `FindDataRequestSchema.query` is graded by. Widening or narrowing either declaration turns it red and lands the author on the cross-reference.
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#18977] `$orderby` is declared TWICE, and the two declarations are
5+
* COMPLEMENTARY REFUSALS — each accepts exactly what the other rejects.
6+
*
7+
* | declaration | accepts | refuses |
8+
* |:---|:---|:---|
9+
* | `ODataQuerySchema.$orderby` (`api/odata.zod.ts`) | `string`, `string[]` | the record maps, `SortNode[]` |
10+
* | `QueryTransportParamsSchema.$orderby` = `DataEngineSortSchema` (`data/data-engine.zod.ts`) | the record maps, `SortNode[]` | `string`, `string[]` — deliberately, #18704 |
11+
*
12+
* Neither file pointed at the other, so reading one of them carefully and
13+
* completely still produced the wrong answer about the other — measured, at a
14+
* price: objectui#9554 was filed, triaged, graded and dispatched against a
15+
* shipped `object-grid` producer that had been sending the canonical shape all
16+
* along, because the filing seat read the OData declaration and correctly
17+
* quoted it. The cross-references landed in both files with this card; this
18+
* file is their MECHANICAL half.
19+
*
20+
* ## What this pins, and what it deliberately does not
21+
*
22+
* It pins the two accept sets AS THEY ARE, and their disjointness. It is not
23+
* an argument that either set is right:
24+
*
25+
* - §1/§2 hold each side's accept set, so widening or narrowing either one
26+
* turns this red and lands the author on the cross-reference that explains
27+
* why the gap is a decision rather than a defect. Widening the TRANSPORT
28+
* side is the one the source argues against in its own words — lowering a
29+
* sort expression means PARSING, and a second parser beside the door's is
30+
* how one rule gets two implementations that disagree.
31+
* - §3 is the disjointness itself: no value parses under both. That is the
32+
* property a reader cannot get from either file alone, and the one that
33+
* makes "I read the declaration" insufficient.
34+
* - §4 says which of the two grades a query bag, through the slot the REST
35+
* door actually parses (`FindDataRequestSchema.query`).
36+
*
37+
* ⛔ It pins nothing about what the RUNTIME serves. The OData string forms are
38+
* not unserved — `normalizeSortNodes` (`@objectstack/metadata-protocol`) reads
39+
* them at the GET querystring ingress and for in-process `findData`. What this
40+
* file measures is the two SCHEMAS, which is where the card's trap lives.
41+
*/
42+
43+
import { describe, it, expect } from 'vitest';
44+
import { ODataQuerySchema } from './odata.zod';
45+
import { FindDataRequestSchema } from './protocol.zod';
46+
import { DataEngineSortSchema, QueryTransportParamsSchema } from '../data/data-engine.zod';
47+
48+
/** The two shapes `ODataQuerySchema` declares and the transport schema refuses. */
49+
const ODATA_SPELLINGS: ReadonlyArray<readonly [string, unknown]> = [
50+
["the 'field direction' expression", 'name desc'],
51+
["the '-field' shorthand", '-created_at'],
52+
['the expression array', ['name desc', 'email asc']],
53+
['a single-element expression array', ['name']],
54+
];
55+
56+
/** The three shapes `DataEngineSortSchema` declares and the OData schema refuses. */
57+
const TRANSPORT_SPELLINGS: ReadonlyArray<readonly [string, unknown]> = [
58+
['the asc/desc record map', { name: 'desc' }],
59+
['the 1/-1 record map', { name: 1 }],
60+
['the SortNode array', [{ field: 'name', order: 'desc' }]],
61+
];
62+
63+
describe('[#18977] $orderby is declared twice — the two accept sets', () => {
64+
describe('§1 ODataQuerySchema.$orderby — the OData URL-convention vocabulary', () => {
65+
it.each(ODATA_SPELLINGS)('accepts %s', (_label, value) => {
66+
const parsed = ODataQuerySchema.safeParse({ $orderby: value });
67+
expect(parsed.success).toBe(true);
68+
});
69+
70+
it.each(TRANSPORT_SPELLINGS)('refuses %s, at the $orderby member', (_label, value) => {
71+
const parsed = ODataQuerySchema.safeParse({ $orderby: value });
72+
expect(parsed.success).toBe(false);
73+
if (parsed.success) return;
74+
expect(parsed.error.issues.map((i) => i.path.join('.'))).toContain('$orderby');
75+
});
76+
});
77+
78+
describe('§2 DataEngineSortSchema — what QueryTransportParamsSchema.$orderby is', () => {
79+
it.each(TRANSPORT_SPELLINGS)('accepts %s', (_label, value) => {
80+
expect(DataEngineSortSchema.safeParse(value).success).toBe(true);
81+
expect(QueryTransportParamsSchema.safeParse({ $orderby: value }).success).toBe(true);
82+
});
83+
84+
it.each(ODATA_SPELLINGS)('refuses %s, at the $orderby member', (_label, value) => {
85+
expect(DataEngineSortSchema.safeParse(value).success).toBe(false);
86+
const parsed = QueryTransportParamsSchema.safeParse({ $orderby: value });
87+
expect(parsed.success).toBe(false);
88+
if (parsed.success) return;
89+
expect(parsed.error.issues.map((i) => i.path.join('.'))).toContain('$orderby');
90+
});
91+
92+
it('refuses the OData spelling on `sort` too — the bare transport alias of the same slot', () => {
93+
// A reader who works around the `$orderby` refusal by re-spelling the key
94+
// gets the same answer: the refusal is on the VALUE, not on the spelling.
95+
for (const [, value] of ODATA_SPELLINGS) {
96+
expect(QueryTransportParamsSchema.safeParse({ sort: value }).success).toBe(false);
97+
}
98+
});
99+
});
100+
101+
describe('§3 the two accept sets are DISJOINT — this is the trap', () => {
102+
it('no declared $orderby value parses under both', () => {
103+
const every = [...ODATA_SPELLINGS, ...TRANSPORT_SPELLINGS];
104+
const bothAccept = every.filter(([, value]) =>
105+
ODataQuerySchema.safeParse({ $orderby: value }).success
106+
&& DataEngineSortSchema.safeParse(value).success);
107+
expect(bothAccept.map(([label]) => label)).toEqual([]);
108+
});
109+
110+
it('every declared $orderby value parses under exactly one of them — neither set is empty', () => {
111+
// The lit control for the emptiness above: a disjointness assertion is
112+
// also satisfied by two schemas that accept nothing at all.
113+
const every = [...ODATA_SPELLINGS, ...TRANSPORT_SPELLINGS];
114+
const accepted = every.map(([, value]) =>
115+
Number(ODataQuerySchema.safeParse({ $orderby: value }).success)
116+
+ Number(DataEngineSortSchema.safeParse(value).success));
117+
expect(accepted).toEqual(every.map(() => 1));
118+
});
119+
});
120+
121+
describe('§4 which one grades a query bag', () => {
122+
// `POST /data/:object/query` parses its body through this schema and answers
123+
// `400 VALIDATION_FAILED` on a refusal (`rest-server.ts`), so this is the
124+
// declaration an author's stored query bag is actually judged against.
125+
const findInput = (query: Record<string, unknown>) => ({ object: 't', query: { ...query, object: 't' } });
126+
127+
it.each(TRANSPORT_SPELLINGS)('FindDataRequest.query accepts %s on $orderby', (_label, value) => {
128+
expect(FindDataRequestSchema.safeParse(findInput({ $orderby: value })).success).toBe(true);
129+
});
130+
131+
it.each(ODATA_SPELLINGS)('FindDataRequest.query refuses %s on $orderby', (_label, value) => {
132+
const parsed = FindDataRequestSchema.safeParse(findInput({ $orderby: value }));
133+
expect(parsed.success).toBe(false);
134+
if (parsed.success) return;
135+
expect(parsed.error.issues.map((i) => i.path.join('.'))).toContain('query.$orderby');
136+
});
137+
138+
it('ODataQuerySchema grades nothing here — it is not on any path into FindDataRequest', () => {
139+
// The canonical AST key is the one the output carries, whichever declared
140+
// spelling arrived: the transport spelling folds onto `orderBy`.
141+
const parsed = FindDataRequestSchema.safeParse(findInput({ $orderby: { created_at: 'desc' } }));
142+
expect(parsed.success).toBe(true);
143+
if (!parsed.success) return;
144+
expect((parsed.data as { query: { orderBy?: unknown } }).query.orderBy)
145+
.toEqual([{ field: 'created_at', order: 'desc' }]);
146+
expect((parsed.data as { query: Record<string, unknown> }).query.$orderby).toBeUndefined();
147+
});
148+
});
149+
});

‎packages/spec/src/api/odata.zod.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,47 @@ import { z } from 'zod';
7171
* System query options defined by OData v4 specification.
7272
* These are URL query parameters that control the query execution.
7373
*
74+
* ## [#18977] This schema grades no runtime door — and a SECOND declaration of
75+
* the same parameter names does
76+
*
77+
* The declaration that grades a query bag is `QueryTransportParamsSchema`
78+
* (`../data/data-engine.zod.ts`), reached from `FindDataRequestSchema.query`
79+
* through `QueryWithTransportSchema`. This one is the OData v4 URL-convention
80+
* VOCABULARY and nothing parses through it: measured on this tree, its only
81+
* consumers are the `OData.buildUrl` helper at the foot of this file and this
82+
* file's own unit test — no route, no ingress, no normalizer.
83+
*
84+
* ⚠️ On `$orderby` the two declarations are COMPLEMENTARY REFUSALS: each
85+
* accepts exactly what the other rejects, so reading either one carefully and
86+
* completely still produces the wrong answer about the other. Measured with
87+
* `safeParse` on both, one tree:
88+
*
89+
* | `$orderby` value | here | `QueryTransportParamsSchema.$orderby` |
90+
* |:---|:---|:---|
91+
* | `'name desc'` / `'-created_at'` | accepted | REFUSED |
92+
* | `['name desc', 'email asc']` | accepted | REFUSED |
93+
* | `[{field, order}]` | REFUSED | accepted |
94+
* | `{name: 'asc'}` / `{name: 1}` | REFUSED | accepted |
95+
*
96+
* Both halves are pinned in `odata-orderby-dual-declaration.test.ts`, which is
97+
* the mechanical half of this cross-reference: widening or narrowing either
98+
* side turns it red and lands the author here.
99+
*
100+
* ⛔ The gap is NOT closed by widening one side to match the other. #18704
101+
* settled which spelling is canonical and why the transport schema refuses
102+
* these two: lowering a sort EXPRESSION means PARSING, and a second parser
103+
* beside the door's is how one rule gets two implementations that disagree
104+
* (the paragraph above `QueryTransportParamsSchema` states it verbatim).
105+
* Changing either accept set is a decision, not a tidy-up.
106+
*
107+
* ⭐ What the string forms are not is unserved. `normalizeSortNodes`
108+
* (`@objectstack/metadata-protocol`) is the one shared ingress normalizer
109+
* behind `GET /data/:object`, the export route and in-process `findData`, and
110+
* it reads `'name desc'`, `'-created_at'` and the `string[]` form — so a
111+
* querystring spelled the OData way works, while the same bag sent as a
112+
* `POST /data/:object/query` body answers `400 VALIDATION_FAILED`. The
113+
* difference is the DOOR, and neither door is this schema.
114+
*
74115
* @see https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_SystemQueryOptions
75116
*/
76117
import { lazySchema } from '../shared/lazy-schema';
@@ -115,6 +156,15 @@ export const ODataQuerySchema = lazySchema(() => z.object({
115156
* @example "name"
116157
* @example "revenue desc"
117158
* @example "country asc, revenue desc"
159+
*
160+
* ⛔ [#18977] These two shapes are exactly the ones
161+
* `QueryTransportParamsSchema.$orderby` (`DataEngineSortSchema`,
162+
* `../data/data-engine.zod.ts`) DELIBERATELY refuses, and that is the
163+
* declaration a query bag is graded against. Sent as a
164+
* `POST /data/:object/query` body this spelling answers
165+
* `400 VALIDATION_FAILED`; sent on the querystring it is parsed by
166+
* `normalizeSortNodes` at the ingress and works. See the cross-reference on
167+
* the schema above before writing either shape into stored metadata.
118168
*/
119169
$orderby: z.union([
120170
z.string(), // "name desc"

‎packages/spec/src/data/data-engine.zod.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,25 @@ const TransportCountValueSchema = lazySchema(() => z.union([z.boolean(), z.enum(
830830
* its `string[]` form. They fail the parse at the member that carries them
831831
* rather than reaching the AST as a string the engine would have to re-read.
832832
*
833+
* ⚠️ [#18977] `$orderby` is declared a SECOND time, and the other declaration
834+
* accepts exactly the two shapes this one refuses: `ODataQuerySchema.$orderby`
835+
* (`../api/odata.zod.ts`) is `string | string[]` and refuses this one's record
836+
* maps and `SortNode[]`. It cannot contradict this schema at a DOOR — measured
837+
* on this tree, nothing parses through it: its only consumers are its own
838+
* `OData.buildUrl` helper and its own unit test. It contradicts it in a READER,
839+
* which is the cost already paid: objectui#9554 was filed, triaged and
840+
* dispatched against a shipped producer that had been sending the canonical
841+
* shape all along, because a competent seat read the OTHER declaration, quoted
842+
* it correctly, and had no signal that this one exists. The two accept sets are
843+
* disjoint and pinned as such in
844+
* `../api/odata-orderby-dual-declaration.test.ts`.
845+
*
846+
* ⛔ Closing that gap by widening either side is a decision, not a tidy-up —
847+
* and widening THIS one is precisely the second parser the paragraph above
848+
* refuses. What serves the string forms is `normalizeSortNodes` at the
849+
* `@objectstack/metadata-protocol` ingress (the GET querystring path, the
850+
* export route and in-process `findData`), not a schema.
851+
*
833852
* ⛔ Declaring the narrower structured form ALONE would have turned live
834853
* traffic into a `400` — measured: the body-form AST array on
835854
* `POST /data/:object/query`, pinned by `#7390 §3` in

0 commit comments

Comments
 (0)