Files
buzz/crates/buzz-search/src/lib.rs
Kalvin C 3d26ed7dfd feat(relay): match verified names in profile search (#9019)
## Summary

`POST /profiles/search` only matches kind:0 `name` and `display_name`.
In communities that publish NIP-FI verified profiles, a person's surname
or directory username often appears only in their relay-signed
kind:30382, so typeahead cannot find them by it.

- Where the community advertises verified profiles, the search also
matches the `display_name`, `username` and `full_name` tags of each
current kind:30382 head signed by the relay key.
- Verified names need a trailing prefix of at least three characters, as
in NIP-50, so a short prefix doesn't make the relay rank every verified
head. kind:0 keeps one-character typeahead. Lookups from heads to
`events` also match `created_at`, so Postgres prunes to one partition.
- Results are paged by person. Each person returns their current kind:0,
when they have one, then their current kind:30382, when they have one,
whichever matched. A person is ranked by their best match across both
sources, using the existing tiers.
- NIP-11 `federated_identity.public_profile` gains `searchable: true`,
so clients can rely on the response instead of a separate kind:30382
read.
- Migration `0081` adds a GIN expression index over the head's name tags
on `nip_fi_profile_heads` (built-in `jsonb_path_query_first` and
`to_tsvector`, no new functions). The table holds one row per verified
person. Like `0078`, the migration builds the index only while the table
is empty; populated databases prebuild it with `CREATE INDEX
CONCURRENTLY` (`docs/profile-search.md`), and the migration then checks
it is valid and has the expected definition.

Compatibility: the request schema is unchanged. Existing clients that
keep only kind:0 events work as before and also find people who match
only by verified name. Trust stays pinned to the relay `self` key; the
event shape follows NIP-85 user assertions, but discovery is
relay-advertised rather than kind:10040.

## Testing

- `buzz-search` Postgres integration: new
`profile_names_match_verified_names` covers forged-key heads, replaced
heads, other communities, people without kind:0, per-person grouping,
paging, and words split across sources; the existing profile-name tests
pass unchanged. The test fails when the author filter is inverted.
- `buzz-relay` end to end in enforce mode:
`profile_search_matches_verified_names_where_advertised` publishes
through the real writer and finds the person by surname only where
verified profiles are advertised.
- Desired-state and migration index definitions match.
- `profile_names_use_verified_name_index` asserts the search scans the
new index. Migration `0081` checked against an empty table, a rerun, a
populated table without the index (raises), the documented concurrent
prebuild, and an index with a different definition (raises).
- Latency, 50k people each with a kind:0 and a verified head, release
build, local Postgres, best of 5, 30 people per page: 11–22 ms with
verified names versus 1–8 ms kind:0 only. `EXPLAIN` shows a bitmap scan
on the new index.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:25:15 -05:00

85 lines
3.1 KiB
Rust

#![deny(unsafe_code)]
#![warn(missing_docs)]
//! Buzz search — community-scoped Postgres full-text search over Buzz events.
//!
//! The index lives in the `events` table: `search_tsv TSVECTOR GENERATED
//! ALWAYS AS (to_tsvector('simple', content)) STORED`, with `GIN
//! (search_tsv)` as the access path. Because the column is `GENERATED ALWAYS`,
//! every row write *is* the index update — there is no separate indexer, no
//! mpsc queue, no reindex job, no consistency window to reason about. A
//! client cannot forge the tsvector out of sync with the content it signed.
//!
//! This crate is the **query** side. Indexing is the SQL row insert — owned
//! by `buzz-db`. The relay refetches canonical events through `buzz-db`'s
//! scoped fetcher and runs access checks per hit; search is never the access
//! boundary (conformance row 50).
//!
//! ## Multi-tenant fence
//!
//! Every [`SearchQuery`] carries a [`CommunityId`]. There is no construction
//! path through this crate that omits it, and every SQL execution binds
//! `community_id = $ctx` as the first predicate. A query bound to community
//! A cannot return events stored under community B, by construction.
#[doc(hidden)]
pub mod cancel;
/// Search error types.
pub mod error;
/// Search query execution.
pub mod query;
pub use buzz_core::CommunityId;
pub use error::SearchError;
pub use query::{
search, search_profile_names, ChannelScope, SearchHit, SearchMode, SearchQuery, SearchResult,
SearchSort, PROFILE_NAMES_PER_PAGE_MAX,
};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
/// Options for the pool behind [`SearchService`]: every new connection's
/// session `statement_timeout` is lowered to buzz-db's event-read deadline
/// ([`buzz_db::event::READ_STATEMENT_TIMEOUT`]) unless it is already
/// tighter. Searches cancel their statement when abandoned (see
/// [`cancel`]); this bounds the ones a cancel cannot reach.
pub fn pool_options() -> PgPoolOptions {
PgPoolOptions::new().after_connect(|conn, _meta| {
Box::pin(buzz_db::event::tighten_session_read_statement_timeout(conn))
})
}
/// Thin handle around a `PgPool` for community-scoped FTS.
///
/// Holds nothing the pool itself doesn't already own. The whole purpose of
/// this type is a stable injection point for the relay's `AppState`.
#[derive(Debug, Clone)]
pub struct SearchService {
pool: PgPool,
}
impl SearchService {
/// Build a search service over an existing Postgres pool.
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
/// Execute a community-scoped FTS query.
pub async fn search(&self, query: &SearchQuery) -> Result<SearchResult, SearchError> {
query::search(&self.pool, query).await
}
/// Find people by profile name, and by verified profile names signed by
/// `verified_by`; see [`search_profile_names`].
pub async fn search_profile_names(
&self,
community: CommunityId,
q: &str,
verified_by: Option<[u8; 32]>,
page: u32,
per_page: u32,
) -> Result<SearchResult, SearchError> {
query::search_profile_names(&self.pool, community, q, verified_by, page, per_page).await
}
}