Repository navigation
Commit 140d8ce
fix(spec,metadata-protocol)!: honour field.sortable false in the sortability projection and the data door (#30021)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB6YVzCyL9h4KmxxZsHSmuk7)_
Fixes #29896
Clause-②: yes (narrowing) — `GET /api/v1/data/:object` (and every
request that reaches the same list door: `POST
/api/v1/data/:object/query`, the export route, the RPC dispatcher, the
record-page `related[].sort`) and the stdio MCP `query_records` refuse a
sort on a field whose author declared `sortable: false`, which they used
to answer sorted by the column; a stored list view `sort` or
`record:related_list` `sort` naming such a field is now refused when it
is read. The widening half: `@objectstack/spec/api` exports
`FIELD_UNSORTABLE_AUTHOR_DECLARED`, and `FieldSortabilitySchema.reason`
accepts `'author-declared'` beside `'virtual-type'`.
Metadata-protocol: narrowed
Skills-docs: none — `skills/**` names no `sortable` field key, and
`content/docs/**` already teaches `sortable: false` as "do not allow
sorting by this field" and `INVALID_SORT` as "the field must exist and
have `sortable: true`", which is what the platform now does. The
prescription ships in the refusal text and the upgrade-guide entry.
## 维护者速读
- 改了什么:字段写了 `sortable: false` 以后,对象元数据里随附的「可排序投影」把它报成不可排序(`reason:
'author-declared'`),列表表头和排序选择器因此不再提供它;数据接口和 stdio MCP 的 `query_records`
对这个字段的排序直接 400 `INVALID_SORT`,和对公式字段排序的拒收是同一个答复。
- 为什么改:以前作者标了不可排序却毫无效果:投影仍报可排序,`GET /api/v1/data/对象?sort=该字段` 照样 200
并按它排序,Studio 还把它当成一个有效开关展示。
- 风险与回滚:这是一次接受集收窄(BREAKING,元数据协议 narrowed,三个包 minor):调用方对 `sortable:
false` 字段的排序从 200 变成 400;已存的列表视图 `sort` 或 `record:related_list` 的 `sort`
若指向这种字段,读取时也会 400,升级指南条目 `field-sortable-false-sort-refused`
说明怎么改。仓内没有任何对象写 `sortable: false`;没写这个键或写 `true`
的字段完全不变;引擎内部调用(钩子、流程)不受影响。回滚就是 revert 本 PR。
- 先看:`packages/metadata-protocol/src/protocol-query-validation.ts` 的
`assertSortFieldsAreOffered`(新判定,按投影拒收)、`packages/spec/src/api/sortability.zod.ts`
的
`resolveObjectSortability`(投影读取该键)、`packages/rest/src/data-sort-field-sortable-flag-door.test.ts`(真实路由钉子)。
- 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run
src/data-sort-field-sortable-flag-door.test.ts`,预期 6
个用例全过(投影报不可排序、`sort=nosort` 400 且未读库、普通字段与 `sortable: true`
字段双向排序照常、公式字段拒收不变)。
- 需要你做的:无,席位落地。
## What changes
- **The projection honours the author**
(`packages/spec/src/api/sortability.zod.ts`). `resolveObjectSortability`
answers `{ sortable: false, reason: 'author-declared' }` for a field
whose `sortable` is the literal `false`, the same entry shape a virtual
type gets (`{ sortable: false, reason: 'virtual-type' }`). A field with
no `sortable` key, or `sortable: true` (the schema default once parsed),
keeps its storage verdict. A `formula` field also marked `sortable:
false` keeps `reason: 'virtual-type'`. `FieldSortabilitySchema.reason`
becomes `z.enum(['virtual-type', 'author-declared'])`, and
`FIELD_UNSORTABLE_AUTHOR_DECLARED` is exported beside
`FIELD_UNSORTABLE_VIRTUAL_TYPE`. The module docblock gains category 6.
- **The data door follows the projection**
(`packages/metadata-protocol/src/protocol-query-validation.ts`).
`assertSortFieldsExist` gains a fourth verdict after `unknown` >
`dotted` > unmaterializable: `assertSortFieldsAreOffered` computes the
same `resolveObjectSortability` projection over the registered object
and refuses any undotted sort name whose entry says `sortable: false`,
through the same `invalidSortError` (`400 INVALID_SORT`, `param`,
`field`, `fields`, `object`) the formula refusal uses. It does not read
the key a second time, so the door and the affordance cannot disagree.
Every caller of `findData` passes this door: the REST list route, `POST
/data/:object/query`, the export route and the RPC dispatcher.
- **The stdio MCP `query_records` refuses it too**
(`packages/mcp/src/stdio-data-bridge.ts`). That bridge hands `orderBy`
straight to `engine.find`, so it never passed the list door. `query()`
now calls `unofferedSortRefusal` after the exposure and stored-metadata
refusals and before the engine: the same `resolveObjectSortability`
projection over the object `describe_object` serves, and the same
envelope (`INVALID_SORT`, `status`/`httpStatus` 400, `field`, `fields`,
`object`, `param: 'orderBy'`). The function is exported within the
package only, like its siblings; `index.ts` does not publish it.
- **The engine door is unchanged, on purpose.**
`assertOrderByIsMaterializable` (`objectql/engine.ts`) answers whether a
driver can order by a column, and a stored column can. In-process
callers (hooks, flows) keep their sort. `sortable: false` governs what
the platform offers callers, not what its storage can do. This is
written down in the new function's docblock and in the projection's
category 6.
- **Upgrade-guide entry**
`packages/spec/src/migrations/entries/semantic/18.field-sortable-false-sort-refused.ts`:
the stored surfaces are a list view's `sort`, a page
`record:related_list` block's `sort` (it reaches the record-page
`related[].sort`:
`ui/packages/app-shell/src/views/recordPagePayload.ts#recordPageRelatedLists`
copies it there) and the related list's `dataSource.sort`; the
replacement is dropping the sort or removing `sortable: false`. No
mechanical rewrite exists, so it is a D3 semantic entry, on the
precedent of `17.engine-find-formula-order-by-refused`.
- **Liveness:** `liveness/field.json` `sortable` stays `live` (the grade
does not move) and gains `evidence`
(`sortability.zod.ts#resolveObjectSortability`,
`protocol-query-validation.ts#assertSortFieldsAreOffered`) and a note
naming the pins. Before, the row said "grid." with no evidence while
nothing read the key.
- **Regenerated:** `api-surface/api.json` and `export-origins/api.json`
(+1 export), `content/docs/references/api/{sortability,protocol}.mdx`
(the docblock and the `reason` enum).
- **Changeset:** `@objectstack/spec`, `@objectstack/metadata-protocol`
and `@objectstack/mcp`, all `minor` (Metadata-protocol `narrowed`),
BREAKING banner, Before/Now/Stored sorts, ADR-0087 `registered
field-sortable-false-sort-refused`.
## Why
The card's reproduction at `589049b0`: an object with `nosort: { type:
'text', sortable: false }` served `sortability.fields.nosort = {
sortable: true }`, and `GET /api/v1/data/OBJ?sort=nosort` answered 200
sorted. Re-measured on this branch's base `eee41490` through the real
routes (the new pin file, run before the fix): the projection pins were
red with `expected { sortable: true } to deeply equal { sortable: false,
…(1) }`, and `sort=nosort` answered 200 with the rows in `xray, yankee,
zulu` order (sorted by the column). The formula control in the same file
was already `400 INVALID_SORT`. So the door's existing answer for a
field the projection calls unsortable is a refusal, and this PR gives
that answer, which narrows the door's accept set.
## Tests
- `packages/rest/src/data-sort-field-sortable-flag-door.test.ts` (new,
6), on the `data-search-field-searchable-flag-door.test.ts` harness:
real `SqlDriver` (SQLite), real `ObjectQL`, real
`ObjectStackProtocolImplementation`, real `RestServer` routes.
- The projection over the registered object: `nosort` `{ sortable:
false, reason: 'author-declared' }`, `name` and `rank` `{ sortable: true
}`, `calc` `virtual-type`.
- The served `GET /api/v1/meta/:type/:name` envelope carries the same
verdict and deep-equals `resolveObjectSortability(item)`. The envelope's
`getMetaItem` answer is the producer's shape over the registered object,
because this harness boots no metadata service.
- The card's request: `sort=nosort`, `sort=-nosort`, `$orderby=nosort
desc` and `sort=name,nosort` are each `400 INVALID_SORT`, naming
`'nosort'`, with zero driver reads.
- Controls: `name` (no `sortable` key) and `rank` (`sortable: true`)
sort both ways; `sort=calc` (formula) is still `400 INVALID_SORT` "a
formula field", with zero driver reads.
- `packages/mcp/src/stdio-data-bridge.sortable-false.test.ts` (new, 3),
on the stand-in engine of
`stdio-data-bridge.internal-field-evaluation.test.ts`: `orderBy` on
`nosort`, alone and behind a sortable field, is `INVALID_SORT` 400 with
`field`, `object` and `param: 'orderBy'`, and the engine is never
called; controls (no `sortable` key, `sortable: true`, a filter on the
field) reach the engine unchanged; `query_records` renders the refusal
as its envelope.
- `packages/spec/src/api/sortability.test.ts` (+6): the author-declared
verdict with a no-key neighbour, the PARSED field (`FieldSchema.parse`:
unset and `true` stay sortable, `false` does not), only the literal
`false` counts (`0`, `null` stay sortable), a formula with `sortable:
false` keeps `virtual-type`, the array field-map shape, and wire
validity under `ObjectSortabilitySchema`.
## Acceptance notes
- **The UI half (read, not run live; corrected by contract review
6624674350).** Under manual sorting, `ObjectGrid.tsx` overrides each
generated column's `sortable` through `isPlatformSortableField`, and the
ListView Sort picker reads the projection. Two gaps remain, and the seat
files a `domain:ui` card for them: `ListView.tsx` seeds `currentSort`
from the view's stored `sort` and sends it to the server unfiltered
(~L3186; `filterPlatformSortableSort` covers only the header and the
emit/persist path), and under client-side sorting the header is still
offered.
- **Authoring gap (reported to the seat, not changed here).**
`packages/lint/src/validate-sortable-fields.ts` judges a list view's
declared `sort` for `unknown`, `dotted` and virtual names, and not for a
field declaring `sortable: false`. A view whose declared sort names such
a field publishes clean, and its sort is then refused at the door, or
dropped by the grid's projection filter. No in-repo object authors
`sortable: false` (`git grep -nE "sortable\s*:\s*false"` outside `ui/`
and tests: zero producers), so nothing in the shipped corpus moves.
- **Declarations (revised after contract review 6624674350).** The door
now refuses a request it answered 200, so this is `(narrowing)`; the new
export and the widened `reason` enum make it `yes`. The first round
declared `Metadata-protocol: none` and `not-required
(no-migration-prescription)`. That was wrong: a stored list view `sort`
(and a `record:related_list` `sort`) naming a `sortable: false` field
loaded sorted before and is refused when read now, so the kind is
`narrowed`, every moved package goes `minor`, and the disposition is
`registered` with the semantic entry above. The first changeset's
sentence "A sort the door now refuses is a request, never a stored
value" is struck.
- **`protocol.ts` untouched.** The door's body lives in
`protocol-query-validation.ts`; the method docblock in `protocol.ts` is
left alone so this PR does not overlap the large-file split of that
file.
## Verification
### Round 2 (head `8738359b`), the contract-review rework
Every reading below is at head `8738359b`, base `eee41490`. Main has
moved 2 commits since, with no overlapping path, and the local
`merge-tree` is clean. Round 2 touches only the changeset, the upgrade
entry, the stdio bridge with its pin, one `.describe()` sentence and its
regenerated page, and the liveness note (`git diff --stat
bc7630e..HEAD`: 7 files, +225 / −18). `protocol-query-validation.ts`
and the rest pin are unchanged from round 1.
- **The stdio pin, before and after.** The pin is
`packages/mcp/src/stdio-data-bridge.sortable-false.test.ts`. Without the
guard it reads `Tests 2 failed | 1 passed (3)`: the bridge resolved with
the engine called, and `query_records` answered a result instead of
`isError`. With the guard it reads `Tests 3 passed (3)`.
- **Ablation, once.** I made the throw unreachable (`if (sortRefusal &&
'ABLATED_29896_MCP'.length < 0) throw sortRefusal`). The two refusal
tests went red and the control stayed green. The file was then restored,
the blob matches HEAD, and the pin is green again.
- `pnpm --filter @objectstack/mcp test`: Test Files 56 passed, Tests 663
passed. `typecheck` (tsc + test-typecheck) exits 0 after `8738359b`
typed the stand-in `find`. The first run reported one TS2493 in the new
file, and no debt ledger entry was added.
- `pnpm --filter @objectstack/spec test`: Test Files 811 passed, Tests
24376 passed. This includes the semantic-migration registry suites, with
the new entry `field-sortable-false-sort-refused`.
- The rest pin `data-sort-field-sortable-flag-door.test.ts`: 6 passed
(unchanged). eslint on the changed files: exit 0. `check:generated
--fix` regenerated only `content/docs/references/api/sortability.mdx`,
which is committed in `f320e8de`.
- `check-changeset-no-major --base origin/main`, with this body as the
event: exit 0. It reads `metadata-protocol narrowed, and no package
whose published source it moves is graded patch`.
`check-adr-0087-registration --base origin/main`: exit 0, `registered
field-sortable-false-sort-refused (new here)`.
- `pnpm --filter @objectstack/dogfood test` at `8738359b`, under the
verify lock, after `turbo build --filter=@objectstack/mcp...
--filter=@objectstack/dogfood^...` (69/69): Test Files 416 passed | 3
skipped (419), Tests 3456 passed | 28 skipped (3484), with no failures.
- `node scripts/pm/dispatch-gates.mjs --commands` derives 107 commands
at `8738359b`: round 1's 105, plus `check:spec-changes` and
`check:upgrade-guide`. All 107 exit 0. The `--ran` reconciliation reads
"107 derived famil(ies) accounted for — 107 run, 0 NOT-MEASURED".
### Round 1 (head `bc7630ee`)
Every reading below is at head `bc7630ee`, base `eee41490` (main has
moved one unrelated commit since, `9d1a465f0`; no overlapping path,
local `merge-tree` clean).
<details><summary>The card's reproduction, before and after (the new pin
file)</summary>
- Before the fix (base `eee41490`, same harness): `Tests 3 failed | 3
passed (6)`. The projection pins failed with `expected { sortable: true
} to deeply equal { sortable: false, …(1) }`, and `{"sort":"nosort"}`
answered 200 with `records` in `xray, yankee, zulu` order (sorted by
`nosort`): `expected 200 to be 400`. The three controls passed,
including `sort=calc` (formula) already `400 INVALID_SORT`.
- After: `Tests 6 passed (6)`.
</details>
<details><summary>Ablation (one, of the projection change)</summary>
Mutation: `| undefined)?.sortable === false` → `| undefined)?.sortable
=== 'ABLATED_29896'` in `sortability.zod.ts`, via
`scripts/ablation-replace.mjs` (anchor 1 → 0, blob `a15d6b6a4b9a` →
`2e0f03fd3415`). Then `pnpm --filter @objectstack/spec build`, and
`ablation-dist-preflight.mjs @objectstack/spec ABLATED_29896` exited 0
(marker in `dist/api/index.js` and `index.mjs`).
- Red: the rest pin file gave 3 failed / 3 passed. The projection pin,
the served-envelope pin and the card's `sort=nosort` refusal went red,
which shows the door judges by the projection. The three controls stayed
green. The spec `sortability.test.ts` gave 3 failed / 15 passed (the
author-declared pins).
- Restore: blob back to `a15d6b6a4b9a` (== HEAD), `git diff HEAD` empty,
`git status --porcelain` clean, spec rebuilt, `--absent` preflight exit
0 over 592 built files.
</details>
<details><summary>Package tests and typecheck</summary>
- `pnpm --filter @objectstack/spec test`: Test Files 811 passed, Tests
24376 passed | 1 todo; `typecheck` exit 0.
- `pnpm --filter @objectstack/metadata-protocol test`: Test Files 312
passed | 4 skipped, Tests 22346 passed | 26 skipped; `typecheck` exit 0.
- `pnpm --filter @objectstack/rest test`: Test Files 385 passed, Tests
7184 passed | 332 skipped; `typecheck` (tsc + test-typecheck) exit 0
after `bc7630ee` typed the request list (the first run reported one
TS2345 in the new file; no debt ledger entry was added).
- `eslint --no-inline-config` over the four changed source/test files:
exit 0.
- `pnpm lint` (repo-wide): exit 0 at `bc7630ee`.
</details>
<details><summary>Full build and dogfood (narrowing arm)</summary>
- `turbo run build --concurrency=2 --filter=!@objectstack/docs`: 119/119
tasks successful.
- `pnpm --filter @objectstack/dogfood test` at `bc7630ee`, under the
verify lock: `VERDICT command-exit 0`, Test Files 416 passed | 3 skipped
(419), Tests 3456 passed | 28 skipped (3484), no failures.
</details>
<details><summary>Derived gates (dispatch-gates --commands)</summary>
`node scripts/pm/dispatch-gates.mjs --commands` derives 105 commands
(the same list at `61e984ef` and at `bc7630ee`). All 105 ran after the
full build. `--ran` reconciliation: "105 derived famil(ies) accounted
for — 105 run, 0 NOT-MEASURED".
- One command exited 3 for an environment reason and reran green:
`check-plugin-teardown-shape --self-test` could not read its pinned
fixture commit on the shallow clone; after `git fetch origin 621a487…`
it passed (48 cases).
- `check-changeset-no-major` with this body as the event: exit 0,
"declares clause-② `yes (narrowing)` and metadata-protocol `none` …
introduces no changeset entry graded `minor` or above and moves no
metadata-protocol baseline".
- `check-adr-0087-registration` with this body as the event: exit 0,
`[BREAKING+bang+clause-②-narrowing] not-required
(no-migration-prescription)`.
- `pnpm --filter @objectstack/spec check:generated --fix` regenerated
exactly the three it proved stale (`api-surface/`, `export-origins/`,
`content/docs/references/**`). `check:authorable-surface` and
`check:liveness` are green.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WGufzhnsFtaPy7QwK43MYt)_
<!-- signed: domain:server#6 · os-support-ai ·
session_01WGufzhnsFtaPy7QwK43MYt -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent e8afd1e commit 140d8ce
13 files changed
Lines changed: 577 additions & 25 deletions
File tree
- .changeset
- content/docs/references/api
- packages
- mcp/src
- metadata-protocol/src
- rest/src
- spec
- api-surface
- export-origins
- liveness
- src
- api
- migrations/entries/semantic
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1471 | 1471 | | |
1472 | 1472 | | |
1473 | 1473 | | |
1474 | | - | |
| 1474 | + | |
1475 | 1475 | | |
1476 | 1476 | | |
1477 | 1477 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
35 | 35 | | |
36 | 36 | | |
37 | 37 | | |
38 | | - | |
39 | | - | |
| 38 | + | |
| 39 | + | |
40 | 40 | | |
41 | 41 | | |
42 | 42 | | |
| |||
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
54 | | - | |
55 | | - | |
56 | | - | |
57 | | - | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
58 | 58 | | |
59 | 59 | | |
60 | 60 | | |
| |||
74 | 74 | | |
75 | 75 | | |
76 | 76 | | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
77 | 94 | | |
78 | 95 | | |
79 | 96 | | |
| |||
132 | 149 | | |
133 | 150 | | |
134 | 151 | | |
135 | | - | |
| 152 | + | |
136 | 153 | | |
137 | 154 | | |
138 | 155 | | |
| |||
144 | 161 | | |
145 | 162 | | |
146 | 163 | | |
147 | | - | |
| 164 | + | |
148 | 165 | | |
149 | 166 | | |
150 | 167 | | |
151 | 168 | | |
152 | 169 | | |
153 | 170 | | |
154 | | - | |
| 171 | + | |
155 | 172 | | |
156 | 173 | | |
157 | 174 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
0 commit comments