The claim this whole pass rests on is that the same query reaches a relay as the same REQ from either front end. Nothing checked it — every bug it started from was a violation of it, and each one was invisible because the two answers were never compared. `desktopApp` is the only module that can see both paths, so the test lives there: 16 query shapes, each asked through Android's `searchPostsByText` and Desktop's `SearchFilterFactory`, compared arm by arm on kinds, authors, tags, search, since and until. One assumption of mine was wrong and the test said so: a bare `from:` does build filters on both sides. It is bounded by its author, so unlike a bare `kind:` it is a real search rather than an unbounded feed. The plan is updated to what shipped, including the two findings left for someone else: twelve event classes missing from `EventFactory`, and the searchable-kinds reference table now nine kinds behind the code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
13 KiB
Search state unification
Status: done, by order (b). Phases 1 and 2 both landed; §9's questions are answered below from what shipped rather than left open.
1. Why
Search is implemented twice. Neither implementation delegates to the other, and they drift silently. Over one session of feature work, four separate bugs were found, every one of them the same shape — a control that looks live on one platform and does nothing on the other:
| Android before | Desktop | |
|---|---|---|
-term exclusions, kind:reply/kind:media |
never applied | applied |
query.kinds reaching the REQ |
dropped | honoured |
Relevance sort |
ranked by date | ranked by score |
| Multi-kind alias serialization | wrote the alias once per kind | same bug, both |
| Search history / saved searches | absent | present |
Per-relay progress, real isSearching |
absent | present |
The first three are fixed (2711b365, 4beff0d7); the last two are not. The
point is not the individual bugs — it is that four appeared in one pass without
looking for them. The structure manufactures this class of defect, so fixing
them one at a time has no end.
Supporting measurements, taken 2026-09-10:
SearchBarViewModel— 615 lines, 13 top-level flows.AdvancedSearchBarState— 314 lines, incommons/, used only by desktop.- The same query string is parsed ~9× per keystroke (4 direct
QueryParser.parsecalls plus 5plainTerms(…), which parses again). - 10 debounces across 4 windows (100 ms ×8, 300, 400); 8 collectors debounce the
same
searchValueFlowindependently. - Scope is applied as 7 separate
if (scope == …) return emptyList()guards inside result flows. - Four hand-maintained answers to "which kinds are searchable": the relay
allowlist (34), the local denylist (13),
KindRegistry.aliases(33), and quartz'sSearchableEvent— 142 reachable kinds, referenced 0 times by the client. (The first two are now one list; see Phase 1 step 3.)
2. The real blocker (it is not laziness)
Desktop did not reuse Android's search because it could not:
| Type | Module |
|---|---|
LocalCache, CacheSearch, Account, HiddenUsersState |
amethyst/ |
Note, User, SearchQuery, QueryParser, SearchFilterBuilder |
commons/ |
Android's result flows are built on LocalCache.search.*, an amethyst-module
singleton. Nothing in commons/ can call it, so desktop wrote a parallel state
holder over relay callbacks alone. Every divergence follows from that one fact.
Note what this implies: the pure parts of search already live in commons and
are already shared-capable — SearchQuery, QueryParser, QuerySerializer,
SearchFilterBuilder, SearchResultFilter, SearchResultSorter,
KindRegistry. Three of the four bugs above were fixed this session by simply
calling code that was already there. The divergence in that layer is not
structural; it is a wiring failure that nothing prevents from recurring.
3. Relationship to the commons migration sweep — read before scheduling
commons/plans/2026-08-30-commons-migration-sweep.md already plans the move
that dissolves this problem, as its step 3:
Move
LocalCache(3,980 lines). … This deletesDesktopLocalCache.kt(1,173 lines) — the single largest duplication in the repo.
and step 2 changes IFeedTopNavFilter to use "the existing commons
ICacheProvider port". That port already exists and already carries
findUsersStartingWith, getEventStream(), getNoteIfExists,
allRelayGroupChannels.
Therefore: do not build a bespoke search-side cache port. A SearchIndex
abstraction invented here would be scaffolding that step 3 deletes. The
sequencing question belongs to the maintainer, and the two coherent orders are:
- (a) After the sweep's steps 2–3.
LocalCacheis incommons; a single sharedSearchStatereads it directly; no new port at all. Smallest total work, but gated on a large migration that "needs maintainer input". - (b) Before it, via
ICacheProvider. Extend the existing port with the five methodsCacheSearchalready implements (§5, Phase 2). This is additive to the sweep rather than parallel to it — the same port step 2 already needs — and survives the move unchanged.
Either way, Phase 1 below is independent of both and should land first.
4. Target design
One pipeline, three layers, with the platform boundary at the bottom only:
text ──► SearchQuery ──► List<Filter> ──► results ──► post-filter ──► rank
parse build ask exclusions sort
▲ ▲ pseudo-kinds
│ │
relays ──┘ └── cache (the only platform seam)
SearchPipeline(commons, CLI-safe) — pure functions, no state, no coroutines. Owns parse → build → post-filter → rank. Already ~all present as loose objects; the value is making the sequence a single call so a caller cannot skip a step.SearchState(commons) — one debouncedStateFlow<SearchQuery>, one scope decision, one result set, oneisSearching. Replaces bothAdvancedSearchBarStateand the bulk ofSearchBarViewModel.- Platform shells —
SearchBarViewModelkeepsLazyListState,FocusRequester, invite-link routing, Namecoin, NIP-05; desktop keeps its panel/spotlight state. Both become thin.
What is genuinely not shareable
Do not attempt to unify these; they are real differences, not drift:
- Result types. Android surfaces 7 (notes, users, hashtags, relays, public
chat / ephemeral / live-activity channels); desktop surfaces 2.
SearchStateshould expose a map of result kinds, and each front end renders what it has. - Sort orders. Desktop has separate event and people orders; Android has one. Take desktop's shape; Android binds both to one control.
- Scope. An Android-only control today. Model it as a filter over the shared result set, applied once — not as 7 early returns.
5. Phases
Phase 1 — one pipeline, no state moves (migration-independent, do first)
-
SearchPipelineincommons/search/:parse,filters,keep,rankas one object, with the ordering documented and tested. -
Repoint both
SearchBarViewModelandAdvancedSearchBarStateat it. No behaviour change intended — this is the regression-test bed for Phase 2. -
One kind-set source. (Done —
RenderableKindsincommons/search/, checked againstSearchableKindsin quartz.) The relay allowlist, desktop's copy of it, and the local scan's absent window are now one list. Quartz gainedSearchableKinds.ALL, the 142 kindsEventFactorybuilds aSearchableEventfor, verified by sweeping the whole 16-bit kind space in a test rather than kept by hand;RenderableKindsis the 46 of those Amethyst has a card for, plus three that match oncontentalone, and a test pins the rest so a new searchable kind in quartz arrives as a decision.The audit that produced it found fifteen searchable, renderable kinds the search never asked for — pictures, all four video kinds, workouts, git repos, sites, napplets, meetings, calendar events, software apps. It also found that quartz's own golden test was pinning 126 kinds when 142 were searchable, and that twelve event classes (
FeedDefinitionEventamong them) are never registered inEventFactory, so their events parse as plainEventand nothing ever indexes them. That last one is a quartz bug, filed here rather than fixed: it is not search's to make. -
One debounce policy, stated once. (Done — two windows on
SearchState, named for what they protect: 100 ms before a cache scan, 300 ms before a REQ. Eight collectors had been declaring the 100 separately.)
Size: ~400 lines moved/added, ~200 deleted. No module boundaries crossed. Risk: low. Both callers keep their current shape.
Phase 2 — one state holder (done, by order (b))
- The five search entry points on
ICacheProvider. (Done.) They default to returning nothing rather than being abstract: Desktop's cache holds notes and live channels but no public-chat or ephemeral store, and a port that forced it to implement those would be asking it to lie.CacheSearchnow takesLiveHiddenUsersrather than theHiddenUsersStateholder, which also fixedfindNotesStartingWithre-reading.flow.valuefive times down one scan. SearchStatein commons. (Done.)SearchBarViewModel615 → 508 lines;AdvancedSearchBarStatedelegates its text, parse, debounce and sort orders.SearchInputcarries the text and its parse as one value, so a collector cannot pair one keystroke's characters with another's parse — and carriesnameTerms, the most-repeated parse of all.- One scope table. (Done —
SearchScope.shows(SearchResultKind).) The seven guards disagreed: the note flow let hashtags through under Notes, the channel flows did not, and nothing said which was intended. The pinned test is written from the old guards, not the new enum. - History and saved searches shared. (Done, and Android has both now.)
SearchHistory+ a two-methodSearchHistoryStorage; Desktop keeps itsPreferencesnode, Android gets a DataStore file and a recent-searches list in what used to be a blank screen.
Two bugs surfaced only once the behaviour was under test: a saved search whose label contained a tab lost the query it named, and re-running a query typed in a different token order made a second history entry.
Risk realised: none observed. Both front ends compile and the parity test below passes; the behaviour changes are the ones named above.
6. Non-goals
- Pagination past the 100/200 cap. Independent; do it separately.
- Changing the token language, the chip rendering, or
Route.Searchseeding. - Touching
CacheSearch's matching semantics. It is correct and tested; it just is not reachable fromcommons. - Unifying the two search screens. Android's list and desktop's spotlight are legitimately different UIs over the same state.
7. Risks
| Risk | Mitigation |
|---|---|
| Phase 2 silently changes what a query returns | Phase 1 first, so a shared pipeline is under test before state moves |
| Conflicts with the migration sweep | §3 — extend the sweep's own ICacheProvider, never a parallel port |
| Android's 7 result types bloat a shared API | Expose a result map; front ends render what they recognise |
| Scope semantics shift when guards collapse | Pin current behaviour per scope × result-type as a table test before touching it |
8. Test strategy
The pure layer is already well covered (SearchFilterBuilderTest,
QueryParserTest, QuerySerializerTest, SearchResultFilterTest,
SearchResultSorterTest, SearchSeedTest, SearchTokenizerTest). Add before
refactoring, not after:
- A parity table: one query × both front ends → same filters. (Done:
desktopApp'sSearchFilterParityTest, 16 query shapes. It lives there because that is the only module that can see both paths.) This is the test that would have caught all four bugs. - Scope × result-type: the 7 guards, pinned. (Done:
SearchScopeTest.) - Kind-set parity: relay allowlist == local allowlist, and every kind in it
is one
SearchableEventcovers. (Done:RenderableKindsTest.) - Also added:
SearchPipelineTest(one case per shipped bug),SearchStateTest,SearchHistoryTest, and quartz'sSearchableKindsTest.
9. Questions, and what shipped
- Sequencing — (a) or (b)? (b). The five entry points are additive to the
sweep rather than parallel to it, and they survive the
LocalCachemove unchanged: when step 3 lands,DesktopLocalCachestarts answering the same methods instead of inheriting the empty defaults, and nothing above the port changes. - Delete
AdvancedSearchBarStateor keep it? Kept, as Desktop's shell. What is left in it is genuinely Desktop's — relay callbacks, rawEventresults, per-relay sync status, the form panel — so deleting it would only move that code, not remove it. It is still incommons/while being desktop-only, which remains a naming problem worth fixing on its own. - Is Android's 7-result-type surface wanted on Desktop? Left open — it is a
product question, not a structural one.
SearchResultKindnow names the seven in commons, so the answer can be acted on without another refactor.
10. What is left
- Pagination past the 100/200 cap (§6, still a non-goal here).
FeedDefinitionEventand eleven other event classes are missing fromEventFactory, so their events parse as a plainEventand nothing indexes them. A quartz bug, found by the kind sweep, not fixed here..claude/skills/searchable-events/references/searchable-kinds.mdis stale (133 kinds recorded, 142 reachable) and now has a machine-checked list to be reconciled against.