Files
ersatztv/docs/superpowers/specs/2026-07-23-rulebuilder-bundle-design.md
T

8.3 KiB
Raw Blame History

RuleBuilder bundle — design (#438 + #435 + #434)

Date: 2026-07-23 Issues: #438 (validation & polish), #435 (relative-date operators), #434 (facet-value typeahead) Branch: feat/rulebuilder-bundle Left open: #436 (deeper nesting — self-labeled YAGNI), #437 (inline RuleBuilder adoption)

The visual rule builder (web/src/builder/rules/, landed #176 / PR #433) compiles a visual rule tree to a Lucene query string and parses it back, with a lossless-round-trip contract. It has one consumer today: CollectionsScreen.tsx (SmartCollection create/edit, mounted at line ~347). Convention doc: docs/spa-conventions.md §12 (not §11 — #437's citation is wrong and will be corrected when #437 is worked).

Slicing & workflow

  • One feature branch / one PR closing all three (fixes #438 #435 #434). They share RuleBuilder.tsx (and #435/#438 share types.ts/compile.ts/parse.ts) too heavily to split without rebase churn, and read as one cohesive polish sweep.
  • #434's backend endpoint is the only disjoint slice — delegated to a parallel worktree agent (branched off the feature branch, merged back early) so its C# + OpenAPI regen runs while the frontend is built. No parallel agents over the shared frontend module (the one-file fan-out trap).
  • Everything else is sequential, single committing agent in the main worktree: #438 → #435 → #434-combobox (combobox last, after the endpoint's generated API types exist).
  • Independent adversarial review over the whole diff before push (new API endpoint + >~150 lines). Live-E2E on the SmartCollection screen for the round-trip.

#438 — validation & polish (frontend only)

Confirmed current behavior (compile.ts):

  • between with empty value2 emits field:[v TO ] (malformed). — compile.ts:45
  • empty containsfield:**; empty startsWithfield:*. — compile.ts:3336
  • single-child group drops its connective (join over one element). — compile.ts:5257
  • isNot/notMatchesNOT field:"v", which matches nothing alone (no MatchAllDocs). — compile.ts:31

Fixes:

  1. between requires both bounds. A between rule with empty value or value2 is invalid — surface a per-rule validation error and block compile/save. Never emit field:[v TO ].
  2. Empty text values are invalid. contains/startsWith/is/isNot with an empty value are incomplete (not a wildcard) — same per-rule validation error; block compile/save. Never emit field:* or field:**.
  3. Single-child group round-trips its connective. Fix on the parser side: a compiled single-child group parses back to the builder's default match so a 1-child {match:'any'} no longer reopens as 'all'. Output (compile) is unchanged; this is a parse-side alignment only.
  4. Pure-negative top group → non-blocking warning. When every child of the top group is negative (isNot/notMatches), show a warning (a NOT-only query matches nothing) and rely on the live preview count. Do not block — a positive branch nested deeper can legitimately rescue it, so a hard block would create false negatives. (Decision confirmed with user 2026-07-23.)

Validation surface: a per-rule "incomplete" flag rendered inline in RuleBuilder.tsx, and an aggregate "has-errors" boolean the consumer (CollectionsScreen) uses to disable Save. Compile of an invalid tree returns a sentinel (empty / null) rather than a malformed string.

Tests: extend compile.test.ts / parse.test.ts / roundtrip.test.ts for each case (cases 13 are hard assertions; case 4 asserts the warning predicate, not a block).

#435 — relative-date operators (frontend only — backend macros already exist)

The Lucene macros exist in ErsatzTV.Infrastructure/Search/CustomMultiFieldQueryParser.cs: released_inthelast / released_notinthelast / added_inthelast / added_notinthelast, value form "<n> day|week|month|year" (unit substring-matched; negated from DateTime.Today). No C# change — they are absent from SearchFieldCatalog only because they are query-time synthetic fields. (There is no inthenext macro, so "in the next N" is out of scope — matches the issue.)

  • types.ts: add operators inLast and notInLast, offered only on date fields (extend OPERATORS_BY_TYPE.date). Add optional unit?: 'day' | 'week' | 'month' | 'year' to Rule (cleaner than overloading value2, which is the between upper bound). value holds the integer N.
  • RuleBuilder.tsx: for inLast/notInLast, render a number input + a unit <select>.
  • compile.ts: map {field, op, value:N, unit:U}<synthetic>:"N U" via a small mapping table release_date → released, added_date → added, and inLast → inthelast / notInLast → notinthelast. e.g. {release_date, inLast, 7, day}released_inthelast:"7 day".
  • parse.ts: recognize the four synthetic-field spellings (released|added)_(inthelast|notinthelast):"<n> <unit>" and map back to {field: release_date|added_date, op: inLast|notInLast, value, unit}.
  • The release_date↔released / added_date↔added + inLast↔inthelast mapping table is the single seam, shared by compile and parse.
  • Tests: extend roundtrip.test.ts + explicit compile/parse cases for each synthetic field and unit.

#434 — facet-value typeahead

Backend (parallel worktree agent)

  • New endpoint GET /api/v1/search/fields/{name}/values?q=&limit= in ErsatzTV/Controllers/Api/SearchController.cs, thin mediator.Send(...) per the SearchCollections/SearchArtists pattern (SearchController.cs:8599).
  • New MediatR query + handler under ErsatzTV.Application/Search/Queries/ (GetSearchFieldValues / GetSearchFieldValuesHandler), following GetSearchFieldCatalog*.
  • New ISearchIndex method to enumerate distinct terms for a field with a case-insensitive prefix filter — implemented in LuceneSearchIndex (read the index reader's terms for the field). Cap at limit (default/max 50).
  • Allow-list: text-type catalog fields only. A field that is absent from SearchFieldCatalog or is not typed text returns 404 (bounds the surface; enum ships values inline, number/date/fulltext aren't typeahead targets). (Decision confirmed with user 2026-07-23.)
  • Response DTO: SearchFieldValuesResponseModel { string[] Values } in ErsatzTV.Core/Api/Search/, file-scoped #nullable enable (per api.response-dtos).
  • OpenAPI: regenerate v1.json / v1.d.ts / endpoint-index.md via ./scripts/update-openapi.sh in the same diff (per release.api-contract-ci-gate).
  • Tests: handler test (allow-listed field returns filtered values; non-text/unknown field → the 404 path); term-enumeration unit coverage.

Frontend (sequential, after the endpoint's generated types exist)

  • fieldCatalog.ts: add a getSearchFieldValues(name, q) client over the generated API.
  • RuleBuilder.tsx: for a text field's value input with a contains/startsWith/is/isNot operator, swap the <input> for an autocomplete combobox (debounced query to the endpoint). Purely additive — the emitted value and compile/parse are unchanged. Falls back to free-text when the endpoint returns nothing (so a typo still compiles, keeping the preview-count safety net).

Docs updated in-PR

Doc Change
api-conventions.md checklist entry for the new search/fields/{name}/values endpoint
v1.json / v1.d.ts / endpoint-index.md regenerated (#434 endpoint)
spa-conventions.md §12 relative-date operators (#435) + typeahead combobox (#434) behavior
docs/decisions.md record for the #434 distinct-values endpoint; record for the #435 relative-date field-mapping convention
docs/blazor-route-parity.md not required (no route change)

Out of scope / deferred

  • #436 (deeper group nesting): compile.ts already recurses arbitrarily deep; the cap is solely parse.ts:121 passing false. Left open — self-labeled YAGNI until a real query needs it.
  • #437 (inline RuleBuilder adoption in ChannelBuilder + Auto-Tune): larger integration + live-E2E, its own session. Note for then: cite spa-conventions.md §12, not §11.
  • does not contain / date is: these operators do not exist in the set and are not being added (only #435's inLast/notInLast).