Repository navigation
Commit 3a27208
fix(lint)!: refuse a stored sort on a
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBGAgbDVW8xmM2xwwkx5q3JU)_
Fixes #30023
## 维护者速读
- 改了什么:`validate-sortable-fields` 不再自己判断「虚拟类型」,改为读数据接口列表门所用的同一个可排序投影
`resolveObjectSortability`,所以字段写了 `sortable: false` 时,列表视图的 `sort` 报
`sort-field-unsortable`(error);同时把规则扩展到页面上所有会打到列表门的已存排序(区块自身 `sort`、元素
`dataSource.sort`、相关列表 `parentDataSource.sort`、列表页
`interfaceConfig.sort`、应用页 `record.sort`),并让 page 写入门也跑这条规则。
- 为什么改:#30021 之后列表门对这种排序答 400 `INVALID_SORT`,但 `os validate`
和发布门照样放行,于是视图首次加载起每次都 400;页面上的排序此前完全不判可排序性(公式字段也一样)。
- 风险与回滚:接受集收窄(BREAKING,Metadata-protocol narrowed,`@objectstack/lint` 和
`@objectstack/spec` minor):带这种排序的视图或页面在下次 validate /
保存时被拒(422),已存的照常加载。仓内语料没有任何 `sortable: false`,示例栈实测 0 条新发现。回滚即 revert 本
PR。
- 先看:`packages/lint/src/validate-sortable-fields.ts` 的
`unsortableFinding`、`validatePageSorts`、`PAGE_BLOCK_SORT_OBJECT_PROP`;`reference-integrity-suite.ts`
的 `page` 跨入。
- 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run
src/protocol.runtime-authoring-gate.test.ts -t 30023`(先建 lint),预期 7
个用例全过:视图和 4 个页面槽位被拒在各自路径,对照(无键、`true`、公式字段保留自己的判定)照常。
- 需要你做的:无,席位落地。
Clause-②: yes (narrowing) — `sort-field-unsortable` (error) now also
fires on a sort naming a field whose author declared `sortable: false`,
on the list-view rungs it already walked and on every stored page sort
it did not walk before (a block's own `sort`, every element
`dataSource.sort`, a related list's `parentDataSource.sort`, a list
page's `interfaceConfig.sort`, an app page's `record.sort`, where a
`formula` sort is now refused too), so `os validate` / `os build` / `os
lint` and the `page` and `view` publish doors refuse metadata they used
to accept. The widening half: `@objectstack/lint` exports
`indexObjectSortability` and the `SortabilityFields` type, and
`checkSortDeclaration` takes an optional trailing `sortability` index.
Metadata-protocol: narrowed
Skills-docs: none — `skills/**` names no `sortable` field key and no
page or view `sort` rule; `content/docs/**` already teaches `sortable:
false` as "do not allow sorting by this field". The prescription ships
in the finding's fix line, `os explain sort-field-unsortable` and the
upgrade-guide entry.
## What changes
- **One projection, two reasons**
(`packages/lint/src/validate-sortable-fields.ts`). The virtuality check
no longer calls `isVirtualSearchField` itself. It reads the projection
`resolveObjectSortability` (`@objectstack/spec/api`) builds from the
authored object, the resolver the list door (`assertSortFieldsExist`)
refuses by. A `sortable: false` entry with `reason: 'virtual-type'`
keeps the existing message byte for byte. One with `reason:
'author-declared'` reports `sort-field-unsortable` at `error` with the
door's clause: `list-view sort orders by "body", a field on object
"help_ticket" that declares `sortable: false`, so every load answers 400
INVALID_SORT`. The fix line is `Sort by another field, or remove
`sortable: false` from "body" to make it sortable`. Only the literal
`false` counts. A formula field that also declares `sortable: false`
keeps its virtual verdict, because the projection does.
- **Grading.** `error`, the level the #9257 precedent (`formula`)
publishes at. The door answers both reasons with the same `400
INVALID_SORT` on the view's first fetch.
- **The exported core stays compatible.** `checkSortDeclaration` takes
an optional trailing `sortability` index (`indexObjectSortability`,
exported). If a caller omits it, the projection comes from the type-only
slice, so the caller gets the pre-change answers (virtual only). This is
the posture the `unprovisionedAnchors` parameter already has. Every
in-repo caller passes it.
- **Every stored page sort is walked** (`validatePageSorts`), for the
projection's verdict only:
- each component's `dataSource.sort`, bound to `dataSource.object`;
- a `record:related_list`'s `properties.parentDataSource.sort`, bound to
its own `object`;
- the `properties.sort` of each block that declares one, bound to
`dataSource.object` and then the block's object prop. The
`PAGE_BLOCK_SORT_OBJECT_PROP` table is pinned against
`ComponentPropsMap`:
| Block | Object prop |
| --- | --- |
| `list-view` | `data.object` |
| `record:related_list` | `objectName` |
| `record:line_items` | `childObject` |
| `object-grid`, `object-calendar`, `object-gantt`, `object-map`,
`object-timeline` | `objectName` |
- a list page's `interfaceConfig.sort`, bound to `source` and then the
page `object`;
- an app page's `record.sort`, bound to `record.object`.
`validate-page-field-bindings` still owns existence and provenance on
these positions, as `page-field-unknown` / `page-field-unprovisioned`.
This rule adds only the question that rule does not ask, so no name is
reported twice.
- **The page door** (`reference-integrity-suite.ts`,
`authoring-rules.ts`). `validateSortableFields` declares `page`, and the
suite entry's `runtimeTypes` gains `page`. A page write reaches exactly
this one member: no other member declares `page`. The member resolves
only against `stack.objects`, which the per-write page snapshot carries.
That is the #9313 reason, one write type over. The page dispatch pin
(`runtime-gate.page-visibility-predicate-writes.test.ts`) lists
`validateReferenceIntegrity`.
- **`os explain sort-field-unsortable`** (`rule-explanations.ts`) gains
the author-declared paragraph and the page surfaces.
- **Upgrade-guide entry** `18.field-sortable-false-sort-refused`:
`surface` and `acceptanceCriteria` now list every stored surface above
(delta review 6625831334 ③). The `reason` sentence "Lint does not yet
judge a view `sort`…" is replaced. It now says that `os validate` and
the publish door refuse each surface.
- **Changeset:** `@objectstack/lint` and `@objectstack/spec` are both
`minor`, with `Metadata-protocol: narrowed`, the BREAKING banner, and
Before / Now / Not changed / The remedy. The ADR-0087 marker is
`not-required (already-registered field-sortable-false-sort-refused)`,
which cites the existing entry. A **Supersedes, in the same release**
paragraph corrects #29896's unreleased note "Lint does not yet judge
these at authoring." Before writing it I checked that npm
`@objectstack/spec@18.3.0` (published 2026-11-03 15:15Z) does not carry
`field-sortable-false-sort-refused`.
## Why
#30021 (`140d8ce8`) makes the list door refuse a sort that names a
`sortable: false` field. The lint rule keyed only on
`isVirtualSearchField`, so `os validate` and the view door both passed
such a view. On a page, none of the stored sorts was judged for
sortability at all. The rule walked only the five list-view rungs, and
its module note left page sorts to `walkPageComponents`' territory,
where only existence was asked. The view then broke on every load,
traced to nothing.
## Readings
- **Surfaces before this PR:**
- The rule walked `objects[].listViews.*.sort`, `views[].list.sort`,
`views[].listViews.*.sort`, the flattened overlay `views[].sort` and the
record `views[].config.sort`, all for the virtual verdict only.
- It walked no page surface.
- The suite did not dispatch on `page` writes.
`runtimeAuthoringRulesFor('page')` listed no
`validateReferenceIntegrity`.
- **Surfaces the delta review missed, found by enumerating every `sort:`
key under `packages/spec/src/**/*.zod.ts` and by the new drift pin:**
- `record:line_items` `sort`;
- the page `list-view` node's own `sort` (`ListViewPageProps` spreads
`ListViewSchema`);
- the related list's `parentDataSource.sort`.
The drift pin first went red naming `list-view`, which the hand list had
missed.
- **App page `record.sort` reaches the door.**
`ui/packages/app-shell/src/views/PageView.tsx#appPageRecordBinding`
mounts it through `ElementDataSourceGate` with `sort: true`
(objectui#12931). Its `liveness/page.json` row still says `planned`,
which is stale. That is noted below and not changed here.
- **Corpus.** No shipped object declares `sortable: false`: `grep -rn
"sortable: false"` over `examples/`, `packages/platform-objects/src` and
`packages/plugins` finds nothing. `validateSortableFields` over
`examples/app-crm` (6 objects, 1 page), `app-todo` and
`app-multi-package` gives 0 findings. `examples/app-showcase` (24
objects, 29 pages): 0 findings. Non-vacuity probe: with every authored
field set to `sortable: false` in memory, the same run fires 3 findings,
2 of them on pages (`pages[14].interfaceConfig.sort[0]`,
`pages[17].interfaceConfig.sort[0]`), so the page walk reaches the
shipped corpus.
## Tests
- `packages/lint/src/validate-sortable-fields.test.ts` (+16):
- **The author-declared verdict:** exact message and fix, path, and
`error` level. It fires on the legacy string form, on `-field`, and on
the overlay and record view shapes.
- **Controls:** no key, `true`, and a formula field with `sortable:
false`, which keeps the virtual message.
- **Projection identity:** the rule's index deep-equals
`resolveObjectSortability(object).fields`.
- **The exported core's optional parameter:** absent, it answers only
the virtual verdict; present, it answers both.
- **Every page slot is refused at its path:** related list `sort`, its
`dataSource.sort` and `parentDataSource.sort`, `object-grid`,
`record:line_items`, `list-view`, an element `dataSource.sort`,
`interfaceConfig.sort` and `record.sort`.
- **Binding precedence:** `dataSource.object` wins over the block prop.
A list page with no `source` binds to the page `object`.
- **A formula sort on a page is refused.**
- **Page controls:** no key, `true`, an unknown name, a dotted path,
`created_at`, another package's object and a block with no `sort` prop
are all not reported. Neither is a `kind: 'react'` page's cached
regions.
- **`PAGE_BLOCK_SORT_OBJECT_PROP` ↔ `ComponentPropsMap`:** every row
with a live `sort` key, each with a live object prop. The tombstone
detector is pinned non-vacuous on `element:record_picker.sort`.
- **The real publish route**
(`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts`,
a new `describe` on the existing stub-engine harness, so no double
ledger moves; +7):
- `PUT` view with `sort: body` gives 422 `INVALID_METADATA` with
`sort-field-unsortable` at `views[0].sort[0]`, carrying the door's
clause, and stores nothing.
- A page sorting by `body` at a `record:related_list` `sort`, an element
`dataSource.sort`, a list page's `interfaceConfig.sort` and an app
page's `record.sort` gives 422 at `pages[0]…` slot paths, and stores
nothing.
- Controls: `subject` (no key) and `priority` (`sortable: true`) save on
the view and on all four page slots. `score` (formula) is refused with
its own virtual message on the view and on a page.
## Verification
Readings are at `90989939` on base `c23501b2`, unless marked otherwise.
Head `c745679c` adds only the `index.ts` export of
`indexObjectSortability` / `SortabilityFields` and the matching
changeset clause. For that head:
- lint `tsc`, `check:test-typecheck` and the four touched lint test
files (90 tests, including `rule-id-barrel-exports`) were rerun.
- `check:dts-closure`, `check:lean-entry-closure`,
`check:published-files`, `check:undeclared-dep-imports`,
`check:api-surface`, `check-changeset-no-major`, `check-empty-changeset`
and `check-adr-0087-registration` were rerun, all exit 0.
- The `--commands` derivation is unchanged: the same 82.
- **Builds.** A full `turbo run build --filter='!@objectstack/docs'
--concurrency=2` ran under the verify lock: 119 tasks, 0 failed, 11m16s
on a shared box.
- **Package suites.**
- `@objectstack/lint` `test`: 193 files, 7988 tests passed. `typecheck`:
0.
- `@objectstack/spec` `test`: 811 files, 24387 tests passed.
`typecheck`: 0.
- `@objectstack/metadata-protocol` `test`: 312 files, 22354 tests
passed, 26 skipped. `typecheck`: 0.
- `pnpm lint` (repo eslint): 0.
- **Derived gates.** `dispatch-gates --commands` derived 82, and all 82
ran with exit 0. `dispatch-gates --ran` reports 82 run, 0 NOT-MEASURED,
0 UNRUN; the record is `/mnt/project-files/issue-30023/gates-ran.list`.
- `check-plugin-teardown-shape --self-test` first exited 3 because this
shallow clone could not reach its pinned fixture. After `git fetch
--unshallow origin main` it exited 0, and 48 cases passed.
- `check-adr-0087-registration --base origin/main`: `not-required
(already-registered)` accepted.
- `check-changeset-no-major` with this body as the event: LEVEL AXIS
`yes (narrowing)` + `narrowed`, and no moved package is graded `patch`.
- `test:repo` was not run locally. It belongs to CI (Test Core).
- **Ablation A**, at the author-declared verdict. I changed
`unsortableFinding`'s guard to also return for `reason ===
'author-declared'`, rebuilt lint and reran both files:
- `validate-sortable-fields.test.ts`: `Tests 7 failed | 57 passed (64)`.
The 7 are the author-declared and page-slot cases. Every control, and
the formula page case, stayed green.
- The route pins: `Tests 5 failed | 2 passed`. The view and the 4 page
slots went red; the 2 controls stayed green.
- Restored: the blob equals HEAD.
- **Ablation B**, at the page-door crossing. I dropped `'page'` from
`validateSortableFields`' member `runtimeTypes`:
- The lint unit file stayed green: `64 passed`.
- The route pins went red, `Tests 5 failed | 2 passed`: the 4 page slots
and the virtual page control. The view refusal and the save control
stayed green.
- Restored: the blob equals HEAD.
## Acceptance notes
- **Not walked, by design:** a `kind: 'react'` page's JSX props
(`validate-react-page-props`' territory, unchanged), dashboard widget
`sortBy` and report `order[].by` (dataset names, not object fields; as
before).
- **Existence on page sorts stays advisory.** An unknown name in a
related list's `sort` reaches the same door and answers 400, yet
`page-field-unknown` reports it at `warning`. That is a pre-existing
severity call in `validate-page-field-bindings`, and this PR leaves it
unchanged. Reported to the seat; not filed here.
- **Object door not crossed.** An object write carrying both `sortable:
false` on a field and a built-in `listViews.*.sort` on it is judged by
`os validate`, and by the view door for view items, but not by the
object publish door. `validateSortableFields` does not declare `object`,
and `reference-integrity-suite.ts` records that crossing as its own
adjudication (#15254's note). Reported to the seat.
- **Stale liveness row.** `liveness/page.json` `record` still says
`planned`, while `ui/…/PageView.tsx` reads the binding (objectui#12931).
Not changed here.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzgUAaThh7rNSXWuzXR
<!-- signed: domain:server#6 · os-support-ai ·
session_01WGufzgUAaThh7rNSXWuzXR -->
---------
Co-authored-by: Claude <noreply@anthropic.com>sortable: false field, on list views and every stored page sort (#30084)1 parent 0ce44c9 commit 3a27208
10 files changed
Lines changed: 710 additions & 44 deletions
File tree
- .changeset
- packages
- lint/src
- metadata-protocol/src
- spec/src/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 | |
|---|---|---|---|
| |||
1249 | 1249 | | |
1250 | 1250 | | |
1251 | 1251 | | |
| 1252 | + | |
| 1253 | + | |
| 1254 | + | |
| 1255 | + | |
| 1256 | + | |
1252 | 1257 | | |
1253 | | - | |
| 1258 | + | |
1254 | 1259 | | |
1255 | 1260 | | |
1256 | 1261 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
521 | 521 | | |
522 | 522 | | |
523 | 523 | | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
524 | 527 | | |
525 | 528 | | |
526 | 529 | | |
| 530 | + | |
527 | 531 | | |
528 | 532 | | |
529 | 533 | | |
530 | 534 | | |
531 | 535 | | |
532 | 536 | | |
533 | 537 | | |
| 538 | + | |
534 | 539 | | |
535 | 540 | | |
536 | 541 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
282 | 282 | | |
283 | 283 | | |
284 | 284 | | |
285 | | - | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
286 | 295 | | |
287 | 296 | | |
288 | 297 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2479 | 2479 | | |
2480 | 2480 | | |
2481 | 2481 | | |
2482 | | - | |
| 2482 | + | |
2483 | 2483 | | |
2484 | 2484 | | |
2485 | 2485 | | |
2486 | 2486 | | |
2487 | 2487 | | |
2488 | 2488 | | |
2489 | 2489 | | |
| 2490 | + | |
| 2491 | + | |
| 2492 | + | |
| 2493 | + | |
| 2494 | + | |
| 2495 | + | |
| 2496 | + | |
2490 | 2497 | | |
2491 | 2498 | | |
2492 | 2499 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
138 | 138 | | |
139 | 139 | | |
140 | 140 | | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
141 | 144 | | |
142 | 145 | | |
143 | 146 | | |
| |||
0 commit comments