|
| 1 | +--- |
| 2 | +"@objectstack/spec": major |
| 3 | +"@objectstack/metadata-protocol": major |
| 4 | +--- |
| 5 | + |
| 6 | +feat(spec,metadata-protocol)!: a sort node spelling its direction `direction` is a 400, not a silently reversed page (#4721) |
| 7 | + |
| 8 | +**FROM → TO:** `orderBy: [{ field: 'updated_at', direction: 'desc' }]` → |
| 9 | +`orderBy: [{ field: 'updated_at', order: 'desc' }]`. One word. If you are on the |
| 10 | +`{field, direction}` shape because you moved code over from |
| 11 | +`IReportService.orderBy`, that contract is unchanged — it is `orderBy` on the |
| 12 | +QueryAST / `EngineQueryOptions` axis that has always been `{field, order}`. |
| 13 | + |
| 14 | +## What was wrong |
| 15 | + |
| 16 | +`SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied. |
| 17 | +Measured on `main` before this change: |
| 18 | + |
| 19 | +``` |
| 20 | +SortNodeSchema.parse({ field: 'updated_at', direction: 'desc' }) |
| 21 | + → { field: 'updated_at', order: 'asc' } |
| 22 | +``` |
| 23 | + |
| 24 | +`direction` was discarded and `order` fell back to its `asc` default. The sort |
| 25 | +therefore ran in the **opposite** direction and the request succeeded. Paired |
| 26 | +with `limit` — which is how a caller asks for "the latest N" — that is not a |
| 27 | +reordered page but a **different set of rows**, returned under an ordinary 200 |
| 28 | +with nothing in the response to distinguish it from the answer that was asked |
| 29 | +for. |
| 30 | + |
| 31 | +`direction` is not a typo. It is the live vocabulary of a neighbouring contract, |
| 32 | +`IReportService.orderBy` (`@objectstack/spec/contracts`), and |
| 33 | +`plugin-auth/objectql-adapter.ts` already translates between the two by hand — a |
| 34 | +translation known to be necessary and enforced nowhere, which is the ADR-0049 |
| 35 | +shape. |
| 36 | + |
| 37 | +## What changed |
| 38 | + |
| 39 | +Both doors onto that shape, in one change: |
| 40 | + |
| 41 | +1. **`SortNodeSchema`** (`spec/src/data/query.zod.ts`) is now `strictObject` |
| 42 | + with `aliases: { direction: 'order' }`. An unknown key is rejected, and |
| 43 | + `direction` specifically gets the translation in the error message — edit |
| 44 | + distance can never bridge `direction` → `order`, so a bare "unrecognized key" |
| 45 | + would leave the caller exactly where the silent strip did. |
| 46 | +2. **`normalizeSortNodes`** (`metadata-protocol/src/protocol.ts`), the ingress |
| 47 | + every REST/RPC `orderBy` funnels through, refuses `{ field, direction }` with |
| 48 | + `400 INVALID_SORT` naming `order` and quoting the corrected node. Closing only |
| 49 | + the schema would repeat the door asymmetry of #1535/#4522: `SortNodeSchema` is |
| 50 | + reachable by three paths the REST normalizer never sees. |
| 51 | + |
| 52 | +| `orderBy` you send | Before | After | |
| 53 | +|:--|:--|:--| |
| 54 | +| `[{ field: 'x', order: 'desc' }]` | descending | unchanged — descending | |
| 55 | +| `[{ field: 'x', direction: 'desc' }]` | **200, ascending** | `400 INVALID_SORT`, message names `order` | |
| 56 | +| `[{ field: 'x', order: 'desc', direction: 'asc' }]` | 200, descending | `400 INVALID_SORT` | |
| 57 | +| `'-x'` / `['-x']` / `{ x: 'desc' }` | descending | unchanged | |
| 58 | +| `{ direction: 'desc' }` (the `{field: direction}` map) | sorts by column `direction` | unchanged — a column may legitimately be called `direction` | |
| 59 | + |
| 60 | +Scope is deliberately narrow: **`QuerySchema`'s top level is untouched** and |
| 61 | +still accepts undeclared keys (`QuerySchema.safeParse({ object: 'sales', |
| 62 | +nonsenseKey: 1 }).success === true`). That is tracked in the #4001 campaign map |
| 63 | +for its own batch, not smuggled in here. |
| 64 | + |
| 65 | +Related: #4674, #4720, #4363, #4371, #4001, ADR-0049. |
0 commit comments