mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Summary Adds an opt-in newest-first order to bridge search, so clients can offer "Most recent" alongside relevance. - `buzz-search`: `SearchQuery` gains `sort: SearchSort`. `Relevance` keeps the existing `ts_rank_cd` ordering. `Newest` orders hits by `created_at DESC, id`, and `page` steps through that order. Newest skips the exact-profile-lexeme prioritization, which only applies to relevance ranking. - Bridge `POST /query`: search filters accept `search_sort: "newest"`. When the field is missing or has another value, results stay in relevance order. Community scope, channel intersection and per-hit authorization are unchanged. - NIP-11 lists `buzz-search-sort` in `supported_extensions`. Clients only send the field to relays that advertise it, so they never get relevance results labelled as newest. - WebSocket NIP-50 search stays relevance-only. `NOSTR.md` documents the bridge extension. ### Related issue N/A ### Testing - New Postgres integration test `newest_sort_pages_by_time_then_id_within_scope`. It pages a channel in newest order, including an equal-timestamp tie split across a page boundary and a newer hit outside the channel scope. It checks that the pages join up in `(created_at desc, id asc)` order and that relevance still ranks the strongest match first. - Unit tests: - `bridge_search_sort_extension_selects_newest_only_when_named` - `advertises_nip_cl_and_search_sort_extensions` - Ran the buzz-search Postgres integration suite, the relay `nip11` and `bridge_search` unit tests, `cargo fmt --check`, and `cargo clippy -p buzz-search -p buzz-relay --all-targets -D warnings` on Linux. - Local relay exercised through signed `POST /query`: - NIP-11 lists `buzz-search-sort`. - Relevance and newest return different orders. - Newest pages 1–3 return 25 hits with no gaps or duplicates, including same-second ties across page boundaries. - A non-member gets no hits from a private channel; a member gets all of them. - Client integration is in a companion block/buzz-app change. It was exercised end to end against this relay through the production broker: switching sort, paging, scope changes and capability fallback. Deployment note: clients only offer newest sorting after a relay with this change advertises `buzz-search-sort`. --------- Signed-off-by: Kalvin Chau <kalvin@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
57 lines
2.0 KiB
Rust
57 lines
2.0 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.
|
|
|
|
/// 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, ChannelScope, SearchHit, SearchMode, SearchQuery, SearchResult, SearchSort,
|
|
};
|
|
|
|
use sqlx::PgPool;
|
|
|
|
/// 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
|
|
}
|
|
}
|