Uh oh!
There was an error while loading. Please reload this page.
feat(lint): add the SORT axis authoring gate — refuse a list-view sort naming a formula field (#9257) - #9314
Conversation
…t naming a formula field (#9257) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
…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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop):
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9257
The SORT axis had a runtime refusal on both doors and no authoring gate. This adds the missing half — the exact shape #6674 closed for the SEARCH axis one axis over.
The gap
ListViewSchema.sortisz.union([z.string(), Array of { field, order }]), so the field name is a bare string and Zod validates only the shape — exactly assearchableFieldsentries were before #6674. A list view authored withsort: 'expected_revenue desc'(aformulafield) validated, published, and reported valid, then answered400 INVALID_SORTon first load and every load. A declared sort is the view's initial fetch, not one optional interaction, so the whole view fails with a status nothing traces back to the declaration.Both runtime doors already refuse it and neither can reach the author:
assertSortFieldsExist(@objectstack/metadata-protocol, A non-dottedorderBynaming aformulafield answers 200 in arbitrary order — the sort is silently dropped (measured on driver-sql + driver-memory) #6994) at the REST ingress — precedenceunknownthendottedthen unmaterializable;assertOrderByIsMaterializable(@objectstack/objectqlengine.ts,engine.find()still drops aformulaORDER BY silently — decide whether the engine refuses or keeps its internal-caller tolerance #7095) on the engine's own boundary.What lands
packages/lint/src/validate-sortable-fields.ts, joined toREFERENCE_INTEGRITY_RULESso it runs onos validate,os lintandos compileat once rather than being wired per command. Two verdicts, botherror:sort-field-unknown— the name resolves to no field on the bound object. Judged on the head segment, which is the ingress gate's own rule (!gate.known.has(f.split('.')[0])), 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 toORDER BY.Surfaces walked are exactly
validateSearchableFields':objects[].listViews.*.sort,views[].list.sort,views[].listViews.*.sort.The predicate, and the set this must not use
Virtuality is judged by
isVirtualSearchField/SEARCH_VIRTUAL_TYPES(@objectstack/spec/data), pinned toformulaalone — the same spec storage fact the search ingress gate, the engine's search resolution and the FILTER axis' dotted-head classifier (#8296) already read. It is deliberately notCOMPUTED_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 (summaryis atable.floatthe engine maintains,autonumberatable.stringit assigns). This PR touches nothing inpackages/spec— it consumes the predicate.Ruling 2's corpus check — run, and the count either way
Measured with the rule itself over the real shipped metadata (objects and view containers loaded from their own modules):
examples/app-showcaseexamples/app-crmexamples/app-todopackages/platform-objects56
sortdeclarations reachable by the rule, 0 violations — soerrorships without failing any metadata that exists today, and no corpus fix was needed.A green corpus from a rule that never fires would be worth nothing, so the harness was proved live on the same run: mutating a real corpus object in memory (
crm_opportunitygiven a list view ordering by its ownexpected_revenueformula field) produced exactly one finding,[sort-field-unsortable] objects[4].listViews.forecast.sort.Reverse verification, both legs
The second leg is what stops this from becoming a rule that refuses correct metadata, so it is pinned as a test rather than only measured:
formulasort, in the structured form, the legacy string form, the-fieldshorthand and the comma-separated multi-key string;summarysort or anautonumbersort — and the predicate boundary those two stand on is pinned alongside them (SEARCH_VIRTUAL_TYPESis exactly['formula'],COMPUTED_VALUE_TYPEScontains both others), so the two "must not flag" cases cannot quietly stop meaning anything;created_at— the platform's own most common list ordering), an object with no readable field map, or an object this stack does not define.Positions verified rather than assumed
Ruling 4 named three further sort positions. Each was checked against the schema; the module docblock records all three with their evidence.
query.orderByis not an authoring surface at all.sys_saved_reportis a platform OBJECT and the envelope lives in itsquery_jsoncolumn — a runtime record written through the reports API, never a key in stack metadata. The stack's ownreports[]isReportSchema, whose ADR-0021 single-form cutover removed the inline query; what it declares instead isorder[].by, a dataset dimension or measure, already refined bycheckReportOrder. So no stack rule can reach it, which is precisely whyengine.find()still drops aformulaORDER BY silently — decide whether the engine refuses or keeps its internal-caller tolerance #7095 added the engine door for that surface.automation/builtin-node-config.zod.tsdeclareslimitand no ordering key; no schema underpackages/spec/src/automation/declaressortororderBy.DashboardWidgetOptionsSchema.sortBynames a dimension or measure the widget selects and lowers into aDatasetSelection.order— an ADR-0021 semantic-layer name resolved against a dataset, not an object field. Judging it needs the dataset's measure index, which isvalidateChartBindings' family.Two surfaces are left to their owners and named in the docblock: page/component
sort(walkPageComponents' territory) and the flattened standalone list overlay the metadata door accepts, which reaches the runtime publish gate — where the suite's dispatch isruntimeTypes: ['flow']today. Widening that is #4463 P2's decision, and the SEARCH axis has the identical gap. Recorded as #9313 (a sub-issue of the open P2 tracker #4716) rather than ridden in here — out of scope: #9313 is not addressed by this PR.Verification
Union re-run on the final commit
d7e823085, all green:check:nul-bytes,check:changeset-gate-self-tests,check:cross-package-test-inputs(+ thescripts/form),check:objectui-changeset,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,docs-audit/check-affected-docs, and the convention-triggered set the new test files move —check:query-options-erasure,check:engine-double-contract,check:where-matcher,check:type-check-coverage,check:type-check-debt --re-measure(33 ledger entries, none above its recorded number, run on a fully built workspace closure).packages/lint: 74 test files, 2086 tests passed;typecheckclean.Consumer-package sweep (a spec-consuming lint change can make another package's fixture assert the opposite, and nothing inside
packages/lintcan see it) — the three direct dependents of@objectstack/lint, plus the whole transitive dependent closure:@objectstack/metadata-protocol@objectstack/cli@objectstack/platform-objectspnpm --filter '...@objectstack/lint' test(full dependent closure)packages/qa/dogfood110 files / 780 tests passedChangeset
packages/lintis published, so this carries a real changeset atminor— a BREAKING accept-set narrowing on a published authoring surface, shipped asminorunder the same lockstep launch-window convention the siblingfilter-preset-comparandrefusal used. Its ADR-0087 disposition isnot-required (already-registered engine-find-formula-order-by-refused): this rule refuses no shape the runtime accepts, it moves an existing refusal earlier, and that entry already carries the identical FROM to TO prescription withsummaryexplicitly unaffected.Generated by Claude Code
Generated by Claude Code