Files
amethyst/commons/plans/2026-09-10-search-state-unification.md
Claude 1a97982668 test: put the two front ends' REQs side by side
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
2026-09-10 15:41:16 +00:00

247 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, in `commons/`, used **only** by desktop.
- The same query string is parsed ~9× per keystroke (4 direct `QueryParser.parse`
calls plus 5 `plainTerms(…)`, which parses again).
- 10 debounces across 4 windows (100 ms ×8, 300, 400); 8 collectors debounce the
*same* `searchValueFlow` independently.
- 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's `SearchableEvent` — 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 deletes `DesktopLocalCache.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.** `LocalCache` is in `commons`; a single
shared `SearchState` reads 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 methods `CacheSearch` already 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 debounced `StateFlow<SearchQuery>`, one
scope decision, one result set, one `isSearching`. Replaces both
`AdvancedSearchBarState` and the bulk of `SearchBarViewModel`.
- **Platform shells** — `SearchBarViewModel` keeps `LazyListState`,
`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. `SearchState`
should 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)*
1. `SearchPipeline` in `commons/search/`: `parse`, `filters`, `keep`, `rank` as
one object, with the ordering documented and tested.
2. Repoint both `SearchBarViewModel` and `AdvancedSearchBarState` at it. No
behaviour change intended — this is the regression-test bed for Phase 2.
3. **One kind-set source.** *(Done — `RenderableKinds` in `commons/search/`,
checked against `SearchableKinds` in quartz.)* The relay allowlist, desktop's
copy of it, and the local scan's absent window are now one list. Quartz gained
`SearchableKinds.ALL`, the 142 kinds `EventFactory` builds a `SearchableEvent`
for, verified by sweeping the whole 16-bit kind space in a test rather than
kept by hand; `RenderableKinds` is the 46 of those Amethyst has a card
for, plus three that match on `content` alone, 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 (`FeedDefinitionEvent` among them) are never
registered in `EventFactory`, so their events parse as plain `Event` and
nothing ever indexes them. That last one is a quartz bug, filed here rather
than fixed: it is not search's to make.
4. **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))*
5. **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. `CacheSearch` now
takes `LiveHiddenUsers` rather than the `HiddenUsersState` holder, which
also fixed `findNotesStartingWith` re-reading `.flow.value` five times down
one scan.
6. **`SearchState` in commons.** *(Done.)* `SearchBarViewModel` 615 → 508 lines;
`AdvancedSearchBarState` delegates its text, parse, debounce and sort orders.
`SearchInput` carries the text and its parse as one value, so a collector
cannot pair one keystroke's characters with another's parse — and carries
`nameTerms`, the most-repeated parse of all.
7. **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.
8. **History and saved searches shared.** *(Done, and Android has both now.)*
`SearchHistory` + a two-method `SearchHistoryStorage`; Desktop keeps its
`Preferences` node, 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.Search` seeding.
- Touching `CacheSearch`'s matching semantics. It is correct and tested; it just
is not reachable from `commons`.
- 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`'s `SearchFilterParityTest`, 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 `SearchableEvent` covers. *(Done: `RenderableKindsTest`.)*
- Also added: `SearchPipelineTest` (one case per shipped bug), `SearchStateTest`,
`SearchHistoryTest`, and quartz's `SearchableKindsTest`.
## 9. Questions, and what shipped
1. **Sequencing — (a) or (b)?** (b). The five entry points are additive to the
sweep rather than parallel to it, and they survive the `LocalCache` move
unchanged: when step 3 lands, `DesktopLocalCache` starts answering the same
methods instead of inheriting the empty defaults, and nothing above the port
changes.
2. **Delete `AdvancedSearchBarState` or keep it?** Kept, as Desktop's shell. What
is left in it is genuinely Desktop's — relay callbacks, raw `Event` results,
per-relay sync status, the form panel — so deleting it would only move that
code, not remove it. It is still in `commons/` while being desktop-only,
which remains a naming problem worth fixing on its own.
3. **Is Android's 7-result-type surface wanted on Desktop?** Left open — it is a
product question, not a structural one. `SearchResultKind` now 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).
- `FeedDefinitionEvent` and eleven other event classes are missing from
`EventFactory`, so their events parse as a plain `Event` and nothing indexes
them. A quartz bug, found by the kind sweep, not fixed here.
- `.claude/skills/searchable-events/references/searchable-kinds.md` is stale
(133 kinds recorded, 142 reachable) and now has a machine-checked list to be
reconciled against.