Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/30093-page-sort-unknown-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/lint': minor
---

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)

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

<!-- 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. -->

**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`.

- **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.
- **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.
- **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.
- **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.

**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.
10 changes: 9 additions & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,20 @@ component silently skips it and the page renders one item short.

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

A page **sort** is the exception, because no consumer degrades it: the list door
answers an unknown sort field `400 INVALID_SORT` on every load. So every stored
page sort (an element's `dataSource.sort`, a related list's `sort` and
`parentDataSource.sort`, a list block's own `sort`, `interfaceConfig.sort`, an app
page's `record.sort`) naming a field the bound object lacks is a
`page-field-unknown` **error**, and the page publish door refuses the save with
`422 INVALID_METADATA`.

Skipped, to keep false positives at zero: relationship paths (`account.name`,
resolved by the query engine), registry-injected system fields (`created_at`,
`owner_id`, …), components bound to an object another package defines, and
Expand Down
5 changes: 3 additions & 2 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1252,8 +1252,9 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// [#30023] `page` joins, NARROW by the same mechanism: a page write reaches
// exactly `validateSortableFields`, the one member that declares `page`,
// so a stored page sort naming a field the list door refuses
// (`sortable: false`, a `formula`) is refused at publish rather than at
// its first fetch. Every other member keeps its declaration.
// (`sortable: false`, a `formula`, and since #30093 a field the object
// does not have, as `page-field-unknown`) is refused at publish rather
// than at its first fetch. Every other member keeps its declaration.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['flow', 'view', 'object', 'dataset', 'report', 'page'],
run: (stack, ctx) => validateReferenceIntegrity(stack, ctx),
Expand Down
6 changes: 5 additions & 1 deletion packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,11 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
// [#30023] `page` joins: since the projection's `author-declared` verdict
// the member also walks every stored PAGE sort (element `dataSource.sort`, a
// block's own `sort`, `interfaceConfig.sort`, `record.sort`) and judges it
// by the sortability projection alone. It resolves only against
// by the sortability projection. [#30093] It also asks each page sort
// existence (`page-field-unknown`, `error`) and provenance
// (`page-field-unprovisioned`, `warning`), which `validatePageFieldBindings`
// no longer reads, so the page door refuses an unknown page sort field too.
// It resolves only against
// `stack.objects`, which the per-write page snapshot carries, so it has no
// missing-collection false-positive channel — the #9313 reason, one write
// type over. It is the only member that declares `page`, so a page write
Expand Down
31 changes: 15 additions & 16 deletions packages/lint/src/validate-page-field-bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,10 @@ describe('validatePageFieldBindings — a related list splits its child and pare
]]);
});

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

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

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

export const PAGE_FIELD_UNKNOWN = 'page-field-unknown';
Expand Down Expand Up @@ -600,6 +615,20 @@ export function checkFieldRefs(
return findings;
}

/**
* [#30093] Is `ref` one a related list's `sort` prop yielded? A STORED page
* sort lowers into the list door's ORDER BY, which answers an unknown name
* `400 INVALID_SORT` on every load, so it is not the `skipped` miss this pass
* grades: `validateSortableFields` asks every page sort slot existence,
* provenance and sortability, under this family's ids, and it is the member the
* page publish door runs. {@link relatedListFieldRefs} keeps yielding the
* sort refs for its out-of-repo callers; only this walk sets them aside.
*/
function isPageSortRef(ref: FieldRef, propsPath: string): boolean {
const sortPath = `${propsPath}.sort`;
return ref.path === sortPath || ref.path.startsWith(`${sortPath}[`) || ref.path.startsWith(`${sortPath}.`);
}

export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] {
const findings: PageFieldFinding[] = [];
if (!stack || typeof stack !== 'object') return findings;
Expand Down Expand Up @@ -643,7 +672,14 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] {
// parent; without it, `properties.objectName` names the child.
const split = relatedListFieldRefs(props, base);
const dataSource = isRec(component.dataSource) ? component.dataSource : undefined;
checkRefs(split.related, strName(dataSource?.object) ?? split.relatedObject, where);
// [#30093] The list's `sort` is a stored page sort, judged by
// `validateSortableFields` at the list door's grade (see
// {@link isPageSortRef}); every other child-side ref stays here.
checkRefs(
split.related.filter((ref) => !isPageSortRef(ref, base)),
strName(dataSource?.object) ?? split.relatedObject,
where,
);
// `relationshipValueField` names a field on the PARENT — the record the
// list hangs off, never its own child `dataSource`. The parent is the
// list's own `parentDataSource.object` when it declares one (ruling B on
Expand Down Expand Up @@ -690,9 +726,10 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] {
if (cfg) {
const cfgObject = strName(cfg.source) ?? strName(page.object);
const base = `pages[${pi}].interfaceConfig`;
// [#30093] `cfg.sort` is a stored page sort: `validateSortableFields`
// judges it, at the list door's grade.
const refs: FieldRef[] = [
...fieldRefsFrom(cfg.columns, `${base}.columns`),
...sortFieldRefs(cfg.sort, `${base}.sort`),
...filterFieldRefs(cfg.filterBy, `${base}.filterBy`),
];
const userFilters = isRec(cfg.userFilters) ? cfg.userFilters : undefined;
Expand Down
Loading
Loading