Skip to content

Commit c045c34

Browse files
fix(lint)!: refuse a stored page sort naming an unknown field, on every page sort slot (#30093) (#30270)
domain:server#3 · os-bill · session_01Utp6CYvKbGaAgjBLQdkwPB <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgRSN1GiP9UgXGt4JWsZMg3B)_ Fixes #30093 Clause-②: no (narrowing) — an unknown sort field on a page slot moves from silent or warning to error: `os validate` / `os build` / `os lint` and the `page` publish door refuse a page sort they accepted before. Metadata-protocol: narrowed Skills-docs: updated here — content/docs/deployment/validating-metadata.mdx ## 维护者速读 - **改了什么:** 页面上所有存储的排序(元素 `dataSource.sort`、关联列表的 `sort` 与 `parentDataSource.sort`、各列表类区块自带的 `sort`、列表页 `interfaceConfig.sort`、应用页 `record.sort`)写了对象没有的字段时,`os validate` 和页面发布门一律报 `page-field-unknown` 错误,指到具体槽位路径,并给出最接近的字段名。 - **为什么改:** 列表门对未知排序字段每次读取都答 `400 INVALID_SORT`。之前只有两个槽位在命令行给警告,其余槽位没人检查,页面照样发布成功,第一次打开就坏。 - **风险与回滚:** 这是收窄:以前能保存的这类页面,下次保存会被 422 拒绝;已存的页面照常加载,不受影响。点状路径、`created_at` 这类系统列、本栈没有的对象、没有字段表的对象都不判。回滚 = revert 本 PR。 - **先看:** `packages/lint/src/validate-sortable-fields.ts` 的 `checkPageSortDeclaration`(三问的顺序与跳过条件);`packages/lint/src/validate-page-field-bindings.ts` 的 `isPageSortRef`(这里不再读页面排序,避免一处报两次);`packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts` 末尾的 #30093 用例。 - **两分钟验证:** 运行 `pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.runtime-authoring-gate.test.ts -t 30093`,预期 6 个全绿:5 个槽位各自被 422 `INVALID_METADATA` 拒绝、`page-field-unknown` 指到槽位路径、没有存下任何行;对照组用真实字段全部保存成功。 - **需要你做的:** 无,席位落地。 ## What changes - **`validateSortableFields` asks every stored page sort slot three questions, in the list door's order** (`checkPageSortDeclaration`): existence (`page-field-unknown`, `error`), provenance (`page-field-unprovisioned`, `warning`, a registry-injected anchor on an ADR-0015 `external` object) and sortability (`sort-field-unsortable`, `error`, unchanged from #30084). The slots and their bindings are the ones #30084 already walks, each bound to the object its renderer reads: `dataSource.sort` → `dataSource.object`; a block's own `sort` → `dataSource.object ?? BLOCK_OBJECT_PROP` (`PAGE_BLOCK_SORT_OBJECT_PROP`); `parentDataSource.sort` → `parentDataSource.object`; `interfaceConfig.sort` → `interfaceConfig.source ?? page.object`; `record.sort` → `record.object`. Keys are read by `readSortKeys`, so the legacy string form is judged by its head, and the `-field` shorthand and comma-separated keys are each judged. - **The grade is `error` on every slot.** The list door (`assertSortFieldsExist`) answers an unknown sort name `400 INVALID_SORT` on the first read and every read after it, which is the list view's `sort-field-unknown` grade. The id stays the page family's (`page-field-unknown`), so a page author reads the page rule. - **Why this member.** `validateSortableFields` is the one reference-integrity member that declares `page` in `runtimeTypes` (#30084), so the page publish door (`PUT /api/v1/meta/page/NAME`; Studio, REST, MCP) now refuses the save `422 INVALID_METADATA` instead of storing a page that breaks on its first read. `validatePageFieldBindings` runs on the CLI only. - **`validatePageFieldBindings` no longer reads a page sort** (`isPageSortRef` sets aside a related list's `sort` refs; `interfaceConfig.sort` is no longer collected), so each slot has exactly one reader and one finding. `relatedListFieldRefs` and `sortFieldRefs` keep their exported behaviour for out-of-repo callers and the react surface. - **Skips, the `page-field-unknown` family's own:** a dotted relationship path (the door refuses it as its own verdict), a registry-injected system column (an author-declared field of a system name falls through to the projection, keeping #30084's verdict on it), an object the stack or the publish snapshot does not carry, and an object with no field map. ## Tests - `validate-sortable-fields.test.ts`, new `describe` (#30093): every page slot refused as `page-field-unknown` at `error` at its slot path (rule + path + severity asserted for all nine), message and fix line pinned once; `validatePageFieldBindings` reports none of those slots while still reading a related list's columns (non-vacuity); each slot bound to its renderer's object; legacy string head, `-field` and comma keys; the skips; provenance on the external object at `warning` and silence on the local twin; an author-declared system-named field keeps `sort-field-unsortable`. The #30023 control no longer lists an unknown name as "not this rule's", and the one-line coverage pin now expects the two page ids beside the three sort ids. - `validate-page-field-bindings.test.ts`: the related-list case drops its sort expectation; the #4340 legacy-string case moves to the sortable suite and is replaced by a pin that this walk reads no `interfaceConfig.sort` in either form while still reading the columns. - `protocol.runtime-authoring-gate.test.ts`, new top-level `describe` (#30093): five slots (element `dataSource.sort`, `parentDataSource.sort`, a related list's `sort`, `interfaceConfig.sort`, an app page's `record.sort`) each refused `422` / `INVALID_METADATA` with one `page-field-unknown` issue at `error` at the slot path, nothing stored; control: the same pages on a declared field save. ## Acceptance notes - The two code notes on the page crossing (`packages/lint/src/reference-integrity-suite.ts`, the `validateSortableFields` member note; `packages/lint/src/authoring-rules.ts`, the `page` crossing note) said it judges a page sort "by the sortability projection alone". Corrected in review round 1 (comments only). - A `kind: 'react'` page's sort props stay with `validate-react-page-props` (`sortFieldRefs`, the `skipped` grade). They are JSX props, not stored page sort slots, and are outside this card. ## Review round 1: what changed Head `df135352` → `647a1c1f`, one commit, after the contract review (FAIL, comment 6649790600): one must-fix and one should-fix, both taken. - **M1.** `content/docs/deployment/validating-metadata.mdx` §5 said a `record:related_list`'s `columns`/`sort`/`filter` resolve through the page-binding rule and are advisory. It now names `columns`/`filter` only, and a new paragraph says a page sort is the exception: every stored page sort slot naming a field the bound object lacks is a `page-field-unknown` **error**, and the page publish door refuses the save `422 INVALID_METADATA`, because the list door answers it `400 INVALID_SORT` on every load. The `Skills-docs:` line in this body and the changeset now reads `updated here — content/docs/deployment/validating-metadata.mdx`. - **S1.** The two stale code notes (`reference-integrity-suite.ts` near line 289, `authoring-rules.ts` near lines 1253–1256) now say the page crossing also answers existence (`page-field-unknown`, `error`) and provenance (`page-field-unprovisioned`, `warning`). Comments only. **Round 1 verification, head `647a1c1f`.** `node scripts/pm/dispatch-gates.mjs --commands` now derives 87 families (the docs edit adds 29, the docs-audit and spec doc families among them); all 87 exit 0, `--ran`: 87 derived, 87 run, 0 NOT-MEASURED. `pnpm lint`: exit 0. `@objectstack/lint` rebuilt after the comment edits. The source change since round 0 is comments and one docs page, so the round-0 test, dogfood and reverse-verification readings below still describe the code. <details><summary>Round 1 gate record (87 derived + pnpm lint)</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm lint :: exit 0 ``` </details> ## Verification (round 0, head `df135352`) All readings on head `df135352` (this branch with `origin/main` at `6f095b9c` merged), after a full `turbo run build --filter='!@objectstack/docs'` under the verify lock (exit 0). - `pnpm --filter @objectstack/lint test`: Test Files 193 passed (193), Tests 8001 passed (8001). `pnpm --filter @objectstack/lint run typecheck`: exit 0. - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 312 passed, 4 skipped (316); Tests 22360 passed, 26 skipped. `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. - `pnpm lint`: exit 0. - `node scripts/pm/dispatch-gates.mjs --ran`: 58 derived families, 58 run, 0 NOT-MEASURED, every exit code 0. - Dogfood (`pnpm --filter @objectstack/dogfood test`, the title carries `!`): Test Files 1 failed, 418 passed, 3 skipped (422); Tests 1 failed, 3487 passed, 36 skipped. The one failure is `test/permission-set-clone-door.dogfood.test.ts` › "a platform set … copies into its shipping package's pair" (`422`: permission `my_member_baseline` grants on `sys_comment`, "not defined in objects"). It is not this diff: `main` at `6f095b9c` is red on the same test (Dogfood Regression Gate (2/3), job 127024968149), and this diff touches no permission, object or clone path. - **Reverse verification.** With the two lint sources put back to `origin/main` (`git restore --source=origin/main`), the two lint suites go 8 failed / 122 passed: every new #30093 case and the two moved page-field-bindings cases, plus the one-line coverage pin. With lint rebuilt from those sources, the door suite goes 5 failed / 1 passed: the five refusals red (the page saved), the control green. Both restored with `git checkout HEAD --`, `git status` clean. <details><summary>Gate record (58 derived + pnpm lint)</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm lint :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYvKbGaAgjBLQdkwPB)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CYvKbGaAgjBLQdkwPB --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1fd0c32 commit c045c34

9 files changed

Lines changed: 417 additions & 48 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
fix(lint)!: a stored page sort naming a field its object lacks is refused by `os validate` and at the page publish door (`page-field-unknown`, error), on every page sort slot (#30093)
6+
7+
Clause-②: no (narrowing) — an unknown sort field on a page slot moves from silent or warning to error: `os validate` / `os build` / `os lint` and the `page` publish door refuse a page sort they accepted before.
8+
Metadata-protocol: narrowed
9+
Skills-docs: updated here — content/docs/deployment/validating-metadata.mdx
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: every page sort slot keeps its shape and meaning, and the build and the page publish door now give an unknown field there the verdict a list view sort already gets. A refused name never sorted anything (the list door answered every read 400 INVALID_SORT), so there is no rewrite that keeps what the author meant; the author names a real field, and the refusal says which. -->
12+
13+
**BREAKING** accept-set narrowing at the page publish door (`PUT /api/v1/meta/page/NAME`; Studio, REST and MCP authors) and in `os validate` / `os build` / `os lint`.
14+
15+
- **Before.** A page sort naming a field the object does not have saved green, and its first read, and every read after it, answered `400 INVALID_SORT` at the list door. A list page's `interfaceConfig.sort` and a `record:related_list` block's `sort` drew a `page-field-unknown` warning on the CLI alone; an element `dataSource.sort`, a related list's `parentDataSource.sort`, an app page's `record.sort` and the own `sort` of a `record:line_items`, `list-view` or `object-*` block were read by nothing.
16+
- **Now.** Every one of those slots is judged by `validateSortableFields`, the reference-integrity member the page publish door already runs, and an unknown name is `page-field-unknown` at `error`, at the slot's path, bound to the object the slot's renderer reads (`dataSource.object`, else the block's object prop; `interfaceConfig.source`, else the page's `object`; `parentDataSource.object`; `record.object`). The page door refuses it `422 INVALID_METADATA`. A registry-injected column on an `external` object is `page-field-unprovisioned` at `warning`, as before. `validatePageFieldBindings` no longer reads a page sort, so each slot has one reader.
17+
- **Not judged.** A dotted relationship path (the list door refuses it as its own verdict), a system column such as `created_at`, an object the stack (or the publish snapshot) does not carry, and an object with no field map.
18+
- **Not changed.** A stored page already carrying such a sort keeps loading; only its next save is judged, and its read answers 400 as it did before.
19+
20+
**What an author does.** Name a field the object declares, or add the field to the object. A refused name never sorted the page, so there is nothing to keep.

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,20 @@ component silently skips it and the page renders one item short.
163163

164164
Which object a component binds follows `dataSource.object` → `properties.object`
165165
→ the page's `object`, so a multi-object page is checked per element rather than
166-
against one page-wide guess. A `record:related_list`'s `columns`/`sort`/`filter`
166+
against one page-wide guess. A `record:related_list`'s `columns`/`filter`
167167
resolve against its **related** object (`objectName`), and its add-picker against
168168
its own. Advisory, like form-layout field references — every consumer degrades
169169
rather than failing. The same descriptor table drives the react page surface
170170
(§10), so a component is described once and checked wherever it is authored.
171171

172+
A page **sort** is the exception, because no consumer degrades it: the list door
173+
answers an unknown sort field `400 INVALID_SORT` on every load. So every stored
174+
page sort (an element's `dataSource.sort`, a related list's `sort` and
175+
`parentDataSource.sort`, a list block's own `sort`, `interfaceConfig.sort`, an app
176+
page's `record.sort`) naming a field the bound object lacks is a
177+
`page-field-unknown` **error**, and the page publish door refuses the save with
178+
`422 INVALID_METADATA`.
179+
172180
Skipped, to keep false positives at zero: relationship paths (`account.name`,
173181
resolved by the query engine), registry-injected system fields (`created_at`,
174182
`owner_id`, …), components bound to an object another package defines, and

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,8 +1252,9 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
12521252
// [#30023] `page` joins, NARROW by the same mechanism: a page write reaches
12531253
// exactly `validateSortableFields`, the one member that declares `page`,
12541254
// so a stored page sort naming a field the list door refuses
1255-
// (`sortable: false`, a `formula`) is refused at publish rather than at
1256-
// its first fetch. Every other member keeps its declaration.
1255+
// (`sortable: false`, a `formula`, and since #30093 a field the object
1256+
// does not have, as `page-field-unknown`) is refused at publish rather
1257+
// than at its first fetch. Every other member keeps its declaration.
12571258
surfaces: CLI_AND_RUNTIME,
12581259
runtimeTypes: ['flow', 'view', 'object', 'dataset', 'report', 'page'],
12591260
run: (stack, ctx) => validateReferenceIntegrity(stack, ctx),

‎packages/lint/src/reference-integrity-suite.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,11 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
286286
// [#30023] `page` joins: since the projection's `author-declared` verdict
287287
// the member also walks every stored PAGE sort (element `dataSource.sort`, a
288288
// block's own `sort`, `interfaceConfig.sort`, `record.sort`) and judges it
289-
// by the sortability projection alone. It resolves only against
289+
// by the sortability projection. [#30093] It also asks each page sort
290+
// existence (`page-field-unknown`, `error`) and provenance
291+
// (`page-field-unprovisioned`, `warning`), which `validatePageFieldBindings`
292+
// no longer reads, so the page door refuses an unknown page sort field too.
293+
// It resolves only against
290294
// `stack.objects`, which the per-write page snapshot carries, so it has no
291295
// missing-collection false-positive channel — the #9313 reason, one write
292296
// type over. It is the only member that declares `page`, so a page write

‎packages/lint/src/validate-page-field-bindings.test.ts‎

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,10 @@ describe('validatePageFieldBindings — a related list splits its child and pare
423423
]]);
424424
});
425425

426-
it('a dataSource-bound list checks sort, filter and relationshipField against the child too', () => {
426+
// [#30093] The list's `sort` is a stored page sort, judged by
427+
// `validateSortableFields` at the list door's grade
428+
// (`validate-sortable-fields.test.ts` pins every slot), so it is not read here.
429+
it('a dataSource-bound list checks filter and relationshipField against the child too, and leaves sort to the sort rule', () => {
427430
expect(findingsOf({
428431
dataSource: { object: 'crm_account' },
429432
properties: {
@@ -432,7 +435,6 @@ describe('validatePageFieldBindings — a related list splits its child and pare
432435
filter: [{ field: 'ghost_filter', operator: 'equals', value: 'x' }],
433436
},
434437
}).map(([, , path, message]) => [path, message])).toEqual([
435-
[`${LIST}.sort[0].field`, expect.stringContaining('"ghost_sort" is not a field on object "crm_account"')],
436438
[`${LIST}.filter[0].field`, expect.stringContaining('"ghost_filter" is not a field on object "crm_account"')],
437439
[`${LIST}.relationshipField`, expect.stringContaining('"ghost_fk" is not a field on object "crm_account"')],
438440
]);
@@ -731,30 +733,27 @@ describe('validatePageFieldBindings — false-positive floor', () => {
731733
});
732734
});
733735

734-
describe('validatePageFieldBindings — legacy bare-string sort (#4340)', () => {
735-
/**
736-
* `ListViewSchema.sort` still accepts `"created_at desc"`. Reading the whole
737-
* string as a field name reported `"amount desc"` as unknown — a finding
738-
* whose "field" the author never wrote, and one no fix could satisfy. The
739-
* shared `sortFieldRefs` reads its head instead, the way the renderer does.
740-
*/
741-
it('judges the head segment, not the whole "field desc" string', () => {
736+
// [#30093] A list page's `interfaceConfig.sort` is a stored page sort, judged
737+
// by `validateSortableFields` (existence at `error`, the legacy string form read
738+
// by its head — the #4340 reading, pinned there), so this walk reads none of it.
739+
describe('validatePageFieldBindings — a stored page sort is not read here (#30093)', () => {
740+
it('reports nothing for an unknown interfaceConfig.sort, in either form, and still reads the columns', () => {
742741
const listPage = (sort: unknown) => ({
743742
...baseStack(),
744743
pages: [
745744
{
746745
name: 'list_page',
747746
type: 'list',
748747
object: 'crm_lead',
749-
interfaceConfig: { source: 'crm_lead', columns: ['name'], sort },
748+
interfaceConfig: { source: 'crm_lead', columns: ['name', 'ghost_col'], sort },
750749
},
751750
],
752751
});
753-
expect(validatePageFieldBindings(listPage('amount desc'))).toEqual([]);
754-
const bad = validatePageFieldBindings(listPage('ghost_col desc'));
755-
expect(bad).toHaveLength(1);
756-
expect(bad[0].message).toContain('"ghost_col"');
757-
expect(bad[0].message).not.toContain('ghost_col desc');
752+
for (const sort of ['ghost_sort desc', [{ field: 'ghost_sort', order: 'desc' }]]) {
753+
expect(validatePageFieldBindings(listPage(sort)).map((f) => f.path)).toEqual([
754+
'pages[0].interfaceConfig.columns[1]',
755+
]);
756+
}
758757
});
759758
});
760759

‎packages/lint/src/validate-page-field-bindings.ts‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,21 @@
5252
* same skips, same rule id: the two surfaces agree on what counts as a field by
5353
* construction rather than by two lists that happen to match, which is the
5454
* drift #4330 had just finished removing from the system-field lists.
55+
*
56+
* ── Stored page sorts are not read here (#30093) ─────────────────────────
57+
*
58+
* A page `sort` is not a binding a renderer skips: it lowers into the list
59+
* door's ORDER BY, which answers an unknown name `400 INVALID_SORT` on the
60+
* first load and every load after it. So every page sort slot (a component's
61+
* `dataSource.sort`, a related list's `sort` and `parentDataSource.sort`, each
62+
* sort-carrying block's own `sort`, `interfaceConfig.sort`, an app page's
63+
* `record.sort`) is judged by `validateSortableFields`, under this family's
64+
* ids: `page-field-unknown` at `error`, `page-field-unprovisioned` at
65+
* `warning`. That member is the one the page publish door runs, so the miss
66+
* refuses the save instead of publishing a page that breaks on its first read.
67+
* Until #30093 this walk read two of those slots at `warning` and the rest
68+
* were read by nothing. The react surface's sort props are not page metadata
69+
* slots and keep their own reading in `validate-react-page-props`.
5570
*/
5671

5772
export const PAGE_FIELD_UNKNOWN = 'page-field-unknown';
@@ -600,6 +615,20 @@ export function checkFieldRefs(
600615
return findings;
601616
}
602617

618+
/**
619+
* [#30093] Is `ref` one a related list's `sort` prop yielded? A STORED page
620+
* sort lowers into the list door's ORDER BY, which answers an unknown name
621+
* `400 INVALID_SORT` on every load, so it is not the `skipped` miss this pass
622+
* grades: `validateSortableFields` asks every page sort slot existence,
623+
* provenance and sortability, under this family's ids, and it is the member the
624+
* page publish door runs. {@link relatedListFieldRefs} keeps yielding the
625+
* sort refs for its out-of-repo callers; only this walk sets them aside.
626+
*/
627+
function isPageSortRef(ref: FieldRef, propsPath: string): boolean {
628+
const sortPath = `${propsPath}.sort`;
629+
return ref.path === sortPath || ref.path.startsWith(`${sortPath}[`) || ref.path.startsWith(`${sortPath}.`);
630+
}
631+
603632
export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] {
604633
const findings: PageFieldFinding[] = [];
605634
if (!stack || typeof stack !== 'object') return findings;
@@ -646,7 +675,14 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] {
646675
// parent; without it, `properties.objectName` names the child.
647676
const split = relatedListFieldRefs(props, base);
648677
const dataSource = isRec(component.dataSource) ? component.dataSource : undefined;
649-
checkRefs(split.related, strName(dataSource?.object) ?? split.relatedObject, where);
678+
// [#30093] The list's `sort` is a stored page sort, judged by
679+
// `validateSortableFields` at the list door's grade (see
680+
// {@link isPageSortRef}); every other child-side ref stays here.
681+
checkRefs(
682+
split.related.filter((ref) => !isPageSortRef(ref, base)),
683+
strName(dataSource?.object) ?? split.relatedObject,
684+
where,
685+
);
650686
// `relationshipValueField` names a field on the PARENT — the record the
651687
// list hangs off, never its own child `dataSource`. The parent is the
652688
// list's own `parentDataSource.object` when it declares one (ruling B on
@@ -693,9 +729,10 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] {
693729
if (cfg) {
694730
const cfgObject = strName(cfg.source) ?? strName(page.object);
695731
const base = `pages[${pi}].interfaceConfig`;
732+
// [#30093] `cfg.sort` is a stored page sort: `validateSortableFields`
733+
// judges it, at the list door's grade.
696734
const refs: FieldRef[] = [
697735
...fieldRefsFrom(cfg.columns, `${base}.columns`),
698-
...sortFieldRefs(cfg.sort, `${base}.sort`),
699736
...filterFieldRefs(cfg.filterBy, `${base}.filterBy`),
700737
];
701738
const userFilters = isRec(cfg.userFilters) ? cfg.userFilters : undefined;

0 commit comments

Comments
 (0)