Files
buzz/docs/profile-search.md
Logan Johnson 7852b1670c fix(search): normalize profile name tiers in one place (#8334)
🤖

## Summary

Profile name search (`POST /profiles/search`, added in #8301) sorts
results into tiers: an exact name first, then a name that starts with
the query, then a name with a later word that starts with it. This PR
stacks on #8301 and fixes two review items.

**Tier normalization in one place.** Before this change, the query was
lower-cased in Rust (`to_lowercase`), and the names were lower-cased in
SQL (`lower()`). These can disagree. Under a `C` collation, PostgreSQL's
`lower()` changes only ASCII letters, while Rust also lower-cases
letters like `É`. So a search for `Émile` did not put the profile named
`Émile` in the exact tier. Now the query and both name fields go through
the same SQL expression: lower-case, collapse whitespace runs to one
space, then trim. The query is bound once and both sides compare equal
by construction. The trim also moved after the collapse, so surrounding
whitespace that the regex turns into a space is now trimmed too.

**Ranking wording.** The doc comment and `docs/profile-search.md` said
"ties keep the NIP-50 order". That was wrong. Ties are ordered by
`ts_rank_cd` over the two name fields only (not the whole profile, as in
NIP-50), then newest first, then event id.

Out of scope, not changed here: matching names that contain escaped
Unicode or newlines, and any new name index.

## Related issue

Review on #8301:
https://github.com/block/buzz/pull/8301#pullrequestreview-5524491230

## Testing

- New case in `profile_names_match_name_words_only`: profiles `Émile`
and a newer `Émilee`, query `Émile`. The exact match must come first.
- Ran the Postgres integration tests against a `postgres:17-alpine`
database created with `--locale=C`:
- this branch: `profile_names_match_name_words_only` and
`profile_names_survive_unparsable_content` pass;
- #8301 code with the new case: `profile_names_match_name_words_only`
fails (`Émilee` sorts before `Émile`).
- Nine other tests in that file fail locally on both this branch and
#8301 because they reuse one database (duplicate community hosts). CI
gives each test its own database.
- `cargo fmt --check`, `cargo clippy -p buzz-search --all-targets -D
warnings`, and `cargo test -p buzz-search`: clean.
- The pre-push hook's `rust-tests` and `desktop-tauri-checks` passed.

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 18:02:10 -04:00

54 lines
2.4 KiB
Markdown

# Profile name search
`POST /profiles/search` finds community members by profile name. It matches
only the `name` and `display_name` string fields of each current global kind:0
profile. It does not search `picture` (including data URLs), `about`, `nip05`,
`banner`, or any other profile field. NIP-50 search over kind:0 matches the
whole profile JSON, so it can return profiles whose avatar data or bio happens
to contain the query text.
Apart from the fields it reads, the endpoint matches and pages like a NIP-50
kind:0 search with `search_mode: "prefix"`. A client can switch from one to the
other without a visible change in which people a query finds.
The endpoint requires NIP-98 authentication over the request body and relay
membership, like other relay-owned member endpoints. A relay without the
endpoint answers `404`; clients fall back to NIP-50 search for that request.
Request:
```json
{ "query": "wes", "limit": 20, "page": 1 }
```
- `query`: 1 through 100 characters after trimming. Required.
- `limit`: results per page, 1 through 100. Defaults to 20.
- `page`: 1-based page of `limit` results, 1 through 1000. Defaults to 1.
- Unknown fields are rejected with `400`.
Response: a JSON array of signed kind:0 events for that page, best match
first. A page shorter than `limit` is the last page.
Matching is the NIP-50 prefix rule, case-insensitive. The query is split on
whitespace. Every word except the last must equal a word of `name` or
`display_name`; the last word may be a word prefix. Words may appear in any
order and may be split across the two fields, so "johnson wes" finds
"Wes Johnson". Accents are not folded.
Results are ordered:
1. `name` or `display_name` equal to the query;
2. a name that starts with the query;
3. a name with a later word that starts with the query;
4. any other match.
For these tiers the query and both names are lower-cased, with whitespace
runs collapsed to one space and trimmed, by the same database expression.
Ties go by relevance of the query to the two names alone (not the whole
profile, as in NIP-50), then newest first, then event id, so pages do not
overlap. Profile content that is not a JSON object, or a name field that
is not a string, never matches. Content that PostgreSQL cannot read as `jsonb`
(for example a `\u0000` escape or very deep nesting) has no names, so it
never matches and never makes the search fail
(`schema/functions/profile_name_fields.sql`).