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
62 changes: 62 additions & 0 deletions .changeset/searchable-fields-stale-declaration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
"@objectstack/lint": minor
---

feat(lint): a `searchableFields` entry naming no field is caught at authoring
time, not at request time

`searchableFields` is `z.array(z.string())` in both `object.zod.ts` and the
list-view schema, so nothing ever checked that an entry resolves to anything.
Rename a field and the old name stays behind — Zod-valid, shipped, pointing at
a column that no longer exists.

The engine tolerates it, which is exactly what kept the drift invisible:
`resolveSearchFields` filters the declaration down to fields that exist
(`searchableFields?.filter((f) => all[f])`) and says nothing. The tolerance
fails in the direction nobody expects:

- **some entries stale** → `$search` scans a NARROWER set than the object
declares. Records that should match do not, and the response is
indistinguishable from "no such record";
- **every entry stale** → the filtered set is empty, so resolution falls
through to the AUTO-DEFAULT (name/title + short-text fields). A declaration
whose whole purpose is to CHOOSE the searchable set ends up selecting one the
author never wrote — the "asked narrower, answered wider" inversion #4226
closed on the projection axis.

It also stops being quiet downstream. Clients echo the declaration verbatim as
the `$searchFields` override (objectui's list search sends
`schema.searchableFields`), so once the REST read path validates that override
against the object (#4254), a stale entry the engine had been silently skipping
becomes a `400 INVALID_FIELD` on every list search for that object — a
request-time break whose cause is an authoring typo made long before.

**New rule — `searchable-field-unknown` (gating).** Wired into
`REFERENCE_INTEGRITY_RULES`, so it runs on `os validate`, `os lint` and
`os compile` with no CLI edit. It covers the object's own ADR-0061 declaration
and the list views that narrow it (`objects[].listViews`, a `defineView`
default `list`, and named `listViews`), resolving each entry against the bound
object's declared fields.

`error`, not the advisory level the other field-existence rules use
(`page-field-unknown`, `form-field-unknown`, `semantic-role-field-unknown` are
all warnings). Those describe a consumer that SKIPS an unknown name and renders
the rest; this describes a declaration that either selects the wrong set or
refuses the request outright — the same call `validate-flow-template-paths`
makes for a filter-position token, where the miss widens the query instead of
shrinking the page.

Existence only: a field that exists but is an odd search target (a `json`
column) is NOT flagged — an explicit `searchableFields` is authoritative, so
declaring one is a choice, not drift. Three skips keep false positives near zero
(ADR-0072 D1): an object this stack does not define, an object with no authored
field map (external / datasource-introspected), and registry-injected system
columns — the last derived from the spec's own `FIELD_GROUP_SYSTEM_FIELDS` and
`SystemFieldName` rather than hand-copied, since this package already carries
five slightly-different copies of that list.

Dotted paths are the one place this rule is stricter than its siblings. They
skip `owner_id.name` because the query engine resolves the traversal; search
does not — `resolveSearchFields` matches the field map by exact string, so a
dotted entry is dropped exactly like a typo, and it is the spelling most likely
borrowed from `select`/`sort`. It is flagged, with its own fix hint.
9 changes: 9 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,15 @@ export {
} from './validate-object-references.js';
export type { ObjectRefFinding, ObjectRefSeverity } from './validate-object-references.js';

export {
validateSearchableFields,
SEARCHABLE_FIELD_UNKNOWN,
} from './validate-searchable-fields.js';
export type {
SearchableFieldFinding,
SearchableFieldSeverity,
} from './validate-searchable-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
6 changes: 6 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('reference-integrity suite — membership', () => {
it('holds exactly the reference-resolution rules, in report order', () => {
expect(REFERENCE_INTEGRITY_RULES.map((r) => r.name)).toEqual([
'validateObjectReferences',
'validateSearchableFields',
'validateActionNameRefs',
'validatePageFieldBindings',
'validateChartBindings',
Expand Down Expand Up @@ -48,6 +49,10 @@ describe('reference-integrity suite — every member actually runs', () => {
{
name: 'crm_lead',
fields: { name: { type: 'text', label: 'Name' } },
// 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'],
permissions: {},
},
],
Expand Down Expand Up @@ -144,6 +149,7 @@ describe('reference-integrity suite — every member actually runs', () => {
const rules = new Set(findings.map((f) => f.rule));

expect(rules).toContain('object-reference-unknown');
expect(rules).toContain('searchable-field-unknown');
expect(rules).toContain('action-name-undefined');
expect(rules).toContain('page-field-unknown');
expect(rules).toContain('chart-measure-unknown');
Expand Down
11 changes: 11 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@
* severities (see that module: a filter-position miss gates, every other
* position advises), which is why the suite's contract is severity-agnostic.
*
* `validateSearchableFields` is a member on the same reading, one layer in: an
* ADR-0061 `searchableFields` entry is a field name written in metadata,
* resolved against the object's own declared fields. It gates (`error`) because
* the engine's tolerance for a stale entry — silently filtering it out — either
* narrows the searched set below what the object declares or, once every entry
* is stale, falls through to the auto-default and searches a set the author
* never wrote. See that module for why the other field-existence rules stay
* advisory and this one does not.
*
* 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 @@ -46,6 +55,7 @@
*/

import { validateObjectReferences } from './validate-object-references.js';
import { validateSearchableFields } from './validate-searchable-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 Down Expand Up @@ -91,6 +101,7 @@ export interface ReferenceIntegrityRule {
*/
export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
{ name: 'validateObjectReferences', run: validateObjectReferences },
{ name: 'validateSearchableFields', run: validateSearchableFields },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
{ name: 'validateChartBindings', run: validateChartBindings },
Expand Down
Loading
Loading