From c150d3bed04c72bf787b7ffdecb6f8480ec809bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 11:48:56 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(lint):=20add=20the=20SORT=20axis=20aut?= =?UTF-8?q?horing=20gate=20=E2=80=94=20refuse=20a=20list-view=20sort=20nam?= =?UTF-8?q?ing=20a=20formula=20field=20(#9257)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime already refuses both verdicts with 400 INVALID_SORT — assertSortFieldsExist (#6994, REST ingress) and assertOrderByIsMaterializable (#7095, engine boundary) — and neither door can reach the author. A list view's declared `sort` is its FIRST fetch, so a `formula` entry breaks the whole view on every load with a status nothing traces back to the declaration. `validate-sortable-fields.ts` mirrors `validate-searchable-fields.ts` one axis over: `sort-field-unknown` (resolves to no field, judged head-first exactly as the ingress gate does) and `sort-field-unsortable` (a real field with no stored column). Virtuality is judged by the spec's own storage predicate `isVirtualSearchField` / SEARCH_VIRTUAL_TYPES, pinned to `formula` alone — never COMPUTED_VALUE_TYPES, which is the write contract and would refuse the `summary` and `autonumber` sorts that work correctly. Level `error`, gated on a corpus sweep first: 56 reachable sort declarations across app-showcase, app-crm, app-todo and platform-objects, 0 violations. Wired into REFERENCE_INTEGRITY_RULES so it runs on validate, lint and compile at once. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --- .changeset/sort-axis-authoring-gate.md | 73 +++ packages/lint/src/index.ts | 17 + .../src/reference-integrity-suite.test.ts | 24 + .../lint/src/reference-integrity-suite.ts | 17 + .../lint/src/validate-sortable-fields.test.ts | 298 +++++++++++ packages/lint/src/validate-sortable-fields.ts | 464 ++++++++++++++++++ 6 files changed, 893 insertions(+) create mode 100644 .changeset/sort-axis-authoring-gate.md create mode 100644 packages/lint/src/validate-sortable-fields.test.ts create mode 100644 packages/lint/src/validate-sortable-fields.ts diff --git a/.changeset/sort-axis-authoring-gate.md b/.changeset/sort-axis-authoring-gate.md new file mode 100644 index 0000000000..5033082981 --- /dev/null +++ b/.changeset/sort-axis-authoring-gate.md @@ -0,0 +1,73 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): refuse a list-view `sort` that names a formula field, or no field at all, at authoring time (#9257) + +**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. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 47864db444..0b40167a2b 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -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'; diff --git a/packages/lint/src/reference-integrity-suite.test.ts b/packages/lint/src/reference-integrity-suite.test.ts index 5cb8337a27..eed844bd79 100644 --- a/packages/lint/src/reference-integrity-suite.test.ts +++ b/packages/lint/src/reference-integrity-suite.test.ts @@ -17,6 +17,7 @@ describe('reference-integrity suite — membership', () => { expect(REFERENCE_INTEGRITY_RULES.map((r) => r.name)).toEqual([ 'validateObjectReferences', 'validateSearchableFields', + 'validateSortableFields', 'validateActionNameRefs', 'validatePageFieldBindings', 'validateChartBindings', @@ -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 @@ -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'); @@ -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'); diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 590cd900c6..1b8cf0300c 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -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. @@ -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'; @@ -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 }, diff --git a/packages/lint/src/validate-sortable-fields.test.ts b/packages/lint/src/validate-sortable-fields.test.ts new file mode 100644 index 0000000000..005d408514 --- /dev/null +++ b/packages/lint/src/validate-sortable-fields.test.ts @@ -0,0 +1,298 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SEARCH_VIRTUAL_TYPES, COMPUTED_VALUE_TYPES } from '@objectstack/spec/data'; +import { + validateSortableFields, + checkSortDeclaration, + SORT_FIELD_UNKNOWN, + SORT_FIELD_UNSORTABLE, +} from './validate-sortable-fields.js'; +import { indexObjectSearchTargets } from './validate-searchable-fields.js'; + +/** + * The object the whole file judges against. It carries one field of each of the + * three COMPUTED types on purpose — that trio is the rule's whole risk surface, + * because the write contract groups them and STORAGE does not. + */ +const opportunityFields = { + name: { type: 'text', label: 'Name' }, + amount: { type: 'currency', label: 'Amount' }, + probability: { type: 'percent', label: 'Probability' }, + stage: { type: 'select', label: 'Stage' }, + // Virtual — computed on read, no stored column on any driver. + expected_revenue: { type: 'formula', label: 'Expected Revenue' }, + // Stored: `table.float`, maintained by the engine. Sorts correctly. + open_task_count: { type: 'summary', label: 'Open Tasks' }, + // Stored: `table.string`, engine-assigned. Sorts correctly. + opp_no: { type: 'autonumber', label: 'Opportunity No.' }, +}; + +const withListView = (sort: unknown) => ({ + objects: [ + { + name: 'crm_opportunity', + fields: opportunityFields, + listViews: { pipeline: { type: 'grid', sort } }, + }, + ], +}); + +describe('validateSortableFields — the virtuality verdict (#9257)', () => { + it('flags a list-view sort naming a formula field', () => { + const findings = validateSortableFields( + withListView([{ field: 'expected_revenue', order: 'desc' }]), + ); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SORT_FIELD_UNSORTABLE); + expect(findings[0].severity).toBe('error'); + expect(findings[0].where).toBe('object "crm_opportunity" › listViews.pipeline'); + // The index is part of the path so the author can go straight to the key. + expect(findings[0].path).toBe('objects[0].listViews.pipeline.sort[0]'); + expect(findings[0].message).toContain('expected_revenue'); + expect(findings[0].message).toContain("'formula'"); + // The remedy is the engine door's own, so an author refused at authoring + // time and one refused at request time are sent the same way. + expect(findings[0].hint).toContain('Denormalise'); + expect(findings[0].hint).toContain('400 INVALID_SORT'); + }); + + it('flags it in the legacy string form too — the shape Zod cannot judge', () => { + // `ListViewSchema.sort` is `z.union([z.string(), Array<{field, order}>])`, + // so this parses clean and names a field that cannot be ordered by. + const findings = validateSortableFields(withListView('expected_revenue desc')); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SORT_FIELD_UNSORTABLE); + expect(findings[0].path).toBe('objects[0].listViews.pipeline.sort'); + }); + + it('reads the `-field` shorthand and the comma-separated multi-key string', () => { + const findings = validateSortableFields(withListView('stage asc, -expected_revenue')); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SORT_FIELD_UNSORTABLE); + expect(findings[0].path).toBe('objects[0].listViews.pipeline.sort[1]'); + }); + + it('flags every offending key, not just the first', () => { + const findings = validateSortableFields( + withListView([ + { field: 'stage', order: 'asc' }, + { field: 'expected_revenue', order: 'desc' }, + { field: 'no_such_field', order: 'asc' }, + ]), + ); + + expect(findings.map((f) => f.rule)).toEqual([SORT_FIELD_UNSORTABLE, SORT_FIELD_UNKNOWN]); + expect(findings.map((f) => f.path)).toEqual([ + 'objects[0].listViews.pipeline.sort[1]', + 'objects[0].listViews.pipeline.sort[2]', + ]); + }); +}); + +/** + * The second leg of the reverse verification, and the one that decides whether + * this rule is worth having: a gate that also refused `summary` / `autonumber` + * would reject metadata the runtime executes correctly. + * + * The trap is that the spec DOES group all three — `COMPUTED_VALUE_TYPES` is + * `formula` / `summary` / `autonumber` — but that set is the WRITE contract + * ("never client-written"), not a storage fact. `summary` is a `table.float` + * the engine maintains and `autonumber` a `table.string` it assigns; both have + * a real column and both sort. Only `formula` has none. + */ +describe('validateSortableFields — what it must NOT flag', () => { + it('does not flag a summary sort — a real stored column the engine maintains', () => { + expect(validateSortableFields(withListView([{ field: 'open_task_count', order: 'desc' }]))) + .toEqual([]); + }); + + it('does not flag an autonumber sort — a real stored column the engine assigns', () => { + expect(validateSortableFields(withListView([{ field: 'opp_no', order: 'asc' }]))) + .toEqual([]); + }); + + it('pins the predicate boundary the two cases above stand on', () => { + // If this ever fails, the two assertions above stopped meaning anything: + // the rule reads `SEARCH_VIRTUAL_TYPES`, and its distance from + // `COMPUTED_VALUE_TYPES` is the entire reason those two sorts stay legal. + expect([...SEARCH_VIRTUAL_TYPES]).toEqual(['formula']); + expect([...COMPUTED_VALUE_TYPES].sort()).toContain('summary'); + expect([...COMPUTED_VALUE_TYPES].sort()).toContain('autonumber'); + }); + + it('does not flag an ordinary stored field', () => { + expect(validateSortableFields(withListView([{ field: 'amount', order: 'desc' }]))).toEqual([]); + }); + + it('does not flag a registry-injected system column', () => { + // `created_at` never appears in authored `fields` and is the single most + // common ordering in the platform's own list views. Flagging it would be + // the false finding ADR-0072 D1 warns about. + expect(validateSortableFields(withListView([{ field: 'created_at', order: 'desc' }]))) + .toEqual([]); + }); + + it('says nothing about an object it cannot see', () => { + const findings = validateSortableFields({ + objects: [ + { + name: 'crm_opportunity', + fields: opportunityFields, + listViews: { + // Retargeted at an object no stack in view declares — a field map + // we cannot read cannot be judged. + partner: { + data: { provider: 'object', object: 'billing_invoice' }, + sort: [{ field: 'whatever', order: 'asc' }], + }, + }, + }, + ], + }); + expect(findings).toEqual([]); + }); + + it('says nothing about an object that declares no field map', () => { + // External / datasource-introspected: columns resolve at runtime. + const findings = validateSortableFields({ + objects: [ + { name: 'ext_orders', datasource: 'erp', listViews: { all: { sort: 'total desc' } } }, + ], + }); + expect(findings).toEqual([]); + }); + + it('ignores a declaration that names no field, and an absent one', () => { + expect(validateSortableFields(withListView(undefined))).toEqual([]); + expect(validateSortableFields(withListView(''))).toEqual([]); + expect(validateSortableFields(withListView([]))).toEqual([]); + // Shape errors belong to the schema, not to a reference rule. + expect(validateSortableFields(withListView([{ order: 'desc' }]))).toEqual([]); + expect(validateSortableFields(withListView([42]))).toEqual([]); + }); +}); + +describe('validateSortableFields — the existence verdict', () => { + it('flags a sort naming no field at all, and suggests the near miss', () => { + const findings = validateSortableFields(withListView([{ field: 'amont', order: 'desc' }])); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SORT_FIELD_UNKNOWN); + expect(findings[0].severity).toBe('error'); + expect(findings[0].message).toContain('Did you mean "amount"?'); + expect(findings[0].message).toContain('400 INVALID_SORT'); + }); + + it('judges a dotted path on its HEAD, exactly as the ingress gate does', () => { + // Unknown head → the unknown verdict, with the relation-crossing remedy. + const unknownHead = validateSortableFields( + withListView([{ field: 'account.name', order: 'asc' }]), + ); + expect(unknownHead).toHaveLength(1); + expect(unknownHead[0].rule).toBe(SORT_FIELD_UNKNOWN); + expect(unknownHead[0].hint).toContain('never a related record'); + // A KNOWN head is left to the ingress gate's own dotted verdict — see the + // module note on why this rule does not add a third finding for it. + expect(validateSortableFields(withListView([{ field: 'stage.label', order: 'asc' }]))) + .toEqual([]); + }); +}); + +describe('validateSortableFields — the surfaces it walks', () => { + const fields = { name: { type: 'text' }, score: { type: 'formula' } }; + + it('walks a defineView aggregate\'s default list', () => { + const findings = validateSortableFields({ + objects: [{ name: 'crm_lead', fields }], + views: [ + { + name: 'lead_views', + objectName: 'crm_lead', + list: { type: 'grid', sort: [{ field: 'score', order: 'desc' }] }, + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].where).toBe('view "lead_views" › list'); + expect(findings[0].path).toBe('views[0].list.sort[0]'); + }); + + it('walks a defineView aggregate\'s named list views', () => { + const findings = validateSortableFields({ + objects: [{ name: 'crm_lead', fields }], + views: [ + { + name: 'lead_views', + object: 'crm_lead', + listViews: { hot: { type: 'grid', sort: 'score desc' } }, + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].where).toBe('view "lead_views" › listViews.hot'); + expect(findings[0].path).toBe('views[0].listViews.hot.sort'); + }); + + it('honors a list view\'s own `data.object` binding over the container\'s', () => { + const findings = validateSortableFields({ + objects: [ + { name: 'crm_lead', fields: { name: { type: 'text' } } }, + { name: 'crm_task', fields: { due_at: { type: 'datetime' }, age: { type: 'formula' } } }, + ], + views: [ + { + name: 'lead_views', + objectName: 'crm_lead', + listViews: { + tasks: { + data: { provider: 'object', object: 'crm_task' }, + sort: [{ field: 'age', order: 'desc' }], + }, + }, + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('crm_task'); + }); + + it('returns nothing for an empty stack', () => { + expect(validateSortableFields({})).toEqual([]); + }); +}); + +describe('checkSortDeclaration — the shared core', () => { + it('resolves against the same object index the search axis uses', () => { + const stack = { objects: [{ name: 'crm_opportunity', fields: opportunityFields }] }; + const findings = checkSortDeclaration( + [{ field: 'expected_revenue', order: 'desc' }], + 'crm_opportunity', + indexObjectSearchTargets(stack), + 'page "pipeline"', + 'pages[0].sort', + 'page sort', + ); + + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SORT_FIELD_UNSORTABLE); + expect(findings[0].where).toBe('page "pipeline"'); + expect(findings[0].message).toContain('page sort'); + }); + + it('says nothing when the caller cannot name an object', () => { + expect( + checkSortDeclaration( + [{ field: 'anything', order: 'asc' }], + undefined, + indexObjectSearchTargets({ objects: [] }), + 'somewhere', + 'x.sort', + 'sort', + ), + ).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-sortable-fields.ts b/packages/lint/src/validate-sortable-fields.ts new file mode 100644 index 0000000000..dcefa3fb26 --- /dev/null +++ b/packages/lint/src/validate-sortable-fields.ts @@ -0,0 +1,464 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#9257 — the SORT axis' authoring gate] A list view's declared `sort` must + * name a field the object actually has, and one the runtime will agree to + * order by. + * + * This is the SORT-axis twin of `validate-searchable-fields.ts` (#6674), added + * for the same reason and judging by the same spec predicate. The runtime + * already refuses both halves; nothing read the DECLARATION. + * + * ── Why an authoring gate, when the runtime already refuses ─────────────── + * + * Two doors close this at request time, and neither can reach the author: + * + * - `assertSortFieldsExist` (`@objectstack/metadata-protocol`, #6994) — the + * REST ingress. Precedence `unknown` > `dotted` > unmaterializable, all + * three answering `400 INVALID_SORT`. + * - `assertOrderByIsMaterializable` (`@objectstack/objectql` `engine.ts`, + * #7095) — the engine's own boundary, for callers that never pass ingress. + * Third verdict only, same `400 INVALID_SORT`. + * + * A list view's `sort` is what the renderer puts on that view's FIRST fetch, so + * a declaration naming a `formula` field — or naming nothing at all — is a + * `400` on first load and on every load, with the cause being an authoring + * typo made long before. That is the failure `validate-searchable-fields`' + * docblock describes for the search axis, one axis over and strictly larger: + * a refused search is one optional interaction, a refused sort is the view's + * initial fetch. + * + * Nothing caught it before this rule. `ListViewSchema.sort` + * (`packages/spec/src/ui/view.zod.ts`) is + * `z.union([z.string(), z.array({ field: z.string(), order })])` — the field + * name is a bare string, exactly as `searchableFields` entries were before + * #6674, so Zod validates the SHAPE and can say nothing about the NAME. + * + * ── What is checked ────────────────────────────────────────────────────── + * + * 1. EXISTENCE (`sort-field-unknown`): a name that resolves to no field at all. + * Judged on the HEAD segment, which is the ingress gate's own rule + * (`!gate.known.has(f.split('.')[0])`) — so linter and gate agree about + * which names are "unknown" rather than disagreeing on dotted paths. + * + * 2. VIRTUALITY (`sort-field-unsortable`): a `formula` entry names a real + * field, so check 1 passes it, but the value is computed on read with no + * stored column, so no driver materialises anything to ORDER BY. Measured + * on this repo's own conformance suite: `orderBy asc` and + * `orderBy desc` return BYTE-IDENTICAL row order (insertion + * order), carrying the very values they were asked to be ordered by — the + * answer contradicts the request in plain view and still reports success. + * Since #6994/#7095 both doors refuse it by name instead. + * + * The verdict is `error`, not the advisory level the field-existence rules for + * pages and forms use, for the reason `validate-searchable-fields` gives at + * `error`: those describe a consumer that SKIPS an unknown name and renders the + * rest; this one describes a declaration the runtime REFUSES outright. + * + * ── The predicate, and the type list this rule must NOT use ────────────── + * + * Virtuality is judged by {@link isVirtualSearchField} / `SEARCH_VIRTUAL_TYPES` + * (`@objectstack/spec/data`), pinned to `formula` alone — the same spec fact + * the search ingress gate, the engine's search resolution and the FILTER axis' + * dotted-head classifier (#8296) already read. That constant documents itself + * as a STORAGE fact rather than a search taste judgment, which is what makes it + * the right authority for an axis that asks "is there a column to order by". + * + * ⛔ NOT the spec's `COMPUTED_VALUE_TYPES` (`formula` / `summary` / + * `autonumber`). That 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` maintained by the engine and `autonumber` a + * `table.string` the engine assigns. The distinction is pinned by name in the + * engine's own conformance suite and restated in `protocol.ts`'s + * `UNMATERIALIZED_SORT_TYPES` note; widening here would produce the false + * finding that makes authors stop trusting the linter (ADR-0072 D1). + * + * ── Which sort positions are walked, and which were checked and are not ── + * + * Walked — every author-written `sort` that lowers into an engine `orderBy` + * over the bound object's OWN fields, which is the same set of surfaces + * `validateSearchableFields` walks for `searchableFields`: + * + * - `objects[].listViews..sort` — built-in named list views; + * - `views[].list.sort` — a `defineView` aggregate's default list; + * - `views[].listViews..sort` — its named list views. + * + * NOT walked, each verified against the schema rather than assumed: + * + * - **A saved report's `query.orderBy`.** Verified: it is not an authoring + * surface at all. `sys_saved_report` is a platform OBJECT and the envelope + * lives in its `query_json` COLUMN (`packages/platform-objects/src/audit/ + * sys-saved-report.object.ts`, `contracts/report-service.ts`) — a runtime + * record written through the reports API, never a key in stack metadata. + * The stack's own `reports[]` is `ReportSchema`, whose ADR-0021 single-form + * cutover REMOVED the inline query; what it declares instead is + * `order[].by`, naming a dataset dimension or measure, and `checkReportOrder` + * already refines that against what the report selects. So there is no + * authored `query.orderBy` for a stack rule to reach; the engine door + * (#7095) is the only door that surface has, which is precisely why #7095 + * added it. + * - **Flow node sort config.** Verified: none exists. + * `automation/builtin-node-config.zod.ts`'s record-reading node declares + * `limit` and no ordering key at all, and no schema under + * `packages/spec/src/automation/` declares `sort` or `orderBy`. + * - **Dashboard widget sort config.** Verified present but out of this + * predicate's domain: `DashboardWidgetOptionsSchema.sortBy` + * (`ui/dashboard.zod.ts`) names "a dimension or measure this widget + * actually selects" and is lowered into a `DatasetSelection.order`, i.e. an + * ADR-0021 semantic-layer name resolved against a DATASET — not an object + * field, so a field-type predicate cannot judge it. The same is true of + * `ReportSchema.order[].by`. Judging those needs the dataset's measure + * index, which is `validateChartBindings`' family, not this one. + * + * Two more list-shaped surfaces carry a `sort` and are deliberately left to + * their owners, exactly as the search axis leaves the react page surface to + * `validate-react-page-props`: page/component `sort` + * (`ui/page.zod.ts`, `ui/component.zod.ts` — `walkPageComponents`' territory) + * and the flattened standalone list overlay the metadata door accepts + * (`ViewMetadataSchema`'s list-overlay member, top-level `sort`). The overlay + * reaches the runtime publish gate rather than a stack walk, and the + * reference-integrity suite's runtime dispatch is `runtimeTypes: ['flow']` + * today — widening it is #4463 P2's decision, not this rule's, and the SEARCH + * axis has the identical gap. + * + * ── Skips, matching the search axis one for one (ADR-0072 D1) ──────────── + * + * 1. An object this stack does not define — it may come from another package, + * and a field map we cannot see cannot be judged. + * 2. An object that declares no field map at all — external objects and + * datasource-introspected schemas whose columns resolve at runtime. + * 3. Registry-injected system columns (`SYSTEM_FIELDS`, derived from the + * spec's own declarations). `sort: [{ field: 'created_at' }]` is the + * single most common list-view ordering in the platform's own objects and + * names a real column that never appears in authored `fields`. They are + * skipped for VIRTUALITY too: their runtime metadata is registry-owned and + * invisible here, and none of them is a formula. + * + * A DOTTED name (`account.name`) is refused by the ingress gate as its own + * second verdict, and this rule deliberately does not add a third finding for + * it — it judges the head for existence and stops there, so a dotted path with + * a known head passes here and is refused at request time. That gap is + * recorded rather than closed because the dotted verdict is a posture shared + * with the FILTER and PROJECTION axes (#4256 / #7532 / #7589) and giving one + * axis its own authoring answer is how those doors drifted apart before. + */ + +import { isVirtualSearchField } from '@objectstack/spec/data'; +import { SYSTEM_FIELDS } from './system-fields.js'; +import { + indexObjectSearchTargets, + type ObjectSearchTarget, +} from './validate-searchable-fields.js'; + +export const SORT_FIELD_UNKNOWN = 'sort-field-unknown'; +export const SORT_FIELD_UNSORTABLE = 'sort-field-unsortable'; + +export type SortableFieldSeverity = 'error' | 'warning'; + +export interface SortableFieldFinding { + /** Always `error` — both verdicts are a `400 INVALID_SORT` at request time. */ + severity: SortableFieldSeverity; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `object "crm_opportunity" › listViews.pipeline`. */ + where: string; + /** Config path, e.g. `objects[0].listViews.pipeline.sort[1]`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** One parsed sort key: the field name, and the path segment it was written at. */ +interface SortKey { + field: string; + /** Index suffix for `path` — `[0]` for an array entry, `''` for the string form. */ + at: string; +} + +/** + * Read an authored `sort` declaration into the field names it orders by. + * + * Mirrors the SHAPES `normalizeSortNodes` (`@objectstack/metadata-protocol`) + * folds at the wire, narrowed to the two the authoring schema declares: + * + * - the legacy string — `'amount desc'`, `'-amount'`, and the comma-separated + * multi-key form `'stage asc, amount desc'` the normalizer splits on; + * - the structured `Array<{ field, order }>`. + * + * A string ENTRY inside the array is accepted too: the wire normalizer takes it + * and a pre-parse stack can still be carrying one. Anything else — a number, a + * record with no string `field` — is a SHAPE error the schema owns, not a + * dangling reference, and is skipped here for the same reason + * `checkSearchableFieldList` skips a non-string entry. + */ +function readSortKeys(declared: unknown): SortKey[] { + const fromShorthand = (raw: string, at: string): SortKey | undefined => { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + const bare = trimmed.startsWith('-') ? trimmed.slice(1).trim() : trimmed.split(/\s+/)[0]; + return bare ? { field: bare, at } : undefined; + }; + + if (typeof declared === 'string') { + return declared + .split(',') + .map((part, i) => fromShorthand(part, declared.includes(',') ? `[${i}]` : '')) + .filter((k): k is SortKey => !!k); + } + + if (Array.isArray(declared)) { + const keys: SortKey[] = []; + for (let i = 0; i < declared.length; i++) { + const el = declared[i]; + if (typeof el === 'string') { + const k = fromShorthand(el, `[${i}]`); + if (k) keys.push(k); + continue; + } + if (!isRec(el)) continue; + const field = strName(el.field); + if (field) keys.push({ field: field.trim(), at: `[${i}]` }); + } + return keys; + } + + return []; +} + +/** Levenshtein-bounded "did you mean?" over the object's own field names. */ +function suggest(target: string, known: Iterable): string { + let best: string | undefined; + let bestScore = Infinity; + for (const candidate of known) { + const d = distance(target, candidate); + if (d < bestScore) { + bestScore = d; + best = candidate; + } + } + const limit = Math.max(2, Math.floor(target.length / 3)); + return best && bestScore <= limit ? ` Did you mean "${best}"?` : ''; +} + +function distance(a: string, b: string): number { + const m = a.length; + const n = b.length; + if (m === 0) return n; + if (n === 0) return m; + let prev = Array.from({ length: n + 1 }, (_, j) => j); + for (let i = 1; i <= m; i++) { + const curr = [i, ...new Array(n).fill(0)]; + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost); + } + prev = curr; + } + return prev[n]; +} + +/** + * Check ONE authored `sort` declaration against the object it is bound to — + * the shared core behind every list-view surface that declares an ordering. + * + * `fieldsByObject` is {@link indexObjectSearchTargets}' index, reused rather + * than rebuilt: the SEARCH and SORT doors must agree about which fields an + * object has and what type each one is, and two hand-written readers of the + * same field map is the drift `system-fields.ts` and `view-walk.ts` were both + * created to end. `subject` names the declaration for the message; the parsed + * key's position is appended to `path` so the author can go straight to it. + */ +export function checkSortDeclaration( + declared: unknown, + objectName: string | undefined, + fieldsByObject: ReadonlyMap, + where: string, + path: string, + subject: string, +): SortableFieldFinding[] { + const findings: SortableFieldFinding[] = []; + if (declared === undefined || declared === null) return findings; + if (!objectName) return findings; // nothing to resolve against + if (!fieldsByObject.has(objectName)) return findings; // ① object from another package + const target = fieldsByObject.get(objectName); + if (!target) return findings; // ② external / introspected — no authored field map + + const known = target.names; + + for (const key of readSortKeys(declared)) { + const name = key.field; + // The ingress gate resolves existence on the HEAD segment + // (`!gate.known.has(f.split('.')[0])`); matching it keeps the two doors + // from disagreeing about which names are unknown. + const head = name.split('.')[0]; + // ③ Registry-injected system column — real at runtime, absent from + // authored `fields`. `created_at` is the platform's own most common list + // ordering, and flagging it would be the false finding ADR-0072 D1 warns + // about. + if (SYSTEM_FIELDS.has(head)) continue; + + if (!known.has(head)) { + const dotted = name.includes('.'); + findings.push({ + severity: 'error', + rule: SORT_FIELD_UNKNOWN, + where, + path: `${path}${key.at}`, + message: + `${subject} orders by "${name}", which is not a field on object ` + + `"${objectName}". The runtime refuses the sort rather than dropping it: ` + + `every load of this view answers 400 INVALID_SORT (#6994), because a sort ` + + `is the view's FIRST fetch and not an optional interaction.` + + (dotted ? '' : suggest(head, known)), + hint: + (dotted + ? `'sort' reaches only whole columns of "${objectName}" itself, never a ` + + `related record's column — denormalise the value onto "${objectName}" ` + + `(a stored field, written when the source changes) and sort by that. ` + : `Fix the name, or add "${name}" to ${objectName}.fields. `) + + (known.size > 0 ? `Object fields: ${[...known].sort().join(', ')}.` : ''), + }); + continue; + } + + // Virtuality — the verdict the runtime added last and the one an author + // cannot see, because the name IS a real field. Judged by the spec's own + // storage predicate so linter, ingress gate and engine cannot disagree + // about which types have a column. + const meta = target.fields[name]; + if (isVirtualSearchField(meta)) { + const vtype = meta?.type; + findings.push({ + severity: 'error', + rule: SORT_FIELD_UNSORTABLE, + where, + path: `${path}${key.at}`, + message: + `${subject} orders by "${name}" on object "${objectName}", a virtual ` + + `'${vtype}' field: its value is computed on read and never stored, so no ` + + `driver materialises a column to ORDER BY. Measured, an unrefused sort on ` + + `one returns 'asc' and 'desc' in byte-identical order — the rows carry the ` + + `values they were asked to be ordered by, unordered, under a success.`, + hint: + `Denormalise the value onto "${objectName}" (a stored field, written when ` + + `the source changes) and sort by that, or drop "${name}" from this sort. ` + + `At runtime both doors now refuse it with 400 INVALID_SORT — the REST ` + + `ingress (#6994) and the engine itself (#7095) — so the declaration ` + + `breaks the view's first fetch, and every fetch after it.`, + }); + } + } + + return findings; +} + +/** + * Validate every list-view `sort` declaration in the stack — the object's + * built-in named list views and the `defineView` aggregates that declare one. + * Returns findings (empty = clean). + * + * The surfaces walked here are exactly `validateSearchableFields`', for the + * same reason: these are the declarations that lower into an engine `orderBy` + * over the bound object's own columns. See the module note for the four + * sort-carrying surfaces that were checked and deliberately left out. + */ +export function validateSortableFields(stack: AnyRec): SortableFieldFinding[] { + const findings: SortableFieldFinding[] = []; + if (!isRec(stack)) return findings; + + const objects = Array.isArray(stack.objects) + ? (stack.objects as unknown[]) + : isRec(stack.objects) + ? Object.entries(stack.objects).map(([name, def]) => ({ name, ...(def as AnyRec) })) + : []; + const fieldsByObject = indexObjectSearchTargets(stack); + + const check = ( + declared: unknown, + objectName: string | undefined, + where: string, + path: string, + subject: string, + ) => { + findings.push( + ...checkSortDeclaration(declared, objectName, fieldsByObject, where, path, subject), + ); + }; + + // ── The object's built-in named list views ── + for (let oi = 0; oi < objects.length; oi++) { + const obj = objects[oi]; + if (!isRec(obj)) continue; + const objName = strName(obj.name); + const label = objName ? `object "${objName}"` : `objects[${oi}]`; + + if (isRec(obj.listViews)) { + for (const [key, lv] of Object.entries(obj.listViews)) { + if (!isRec(lv)) continue; + check( + lv.sort, + // A built-in list view belongs to its object; an inline `data.object` + // may still retarget it (ADR-0047 allows the explicit binding). + listViewObject(lv) ?? objName, + `${label} › listViews.${key}`, + `objects[${oi}].listViews.${key}.sort`, + 'list-view sort', + ); + } + } + } + + // ── `defineView` aggregates: the default `list` + named `listViews` ── + const views = Array.isArray(stack.views) ? (stack.views as unknown[]) : []; + for (let vi = 0; vi < views.length; vi++) { + const view = views[vi]; + if (!isRec(view)) continue; + const viewLabel = strName(view.name) ?? strName(view.objectName) ?? `#${vi}`; + // The aggregate's own binding is the fallback for a list view that declares + // none — the same resolution order `validateSearchableFields` reads. + const viewObject = strName(view.objectName) ?? strName(view.object); + + if (isRec(view.list)) { + check( + view.list.sort, + listViewObject(view.list) ?? viewObject, + `view "${viewLabel}" › list`, + `views[${vi}].list.sort`, + 'list-view sort', + ); + } + + if (isRec(view.listViews)) { + for (const [key, lv] of Object.entries(view.listViews)) { + if (!isRec(lv)) continue; + check( + lv.sort, + listViewObject(lv) ?? viewObject, + `view "${viewLabel}" › listViews.${key}`, + `views[${vi}].listViews.${key}.sort`, + 'list-view sort', + ); + } + } + } + + return findings; +} + +/** A list view's own object binding: `data: { provider: 'object', object }`. */ +function listViewObject(listView: AnyRec): string | undefined { + const data = listView.data; + return isRec(data) ? strName(data.object) : undefined; +} From d7e823085c983bf6f4845c8625e9b5a84d16261f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 11:50:47 +0000 Subject: [PATCH 2/2] docs(changeset): answer the ADR-0087 disposition question in writing (#9257) The changeset declares BREAKING (an accept-set narrowing on a published authoring surface), so `check-adr-0087-registration` requires the ledger question to be answered. The honest answer is `already-registered`: this rule refuses no shape the runtime accepts, it moves the EXISTING refusal earlier. `engine-find-formula-order-by-refused` (semantic, protocol 17) already carries the identical FROM -> TO prescription, and the `sort-field-unknown` half is `assertSortFieldsExist` (#6994), already shipped at the REST ingress. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --- .changeset/sort-axis-authoring-gate.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.changeset/sort-axis-authoring-gate.md b/.changeset/sort-axis-authoring-gate.md index 5033082981..6e89134c26 100644 --- a/.changeset/sort-axis-authoring-gate.md +++ b/.changeset/sort-axis-authoring-gate.md @@ -4,6 +4,19 @@ feat(lint): refuse a list-view `sort` that names a formula field, or no field at all, at authoring time (#9257) + + **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