Files
buzz/crates/buzz-search/src/query.rs
tlongwell-block 38708b84ad rewrite(search): ChannelScope enum closes ChannelLessOnly fence hole
The legacy 2x2 `(channel_ids: Option<Vec<Uuid>>, include_channel_less: bool)` shape could not unambiguously express "channel-less events only" — both `Some(vec![]) + true` and `None + true` fell into the no-constraint branch, silently broadening to all community channels rather than restricting to `channel_id IS NULL`. That matched the legacy Typesense `channel_id:=__global__` sentinel one way (per-channel + global) but not the other (global only).

Replace with a single `ChannelScope` enum whose four variants are 1-to-1 with the legacy `(accessible_channels, include_global)` matrix:

  - non-empty + true  -> ChannelsOrChannelLess(accessible)
  - non-empty + false -> Channels(accessible)
  - empty     + true  -> ChannelLessOnly  (the variant the old shape could not express)
  - empty     + false -> caller short-circuits to EOSE, doesn't call search

Emitted SQL fragments are byte-identical to the legacy match for the three carry-over cases; `ChannelLessOnly` adds `AND channel_id IS NULL` — the fence the old type could not express.

Verification:
- Full package `cargo test -p buzz-search -- --include-ignored --test-threads=1`: 9/9 green (8 existing + 1 new `channel_less_only_excludes_per_channel_events`).
- Adversarial mutation: replaced the `ChannelLessOnly` SQL emission with a no-op (the buggy semantic the old shape produced); new test went RED with 3 hits instead of 1, restored, green again. The fix is the emitted predicate, not the variant name.
- clippy -D warnings clean; fmt clean.
- Empty-vec edge cases are intentionally not special-cased: `Channels(vec![])` emits `channel_id = ANY('{}')` (false-for-all, zero hits, preserves the old early-return semantic via SQL); `ChannelsOrChannelLess(vec![])` is equivalent to `ChannelLessOnly`.

Coordinated with Eva ahead of relay-wiring sweep at req.rs and bridge.rs so call sites land against the final type, not the buggy one.

(cherry picked from commit c8cd333e33)

Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-06-26 20:36:59 -04:00

244 lines
8.8 KiB
Rust

//! NIP-50 search query against Postgres FTS, community-scoped.
//!
//! The relay never trusts a hit by itself: this layer returns canonical event
//! ids ordered by relevance, the relay refetches `StoredEvent`s through
//! buzz-db's `(community_id, event_id)` scoped fetcher, and runs the access
//! predicate (`search_hit_accepted` in `bridge.rs`) per hit. Search is never
//! the access boundary — it cannot widen visibility.
//!
//! See conformance row 50.
use buzz_core::CommunityId;
use sqlx::{PgPool, QueryBuilder, Row};
use uuid::Uuid;
use crate::error::SearchError;
/// Channel-scope filter for a community-scoped FTS query.
///
/// Four variants, 1-to-1 with the legacy `(accessible_channels: &[Uuid],
/// include_global: bool)` matrix from the Typesense relay:
///
/// | accessible | include_global | `ChannelScope` |
/// |---|---|---|
/// | non-empty | true | `ChannelsOrChannelLess(accessible)` |
/// | non-empty | false | `Channels(accessible)` |
/// | empty | true | `ChannelLessOnly` |
/// | empty | false | (don't call — caller short-circuits to EOSE) |
///
/// `ChannelLessOnly` is the variant that the old `Option<Vec<Uuid>>` +
/// `bool` 2x2 could not express unambiguously: with empty accessible
/// channels and `include_global=true`, both `Some(vec![]) + true` and
/// `None + true` would broaden to all community channels rather than
/// restrict to channel-less events. The enum closes that hole at the
/// type level.
///
/// Empty-vec edge cases are intentionally not special-cased:
/// `Channels(vec![])` emits `channel_id = ANY('{}')` which Postgres
/// evaluates as false-for-all-rows (zero hits), and
/// `ChannelsOrChannelLess(vec![])` emits `(channel_id = ANY('{}') OR
/// channel_id IS NULL)` which is equivalent to `ChannelLessOnly`.
#[derive(Debug, Clone)]
pub enum ChannelScope {
/// No channel constraint. Matches every event in the community.
Any,
/// Restrict to `channel_id IS NULL` events only — what the legacy
/// Typesense `channel_id:=__global__` sentinel meant.
ChannelLessOnly,
/// Restrict to events whose `channel_id` is in this list.
Channels(Vec<Uuid>),
/// Restrict to events whose `channel_id` is in this list, OR are
/// channel-less (`channel_id IS NULL`).
ChannelsOrChannelLess(Vec<Uuid>),
}
/// A community-scoped FTS query.
///
/// The community is REQUIRED at the type level — there is no construction path
/// that omits it. This is the search-side expression of conformance row zero:
/// every search call carries the server-resolved tenant, never client input.
#[derive(Debug, Clone)]
pub struct SearchQuery {
/// Server-resolved community. Required.
pub community: CommunityId,
/// NIP-50 search text. Empty string is rejected by `search()` early
/// (no hits, no SQL roundtrip).
pub q: String,
/// How to scope hits by channel. See [`ChannelScope`] — the four variants
/// are 1-to-1 with the legacy `(accessible_channels, include_global)`
/// matrix, and `ChannelLessOnly` closes the gap where "empty accessible
/// channels + include global" used to silently broaden to all channels.
pub channel_scope: ChannelScope,
/// NIP-01 kinds filter. None = no kind constraint.
pub kinds: Option<Vec<i32>>,
/// NIP-01 authors filter (32-byte pubkeys). None = no author constraint.
pub authors: Option<Vec<Vec<u8>>>,
/// NIP-01 since (Unix seconds). Inclusive lower bound on created_at.
pub since: Option<i64>,
/// NIP-01 until (Unix seconds). Inclusive upper bound on created_at.
pub until: Option<i64>,
/// 1-indexed page number.
pub page: u32,
/// Page size. Clamped at 500 internally.
pub per_page: u32,
}
/// A single FTS hit. The relay refetches the canonical `StoredEvent` and
/// re-authorizes; this struct is just enough to drive that fetch and preserve
/// relevance ordering.
#[derive(Debug, Clone)]
pub struct SearchHit {
/// 32-byte event id.
pub event_id: [u8; 32],
/// Nostr kind.
pub kind: i32,
/// 32-byte pubkey of author.
pub pubkey: [u8; 32],
/// Optional channel UUID. `None` = channel-less event.
pub channel_id: Option<Uuid>,
/// Unix seconds.
pub created_at: i64,
/// `ts_rank_cd` relevance score (higher = better).
pub rank: f32,
}
/// Result of a search.
#[derive(Debug, Clone)]
pub struct SearchResult {
/// Hits on this page, ordered by relevance then created_at desc.
pub hits: Vec<SearchHit>,
/// 1-indexed page returned.
pub page: u32,
}
const PER_PAGE_MAX: u32 = 500;
const PER_PAGE_DEFAULT: u32 = 100;
/// Execute a community-scoped FTS query.
///
/// SQL shape (always):
/// ```sql
/// SELECT id, kind, pubkey, channel_id, EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s,
/// ts_rank_cd(search_tsv, query) AS rank
/// FROM events,
/// websearch_to_tsquery('simple', $q) AS query
/// WHERE community_id = $ctx
/// AND deleted_at IS NULL
/// AND search_tsv @@ query
/// [+ channel scope, kinds, authors, since, until]
/// ORDER BY rank DESC, created_at DESC, id
/// LIMIT $per_page OFFSET (($page - 1) * $per_page)
/// ```
///
/// `community_id = $ctx` is the first predicate and is non-negotiable. There
/// is no code path through this function that omits it.
pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult, SearchError> {
let trimmed = query.q.trim();
if trimmed.is_empty() {
return Ok(SearchResult {
hits: Vec::new(),
page: query.page.max(1),
});
}
let per_page = query.per_page.clamp(1, PER_PAGE_MAX);
let per_page_actual = if query.per_page == 0 {
PER_PAGE_DEFAULT
} else {
per_page
};
let page = query.page.max(1);
let offset = ((page - 1) as i64) * (per_page_actual as i64);
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"SELECT id, kind, pubkey, channel_id, \
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s, \
ts_rank_cd(search_tsv, query) AS rank \
FROM events, websearch_to_tsquery('simple', ",
);
qb.push_bind(trimmed);
qb.push(") AS query WHERE community_id = ");
qb.push_bind(*query.community.as_uuid());
qb.push(" AND deleted_at IS NULL AND search_tsv @@ query");
// Channel scope — see `ChannelScope` doc for the four-case mapping. The
// emitted SQL fragments are identical to the legacy 2x2 tuple for the
// three carry-over cases; `ChannelLessOnly` is the new fence that the
// old shape could not express.
match &query.channel_scope {
ChannelScope::Any => {
// No channel constraint.
}
ChannelScope::ChannelLessOnly => {
qb.push(" AND channel_id IS NULL");
}
ChannelScope::Channels(ids) => {
qb.push(" AND channel_id = ANY(");
qb.push_bind(ids.clone());
qb.push(")");
}
ChannelScope::ChannelsOrChannelLess(ids) => {
qb.push(" AND (channel_id = ANY(");
qb.push_bind(ids.clone());
qb.push(") OR channel_id IS NULL)");
}
}
if let Some(ref kinds) = query.kinds {
if !kinds.is_empty() {
qb.push(" AND kind = ANY(");
qb.push_bind(kinds.clone());
qb.push(")");
}
}
if let Some(ref authors) = query.authors {
if !authors.is_empty() {
qb.push(" AND pubkey = ANY(");
qb.push_bind(authors.clone());
qb.push(")");
}
}
if let Some(since) = query.since {
qb.push(" AND created_at >= to_timestamp(");
qb.push_bind(since);
qb.push(")");
}
if let Some(until) = query.until {
qb.push(" AND created_at <= to_timestamp(");
qb.push_bind(until);
qb.push(")");
}
qb.push(" ORDER BY rank DESC, created_at DESC, id LIMIT ");
qb.push_bind(per_page_actual as i64);
qb.push(" OFFSET ");
qb.push_bind(offset);
let rows = qb.build().fetch_all(pool).await?;
let mut hits = Vec::with_capacity(rows.len());
for row in rows {
let id_bytes: Vec<u8> = row.try_get("id")?;
let pk_bytes: Vec<u8> = row.try_get("pubkey")?;
let id: [u8; 32] = id_bytes.try_into().map_err(|v: Vec<u8>| {
sqlx::Error::Decode(format!("event id column is {} bytes, expected 32", v.len()).into())
})?;
let pubkey: [u8; 32] = pk_bytes.try_into().map_err(|v: Vec<u8>| {
sqlx::Error::Decode(format!("pubkey column is {} bytes, expected 32", v.len()).into())
})?;
hits.push(SearchHit {
event_id: id,
kind: row.try_get("kind")?,
pubkey,
channel_id: row.try_get("channel_id")?,
created_at: row.try_get("created_at_s")?,
rank: row.try_get("rank")?,
});
}
Ok(SearchResult { hits, page })
}