Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.3 KiB
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 shareRuleBuilder.tsx(and #435/#438 sharetypes.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):
betweenwith emptyvalue2emitsfield:[v TO ](malformed). — compile.ts:45- empty
contains→field:**; emptystartsWith→field:*. — compile.ts:33–36 - single-child group drops its connective (
joinover one element). — compile.ts:52–57 isNot/notMatches→NOT field:"v", which matches nothing alone (no MatchAllDocs). — compile.ts:31
Fixes:
betweenrequires both bounds. Abetweenrule with emptyvalueorvalue2is invalid — surface a per-rule validation error and block compile/save. Never emitfield:[v TO ].- Empty text values are invalid.
contains/startsWith/is/isNotwith an emptyvalueare incomplete (not a wildcard) — same per-rule validation error; block compile/save. Never emitfield:*orfield:**. - Single-child group round-trips its connective. Fix on the parser side: a compiled
single-child group parses back to the builder's default
matchso a 1-child{match:'any'}no longer reopens as'all'. Output (compile) is unchanged; this is a parse-side alignment only. - Pure-negative top group → non-blocking warning. When every child of the top group is negative
(
isNot/notMatches), show a warning (aNOT-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 1–3 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
inLastandnotInLast, offered only ondatefields (extendOPERATORS_BY_TYPE.date). Add optionalunit?: 'day' | 'week' | 'month' | 'year'toRule(cleaner than overloadingvalue2, which is thebetweenupper bound).valueholds 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 tablerelease_date → released,added_date → added, andinLast → 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↔inthelastmapping 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=inErsatzTV/Controllers/Api/SearchController.cs, thinmediator.Send(...)per theSearchCollections/SearchArtistspattern (SearchController.cs:85–99). - New MediatR query + handler under
ErsatzTV.Application/Search/Queries/(GetSearchFieldValues/GetSearchFieldValuesHandler), followingGetSearchFieldCatalog*. - New
ISearchIndexmethod to enumerate distinct terms for a field with a case-insensitive prefix filter — implemented inLuceneSearchIndex(read the index reader's terms for the field). Cap atlimit(default/max 50). - Allow-list:
text-type catalog fields only. A field that is absent fromSearchFieldCatalogor is not typedtextreturns 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 }inErsatzTV.Core/Api/Search/, file-scoped#nullable enable(perapi.response-dtos). - OpenAPI: regenerate
v1.json/v1.d.ts/endpoint-index.mdvia./scripts/update-openapi.shin the same diff (perrelease.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 agetSearchFieldValues(name, q)client over the generated API.RuleBuilder.tsx: for atextfield's value input with acontains/startsWith/is/isNotoperator, 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.tsalready recurses arbitrarily deep; the cap is solelyparse.ts:121passingfalse. 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/ dateis: these operators do not exist in the set and are not being added (only #435'sinLast/notInLast).