mirror of
https://github.com/block/buzz.git
synced 2026-11-12 12:22:06 +00:00
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>
244 lines
8.8 KiB
Rust
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 })
|
|
}
|