A field's capabilities – output, filterable, facetable, sortable, searchable, joinable – are loose optional properties on SearchFieldBase, and everything about them is spread across the codebase. PR #780 added a second axis, position (top level, nested in a reference type, the inline reference itself, a local lookup), and the resulting matrix is written down nowhere. Four review passes over that PR found several of its cells simply empty.
What is scattered today
Which capability is serviceable where lives in four unrelated places, none aware of the others:
UNSERVICEABLE_NESTED_ROLES (packages/search/src/schema.ts) – for a field of a reference type;
unserviceableInlineRoles – for the inline reference itself;
assertServiceableLocalLookup – for a local lookup;
validateSearchType's joinable-with-inline-ref – for one capability on one position.
What each capability implies physically is re-derived per consumer. physicalFields returns a bag of names (search, sort, facet, identity) and every caller maps its own question onto a member by convention: the filter compiler picks identity ?? field.name, the facet compiler picks facet, query_by picks search, and the label lookup reads document[field.name].
“Declares no capability at all” is isInternalField, which enumerates all six by hand and has to be edited whenever one is added.
Why it is worth doing – the cells that were empty
Not a tidiness argument. These are defects found in review of #780, each of which is one missing cell or one caller picking the wrong member:
joinable was refused on an inline reference but not on a local lookup: the join graph built the edge, the collection emitted no engine reference, and the join failed at query time.
- Then not on a nested lookup either: the criterion degraded to a vacuous
in: [], so the filter matched everything.
searchable became legal on a nested field and was indexed – but nothing added its companion to query_by, so it cost the RAM of an indexed field and matched nothing.
- An internal
local lookup's target was added to query_by while its fields are declared in no collection, which makes the engine reject every search on that collection.
- A filter compiled to the logical name where the value is stored under a companion; the label lookup read a stored nested object as an id and sent
"[object Object]" as an IRI.
The first two are the same mistake found twice, a round apart. That is the signature of a rule with no single home.
Shape
An internal capability registry, one entry per capability, owning both halves:
{ name: 'searchable',
declaredBy: (field) => field.searchable !== undefined,
servedAt: (position) => position !== 'inline', // the matrix, stated once
physical: (field, schema) => [...], // what it needs indexed
queried: true } // ...and that it feeds query_by
Then:
isInternalField becomes “no capability declares it”;
- every refusal becomes “for each declared capability, assert
servedAt(position)”, with one message shape;
physicalFields becomes the union over declared capabilities;
- a consumer asks by capability rather than indexing into a bag, so it cannot reach for
field.name where a companion exists.
Adding a capability, or a position, then forces every cell to be answered rather than leaving holes for a reviewer to find.
Constraint
The declaration stays plain data. A SearchType is a plain object literal on purpose – a SHACL generator can emit one, a hand-written one is equally valid, and a served API mounts it as data. The registry is therefore internal, keyed by the existing flag names; the declaration surface does not change and no deployment schema moves.
Out of scope
- Renaming the capability flags themselves, or the declaration shape.
- The word “Role”, which the code uses for this concept and which now collides with
schema:Role in the qualified-relations work. Worth settling, but separately.
A field's capabilities –
output,filterable,facetable,sortable,searchable,joinable– are loose optional properties onSearchFieldBase, and everything about them is spread across the codebase. PR #780 added a second axis, position (top level, nested in a reference type, the inline reference itself, alocallookup), and the resulting matrix is written down nowhere. Four review passes over that PR found several of its cells simply empty.What is scattered today
Which capability is serviceable where lives in four unrelated places, none aware of the others:
UNSERVICEABLE_NESTED_ROLES(packages/search/src/schema.ts) – for a field of a reference type;unserviceableInlineRoles– for the inline reference itself;assertServiceableLocalLookup– for alocallookup;validateSearchType'sjoinable-with-inline-ref– for one capability on one position.What each capability implies physically is re-derived per consumer.
physicalFieldsreturns a bag of names (search,sort,facet,identity) and every caller maps its own question onto a member by convention: the filter compiler picksidentity ?? field.name, the facet compiler picksfacet,query_bypickssearch, and the label lookup readsdocument[field.name].“Declares no capability at all” is
isInternalField, which enumerates all six by hand and has to be edited whenever one is added.Why it is worth doing – the cells that were empty
Not a tidiness argument. These are defects found in review of #780, each of which is one missing cell or one caller picking the wrong member:
joinablewas refused on an inline reference but not on alocallookup: the join graph built the edge, the collection emitted no engine reference, and the join failed at query time.in: [], so the filter matched everything.searchablebecame legal on a nested field and was indexed – but nothing added its companion toquery_by, so it cost the RAM of an indexed field and matched nothing.locallookup's target was added toquery_bywhile its fields are declared in no collection, which makes the engine reject every search on that collection."[object Object]"as an IRI.The first two are the same mistake found twice, a round apart. That is the signature of a rule with no single home.
Shape
An internal capability registry, one entry per capability, owning both halves:
Then:
isInternalFieldbecomes “no capability declares it”;servedAt(position)”, with one message shape;physicalFieldsbecomes the union over declared capabilities;field.namewhere a companion exists.Adding a capability, or a position, then forces every cell to be answered rather than leaving holes for a reviewer to find.
Constraint
The declaration stays plain data. A
SearchTypeis a plain object literal on purpose – a SHACL generator can emit one, a hand-written one is equally valid, and a served API mounts it as data. The registry is therefore internal, keyed by the existing flag names; the declaration surface does not change and no deployment schema moves.Out of scope
schema:Rolein the qualified-relations work. Worth settling, but separately.