LearnNewsExamplesServices
Frontmatter
id19792
titleGrid record search: the Store-wide search contribution and its counting
stateClosed
labels
enhancementaigrid
assigneesneo-opus-grace
createdAtOct 16, 2026, 11:48 AM
updatedAtOct 16, 2026, 12:45 PM
githubUrlhttps://github.com/neomjs/neo/issues/19792
authorneo-opus-grace
commentsCount3
parentIssue19791
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[ ] 19794 Grid record search: the optional search toolbar and its summary, [ ] 19793 Grid headers show when their column is filtered
closedAtOct 16, 2026, 12:45 PM
milestonev13.3

Grid record search: the Store-wide search contribution and its counting

Closed Backlog/active-chunk-9 enhancementaigrid
neo-opus-grace
neo-opus-grace commented on Oct 16, 2026, 11:48 AM

Context

This is the data-contract leaf of epic #19791, graduated from #19786 (contracts 1–3 and the stored field list from A2). The header active-state leaf and the toolbar leaf consume what this one adds. Nothing visual ships here. The API shape was posted before implementation (comment 6213003977); this body records the delivered shape.

The Problem

A grid cannot write a record search today:

  • No Store contribution searches several fields. The only multi-field predicate is a filterBy function, and nothing declares which fields it reads.
  • The owned-key validator turns an explicit property: null into id (key.property ?? 'id', src/collection/Base.mjs), while the Filter itself materializes null. The validator and the instance disagree for exactly the columnless slot a search needs.
  • Collection always creates a base Filter, so a Filter subclass can't be added through a config.
  • getFilterValues and the Store's soft hydration read filter.property. For raw items, a null property would refuse every count, and hydration would write an item["null"] key.
  • No read says whether a filter actually narrows anything. A cleared header field keeps value: '', which is inert.

The Architectural Reality

  • isFiltered runs filterBy before the property path. disabled_ is reactive and fires change, and afterSetFilterBy is an empty todo, so swapping a callback never re-filters.
  • Neo.setupClass stores each class's merged config on its constructor, so reconciliation can compare any Filter subclass through cls.config.
  • Design authority: the graduated contract in #19786 (body SHA-256 72b90388…, signed by Claude and GPT), and the ownership rules of #19646.

The Fix

  • Neo.collection.SearchFilter (new, src/collection/) extends Filter. Its value holds the query: every whitespace term must occur in at least one declared field. caseSensitive_ and fieldText_ (per-field (value, item) => String hooks) are reactive, and property defaults to null. It overrides isFiltered, effective and export() (which returns null), so it has no callback to swap. An empty or whitespace-only query is inert: it matches everything, is not effective, and keeps the fields.
  • Filter: a generic reactive fields_ (the fields a predicate reads) and an effective getter.
  • Collection.beforeSetFilters: the owner-key property resolves as the Filter exposes it, so an explicit null stays null and absence takes the class default. A config may name module (a Filter subclass); creation uses it, and reconciliation compares the constructor and that class's merged config. fields compare element-wise.
  • Store: a filter's read set is its declared fields, else its non-null property. Counting, soft hydration and isFilteredItem use it. getSearchRefusal(fields, fieldText) returns remote-projection, unknown-field, unsupported-field or null; TreeStore returns tree-population.

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
Neo.collection.SearchFilter (new) #19786 contracts 1, 2 Terms across fields; inert when empty; local only — class JSDoc + @example unit: acceptance fixture
Filter.fields_ (new) #19786 contract 3 The fields a predicate reads null: the read set is property config JSDoc unit
Filter#effective (new) #19786 contract 5 true only while the filter narrows — getter JSDoc unit
Collection.beforeSetFilters #19786 contract 1; #19669 Class-default key property; module creation and reconciliation Plain configs unchanged inline comments unit: owner-key checks
Store#getFilterValues, filter, isFilteredItem read set #19786 contract 3 Declared fields, else non-null property Filters without fields unchanged JSDoc unit: raw-item counts
Store#getSearchRefusal (new), TreeStore#getSearchRefusal #19786 contracts 2, 4 Refusal reason or null — method JSDoc unit

Decision Record impact: aligned-with #19646 (ownership). Decision Record: NOT_NEEDED.

Acceptance Criteria

  • Owner key: an explicit null stays the columnless slot from config to materialized Filter to complete filter-array replacement, and an identical config keeps the live instance. Duplicate equivalent contributions are refused at each stage. An omitted-property callback still resolves to id. The search coexists with header-inline, header-picker and unowned predicates.
  • Matching: on the acceptance fixture, the expected ids hold for case-insensitive terms, the case-sensitive mode, a title-only scope, an application restriction, and the composed predicates.
  • An empty or whitespace query is inert, keeps its fields and is not effective; the next term filters again. Scope-only and case-only changes re-filter through change.
  • Counts: under the composed predicates, the picker's counts follow everything except the picker itself. On raw items, the searched fields are checked canonically and a null property never enters the check set.
  • getSearchRefusal names unknown fields, unsupported types (a Date or an Array without a hook), remote populations and trees. A Date with a hook searches as the hook's text.
  • Soft hydration resolves declared fields on raw complex models and never writes a null key.

Out of Scope

The header active state and the toolbar UI (the epic's other leaves); the grid-level lifecycle witnesses (Store swap, grid destroy and recreate, two grids on one Store), which moved to the toolbar leaf because they need a grid; regex (#19786 OQ4); and the header-filter layout (OQ8).

Related

#19791 · #19786 · #19669

Live latest-open sweep: newest open issues at 09:47Z, no equivalent (only #19791, its epic). A2A claim sweep: no claim on the search contribution. MC sweep: "grid record search toolbar scope all visible named columns", clear miss beyond #19786. Own-assignment sweep: #19790 (Table click-sort), which does not overlap.

Origin Session ID: bb2e8968-a6d6-4ef7-a4ed-5ab3157b1ac8 Retrieval Hint: "SearchFilter columnless owner slot module reconciliation getSearchRefusal read set"

tobiu referenced in commit af81fc0 - "feat(data): a Store-wide record search contribution with declared read sets (#19792) (#19796) on Oct 16, 2026, 12:45 PM
tobiu closed this issue on Oct 16, 2026, 12:45 PM