mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
## 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>
85 lines
3.1 KiB
Rust
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
|
|
}
|
|
}
|