Skip to content
Merged
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
86 changes: 86 additions & 0 deletions .changeset/sort-axis-authoring-gate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
---
"@objectstack/lint": minor
---

feat(lint): refuse a list-view `sort` that names a formula field, or no field at all, at authoring time (#9257)

<!-- adr-0087: not-required (already-registered engine-find-formula-order-by-refused)
This rule refuses no shape the runtime accepts — it moves an EXISTING refusal
earlier. `engine-find-formula-order-by-refused` (semantic, protocol 17) already
registers the condition and carries the identical FROM → TO prescription
("denormalise the value onto the object — a stored field, written when the
source changes — and sort by that", with `summary` explicitly unaffected); the
FROM → TO block below restates that entry's remedy for the list-view position
rather than prescribing a second, different one. The `sort-field-unknown` half
is covered by `assertSortFieldsExist` (#6994), a REST ingress refusal already
shipped. Nothing authorable is renamed, retired or tombstoned, and no
`sys_metadata` row changes shape, so there is no new conversion to register —
what changes is only WHEN the author is told. -->

**BREAKING** accept-set narrowing on a published authoring surface, shipped as
`minor` under the same lockstep launch-window convention the sibling
`filter-preset-comparand` refusal used. Measured against the shipped corpus
before landing at `error`: **56 reachable `sort` declarations across
`examples/app-showcase`, `examples/app-crm`, `examples/app-todo` and
`packages/platform-objects`, 0 violations** — so this narrows the accept set
without failing any metadata that ships today.

The SORT axis had a runtime refusal on both doors and no authoring gate. This
adds the missing half, which is the exact shape #6674 closed for the SEARCH
axis one axis over.

**What was broken.** `ListViewSchema.sort` is
`z.union([z.string(), Array<{ field, order }>])`, so the field name is a bare
string and Zod validates only the shape. A list view authored with
`sort: 'expected_revenue desc'` — a `formula` field — validated, published, and
reported valid, then answered `400 INVALID_SORT` on **first load and every
load**: the declared sort is the view's initial fetch, not an optional
interaction, so the whole view fails with a status the author cannot connect to
the declaration. Both runtime doors already refuse it — `assertSortFieldsExist`
(`@objectstack/metadata-protocol`, #6994) at the REST ingress and
`assertOrderByIsMaterializable` (`@objectstack/objectql`, #7095) on the engine's
own boundary — and neither can reach the author.

**What is refused**, at `error`, on every list-view sort a stack declares
(`objects[].listViews.*.sort`, `views[].list.sort`, `views[].listViews.*.sort`):

- `sort-field-unknown` — the name resolves to no field on the bound object.
Judged on the head segment, matching the ingress gate's own rule so the two
doors cannot disagree about which names are unknown.
- `sort-field-unsortable` — the name is a real field whose type is **virtual**:
computed on read, no stored column, nothing for any driver to `ORDER BY`. An
unrefused sort on one returns `asc` and `desc` in byte-identical order.

**What stays accepted, and this is the load-bearing half:** `summary` and
`autonumber` sorts. Virtuality is judged by `isVirtualSearchField` /
`SEARCH_VIRTUAL_TYPES` (`@objectstack/spec/data`), pinned to `formula` alone —
the same spec storage fact the search ingress gate, the engine's search
resolution and the FILTER axis' dotted-head classifier already read. It is
deliberately **not** the spec's `COMPUTED_VALUE_TYPES`: that set is the WRITE
contract ("never client-written") and gating a sort with it would refuse the two
types that sort correctly — `summary` is a `table.float` the engine maintains,
`autonumber` a `table.string` the engine assigns. Both directions are pinned by
test, and the predicate boundary itself is pinned alongside them so the two
"must not flag" cases cannot quietly stop meaning anything.

Registry-injected system columns (`created_at`, `owner_id`, …) are skipped:
they are real at runtime, never appear in authored `fields`, and `created_at` is
the single most common ordering in the platform's own list views.

## FROM → TO

```ts
// before — parsed green, published, then 400 INVALID_SORT on every load
listViews: {
forecast: { type: 'grid', sort: [{ field: 'expected_revenue', order: 'desc' }] },
}

// after — refused at authoring time, naming the field, the position and the fix
listViews: {
// denormalise the computed value onto a stored column and sort by that
forecast: { type: 'grid', sort: [{ field: 'expected_revenue_stored', order: 'desc' }] },
}
```

The rule joins `REFERENCE_INTEGRITY_RULES`, so it runs on `os validate`,
`os lint` and `os compile` at once rather than being wired per command.
17 changes: 17 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -359,6 +359,23 @@ export type {
SearchableFieldRole,
} from './validate-searchable-fields.js';

// [#9257] The SORT-axis twin of the rule above, judging the same spec storage
// predicate over a list view's declared `sort`. The runtime refuses both
// verdicts with `400 INVALID_SORT` (`assertSortFieldsExist` #6994 at the REST
// ingress, `assertOrderByIsMaterializable` #7095 in the engine); this is the
// authoring-time half, which is what makes the refusal traceable back to the
// declaration that caused it.
export {
validateSortableFields,
checkSortDeclaration,
SORT_FIELD_UNKNOWN,
SORT_FIELD_UNSORTABLE,
} from './validate-sortable-fields.js';
export type {
SortableFieldFinding,
SortableFieldSeverity,
} from './validate-sortable-fields.js';

export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

Expand Down
24 changes: 24 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@ describe('reference-integrity suite — membership', () => {
expect(REFERENCE_INTEGRITY_RULES.map((r) => r.name)).toEqual([
'validateObjectReferences',
'validateSearchableFields',
'validateSortableFields',
'validateActionNameRefs',
'validatePageFieldBindings',
'validateChartBindings',
Expand DownExpand Up@@ -59,11 +60,22 @@ describe('reference-integrity suite — every member actually runs', () => {
fields: {
name: { type: 'text', label: 'Name' },
locked: { type: 'boolean', label: 'Locked', readonly: true },
// validateSortableFields (#9257): a virtual field, so it is a REAL
// field name (existence passes) with no stored column behind it.
days_open: { type: 'formula', label: 'Days Open' },
},
// validateSearchableFields: `budget` is not a field on crm_lead, so the
// ADR-0061 declaration is stale — the engine drops it and searches a
// narrower set than the object declares.
searchableFields: ['name', 'budget'],
// validateSortableFields (#9257): the built-in list view's declared
// ordering names that formula field. Nothing else in this stack can
// produce the finding, and the failure it stands for is the view's
// FIRST fetch answering 400 INVALID_SORT (#6994 / #7095) — so this
// member going silent is a whole view that never loads.
listViews: {
aging: { type: 'grid', sort: [{ field: 'days_open', order: 'desc' }] },
},
permissions: {},
},
// validateNavObjectServability (#7912): an object the app puts in its
Expand DownExpand Up@@ -252,6 +264,7 @@ describe('reference-integrity suite — every member actually runs', () => {

expect(rules).toContain('object-reference-unknown');
expect(rules).toContain('searchable-field-unknown');
expect(rules).toContain('sort-field-unsortable');
expect(rules).toContain('action-name-undefined');
expect(rules).toContain('page-field-unknown');
expect(rules).toContain('chart-measure-unknown');
Expand DownExpand Up@@ -282,6 +295,17 @@ describe('reference-integrity suite — every member actually runs', () => {
expect(react?.severity).toBe('error');
});

it('carries a gating sort-field finding through the suite (#9257)', () => {
const findings = validateReferenceIntegrity(stack);
const sort = findings.find((f) => f.rule === 'sort-field-unsortable');
// Must reach the CLI as an ERROR on all three commands. A warning here
// would trade a loud authoring refusal for a `400 INVALID_SORT` the author
// cannot trace back to the declaration that caused it — which is the whole
// state this rule was added to end.
expect(sort?.severity).toBe('error');
expect(sort?.path).toBe('objects[0].listViews.aging.sort[0]');
});

it('carries a gating flow-template finding through the suite (#3810)', () => {
const findings = validateReferenceIntegrity(stack);
const flow = findings.find((f) => f.rule === 'flow-template-unknown-field');
Expand Down
17 changes: 17 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,6 +41,15 @@
* never wrote. See that module for why the other field-existence rules stay
* advisory and this one does not.
*
* `validateSortableFields` is the same reading one axis over (#9257): a list
* view's `sort` names a field, resolved against the object's declared fields.
* It gates for a stronger reason than its search sibling — the engine has no
* tolerance to describe here. An unknown sort name is refused at the REST
* ingress (`assertSortFieldsExist`, #6994) and a `formula` one by the engine
* itself (`assertOrderByIsMaterializable`, #7095), both `400 INVALID_SORT`; and
* because a view's declared sort is its FIRST fetch, the refusal is the whole
* view failing to load, every time, from an authoring typo made long before.
*
* Rules that check SHAPE rather than reference (view containers, responsive
* styles, seed replay safety, seed state machines, seed/security posture) stay
* out — they answer a different question and have their own call sites.
Expand All@@ -56,6 +65,7 @@

import { validateObjectReferences } from './validate-object-references.js';
import { validateSearchableFields } from './validate-searchable-fields.js';
import { validateSortableFields } from './validate-sortable-fields.js';
import { validateActionNameRefs } from './validate-action-name-refs.js';
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
Expand DownExpand Up@@ -110,6 +120,13 @@ export interface ReferenceIntegrityRule {
export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
{ name: 'validateObjectReferences', run: validateObjectReferences },
{ name: 'validateSearchableFields', run: validateSearchableFields },
// [#9257] The same reading, one axis over: a list view's `sort` is a field
// name written in metadata, resolved against the object's declared fields. It
// gates (`error`) because the runtime does not tolerate a bad one at all —
// `assertSortFieldsExist` (#6994) and `assertOrderByIsMaterializable` (#7095)
// both answer `400 INVALID_SORT` — and a view's sort is its FIRST fetch, so
// the refusal is the whole view, on every load, traced to nothing.
{ name: 'validateSortableFields', run: validateSortableFields },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
{ name: 'validateChartBindings', run: validateChartBindings },
Expand Down
Loading
Loading