mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
🤖 ## 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>
54 lines
2.4 KiB
Markdown
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`).
|