feat(search): restore topbar prefix matching

Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
2026-06-28 13:31:43 -04:00
parent 1b53c2ffcb
commit a0f8b9a2b1
6 changed files with 388 additions and 20 deletions

View File

@@ -218,6 +218,17 @@ fn extract_feed_types(raw: &Value) -> Option<Vec<String>> {
}
}
fn extract_search_mode(raw: &Value) -> buzz_search::SearchMode {
match raw
.get("search_mode")
.or_else(|| raw.get("searchMode"))
.and_then(Value::as_str)
{
Some("prefix") => buzz_search::SearchMode::Prefix,
_ => buzz_search::SearchMode::FullText,
}
}
fn event_in_accessible_channel(se: &buzz_core::StoredEvent, accessible: &[uuid::Uuid]) -> bool {
match se.channel_id {
Some(ch_id) => accessible.contains(&ch_id),
@@ -410,6 +421,7 @@ pub async fn query_events(
}
return handle_bridge_search(
&state,
&raw_filters,
&filters,
&accessible_channels,
&tenant,
@@ -846,6 +858,7 @@ fn search_hit_accepted(
/// from DB. Returns first page of results (no pagination for bridge MVP).
async fn handle_bridge_search(
state: &AppState,
raw_filters: &[Value],
filters: &[nostr::Filter],
accessible_channels: &[uuid::Uuid],
tenant: &buzz_core::tenant::TenantContext,
@@ -866,7 +879,8 @@ async fn handle_bridge_search(
let mut events: Vec<Value> = Vec::new();
let mut seen_ids: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
for filter in filters {
for (raw, filter) in raw_filters.iter().zip(filters) {
let search_mode = extract_search_mode(raw);
let search_text = match &filter.search {
Some(s) if !s.is_empty() => s.clone(),
_ => continue,
@@ -922,6 +936,7 @@ async fn handle_bridge_search(
until,
page: 1,
per_page: limit,
mode: search_mode,
};
let search_result = state
@@ -1268,6 +1283,30 @@ mod tests {
assert!(!has_mixed_search_filters(&filters));
}
#[test]
fn bridge_search_mode_extension_defaults_to_full_text() {
assert_eq!(
extract_search_mode(&serde_json::json!({ "search": "pro" })),
buzz_search::SearchMode::FullText
);
assert_eq!(
extract_search_mode(&serde_json::json!({ "search": "pro", "search_mode": "word" })),
buzz_search::SearchMode::FullText
);
}
#[test]
fn bridge_search_mode_extension_accepts_prefix_snake_or_camel_case() {
assert_eq!(
extract_search_mode(&serde_json::json!({ "search": "pro", "search_mode": "prefix" })),
buzz_search::SearchMode::Prefix
);
assert_eq!(
extract_search_mode(&serde_json::json!({ "search": "pro", "searchMode": "prefix" })),
buzz_search::SearchMode::Prefix
);
}
/// Attack 3 proof: two stateless relay pods sharing Redis must share one
/// community-scoped NIP-98 seen-set. Pod A's first claim succeeds; pod B's
/// replay of the same event id in the same community is rejected. The same

View File

@@ -555,6 +555,7 @@ async fn handle_search_req(
until,
page,
per_page,
mode: buzz_search::SearchMode::FullText,
};
let search_result = match state.search.search(&search_query).await {

View File

@@ -28,7 +28,7 @@ pub mod query;
pub use buzz_core::CommunityId;
pub use error::SearchError;
pub use query::{search, ChannelScope, SearchHit, SearchQuery, SearchResult};
pub use query::{search, ChannelScope, SearchHit, SearchMode, SearchQuery, SearchResult};
use sqlx::PgPool;

View File

@@ -52,6 +52,19 @@ pub enum ChannelScope {
ChannelsOrChannelLess(Vec<Uuid>),
}
/// Search matching semantics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchMode {
/// Standard NIP-50-ish word/lexeme search using `websearch_to_tsquery`.
FullText,
/// Prefix-match the trailing normalized query token (`pro` matches `project`).
///
/// Intended for bounded typeahead surfaces such as the desktop topbar. The
/// relay still refetches and re-authorizes every hit; this mode changes only
/// the candidate tsquery, not the access boundary.
Prefix,
}
/// A community-scoped FTS query.
///
/// The community is REQUIRED at the type level — there is no construction path
@@ -81,6 +94,8 @@ pub struct SearchQuery {
pub page: u32,
/// Page size. Clamped at 500 internally.
pub per_page: u32,
/// Matching semantics for the search text.
pub mode: SearchMode,
}
/// A single FTS hit. The relay refetches the canonical `StoredEvent` and
@@ -113,7 +128,7 @@ pub struct SearchResult {
const PER_PAGE_MAX: u32 = 500;
const PER_PAGE_DEFAULT: u32 = 100;
/// Hard cap on search text handed to `websearch_to_tsquery`. This keeps a
/// Hard cap on search text handed to Postgres text-search parsers. This keeps a
/// single request from spending unbounded parser CPU/memory while still allowing
/// far longer queries than the desktop UI normally emits.
const SEARCH_TEXT_MAX_CHARS: usize = 4096;
@@ -122,6 +137,45 @@ const SEARCH_TEXT_MAX_CHARS: usize = 4096;
/// wire untrusted input into a multi-trillion-row OFFSET.
const PAGE_MAX: u32 = 1000;
fn push_tsquery(qb: &mut QueryBuilder<sqlx::Postgres>, mode: SearchMode, search_text: &str) {
match mode {
SearchMode::FullText => {
qb.push("websearch_to_tsquery('simple', ");
qb.push_bind(search_text);
qb.push(")");
}
SearchMode::Prefix => {
// Prefix mode is for typeahead: completed whitespace-delimited tokens
// are exact, and only the trailing token is suffixed with `:*`.
// Each raw token still goes through Postgres' `simple` parser before
// tsquery construction so query-side normalization matches the
// `search_tsv` generated column. `quote_literal` prevents tsquery
// syntax injection from punctuation/operators in the raw topbar input.
qb.push(
"(SELECT COALESCE(\
string_agg(\
quote_literal(lexeme) || CASE WHEN token_ord = max_token_ord THEN ':*' ELSE '' END, \
' & ' ORDER BY token_ord, lex_ord\
), \
''\
)::tsquery \
FROM (\
SELECT raw_token.token_ord, normalized.lexeme, normalized.lex_ord, raw_token.max_token_ord \
FROM (\
SELECT token, token_ord, max(token_ord) OVER () AS max_token_ord \
FROM regexp_split_to_table(",
);
qb.push_bind(search_text);
qb.push(
", '\\s+') WITH ORDINALITY AS split(token, token_ord)\
) AS raw_token \
CROSS JOIN LATERAL unnest(tsvector_to_array(to_tsvector('simple', raw_token.token))) \
WITH ORDINALITY AS normalized(lexeme, lex_ord)\
) AS prefix_terms)",
);
}
}
}
fn normalized_search_text(q: &str) -> Option<String> {
let trimmed = q.trim();
if trimmed.is_empty() {
@@ -148,7 +202,7 @@ fn normalized_search_text(q: &str) -> Option<String> {
/// 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
/// <mode-specific tsquery> AS query
/// WHERE community_id = $ctx
/// AND deleted_at IS NULL
/// AND search_tsv @@ query
@@ -179,13 +233,13 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult,
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', ",
ts_rank_cd(search_tsv, search_query.query) AS rank \
FROM events CROSS JOIN LATERAL (SELECT ",
);
qb.push_bind(&search_text);
qb.push(") AS query WHERE community_id = ");
push_tsquery(&mut qb, query.mode, &search_text);
qb.push(" AS query) AS search_query WHERE community_id = ");
qb.push_bind(*query.community.as_uuid());
qb.push(" AND deleted_at IS NULL AND search_tsv @@ query");
qb.push(" AND deleted_at IS NULL AND search_tsv @@ search_query.query");
// Channel scope — see `ChannelScope` doc for the four-case mapping. The
// emitted SQL fragments are identical to the legacy 2x2 tuple for the

View File

@@ -149,6 +149,7 @@ async fn search_finds_event_in_same_community() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.expect("search ok");
@@ -196,6 +197,7 @@ async fn search_does_not_return_other_community_events() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.unwrap();
@@ -212,6 +214,7 @@ async fn search_does_not_return_other_community_events() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.unwrap();
@@ -255,6 +258,7 @@ async fn kind0_search_by_display_name_works_without_flattening() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.unwrap();
@@ -264,6 +268,241 @@ async fn kind0_search_by_display_name_works_without_flattening() {
teardown(pool, &schema).await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn prefix_mode_matches_final_token_prefix_without_changing_full_text() {
let (pool, schema) = setup().await;
let c = mk_community(&pool, "prefix.example").await;
let project_plan = rand_bytes32();
let project_archive = rand_bytes32();
let projectile_plan = rand_bytes32();
insert_event(
&pool,
c,
project_plan,
rand_bytes32(),
9,
"project planning milestone",
None,
1_700_000_000,
)
.await;
insert_event(
&pool,
c,
project_archive,
rand_bytes32(),
9,
"project archive",
None,
1_700_000_001,
)
.await;
insert_event(
&pool,
c,
projectile_plan,
rand_bytes32(),
9,
"projectile planning distractor",
None,
1_700_000_002,
)
.await;
let svc = SearchService::new(pool.clone());
let full_text = svc
.search(&SearchQuery {
community: c,
q: "pro".into(),
channel_scope: ChannelScope::Any,
kinds: Some(vec![9]),
authors: None,
since: None,
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.expect("full text search ok");
assert!(
full_text.hits.is_empty(),
"plain full-text search must stay word/lexeme-based, got {:?}",
full_text
.hits
.iter()
.map(|h| h.event_id)
.collect::<Vec<_>>()
);
let prefix = svc
.search(&SearchQuery {
community: c,
q: "pro".into(),
channel_scope: ChannelScope::Any,
kinds: Some(vec![9]),
authors: None,
since: None,
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::Prefix,
})
.await
.expect("prefix search ok");
let prefix_ids: Vec<[u8; 32]> = prefix.hits.iter().map(|h| h.event_id).collect();
assert!(prefix_ids.contains(&project_plan));
assert!(prefix_ids.contains(&project_archive));
let multi_token_prefix = svc
.search(&SearchQuery {
community: c,
q: "project pla".into(),
channel_scope: ChannelScope::Any,
kinds: Some(vec![9]),
authors: None,
since: None,
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::Prefix,
})
.await
.expect("multi-token prefix search ok");
assert_eq!(multi_token_prefix.hits.len(), 1);
assert_eq!(multi_token_prefix.hits[0].event_id, project_plan);
let completed_token_is_exact = svc
.search(&SearchQuery {
community: c,
q: "project pl".into(),
channel_scope: ChannelScope::Any,
kinds: Some(vec![9]),
authors: None,
since: None,
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::Prefix,
})
.await
.expect("completed-token exact + trailing-prefix search ok");
let completed_ids: Vec<[u8; 32]> = completed_token_is_exact
.hits
.iter()
.map(|h| h.event_id)
.collect();
assert!(completed_ids.contains(&project_plan));
assert!(
!completed_ids.contains(&projectile_plan),
"completed tokens must stay exact; only the trailing token gets :*"
);
teardown(pool, &schema).await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn prefix_mode_handles_tsquery_boundary_punctuation() {
let (pool, schema) = setup().await;
let c = mk_community(&pool, "prefix-punctuation.example").await;
let hit_id = rand_bytes32();
insert_event(
&pool,
c,
hit_id,
rand_bytes32(),
9,
"operators ' : & | ( ) ! alpha beta marker",
None,
1_700_000_000,
)
.await;
let svc = SearchService::new(pool.clone());
let result = svc
.search(&SearchQuery {
community: c,
q: "operators ' : & | ( ) ! alpha be".into(),
channel_scope: ChannelScope::Any,
kinds: Some(vec![9]),
authors: None,
since: None,
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::Prefix,
})
.await
.expect("prefix punctuation search ok");
assert_eq!(result.hits.len(), 1);
assert_eq!(result.hits[0].event_id, hit_id);
teardown(pool, &schema).await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn prefix_mode_preserves_storage_level_privacy_exclusions() {
let (pool, schema) = setup().await;
let c = mk_community(&pool, "prefix-privacy.example").await;
let token = "prefixprivacy_unique_marker";
let control_id = rand_bytes32();
insert_event(
&pool,
c,
control_id,
rand_bytes32(),
9,
&format!("public control {token}"),
None,
1_700_000_000,
)
.await;
for (i, &kind) in [1059_u32, 30300, 30622, 44100, 44101].iter().enumerate() {
insert_event(
&pool,
c,
rand_bytes32(),
rand_bytes32(),
kind as i32,
&format!("private kind {kind} {token}"),
None,
1_700_000_100 + i as i64,
)
.await;
}
let svc = SearchService::new(pool.clone());
let result = svc
.search(&SearchQuery {
community: c,
q: "prefixpriv".into(),
channel_scope: ChannelScope::Any,
kinds: None,
authors: None,
since: None,
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::Prefix,
})
.await
.expect("prefix privacy search ok");
assert_eq!(result.hits.len(), 1);
assert_eq!(result.hits[0].event_id, control_id);
teardown(pool, &schema).await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn channel_scope_restricts_results() {
@@ -333,6 +572,7 @@ async fn channel_scope_restricts_results() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.unwrap();
@@ -351,6 +591,7 @@ async fn channel_scope_restricts_results() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.unwrap();
@@ -368,6 +609,7 @@ async fn channel_scope_restricts_results() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.unwrap();
@@ -385,6 +627,7 @@ async fn channel_scope_restricts_results() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.unwrap();
@@ -423,6 +666,7 @@ async fn deleted_events_are_excluded() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.unwrap();
@@ -453,6 +697,7 @@ async fn empty_query_returns_empty_result_no_roundtrip() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.unwrap();
@@ -519,6 +764,7 @@ async fn since_until_filters() {
until: Some(1_700_015_000),
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.unwrap();
@@ -562,6 +808,7 @@ async fn pagination_works() {
until: None,
page: 1,
per_page: 3,
mode: buzz_search::SearchMode::FullText,
})
.await
.unwrap();
@@ -578,6 +825,7 @@ async fn pagination_works() {
until: None,
page: 3,
per_page: 3,
mode: buzz_search::SearchMode::FullText,
})
.await
.unwrap();
@@ -669,6 +917,7 @@ async fn channel_less_only_excludes_per_channel_events() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.unwrap();
@@ -716,6 +965,7 @@ async fn nul_bytes_in_query_are_sanitized() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.expect("NUL-containing search query should not bubble a Postgres error");
@@ -758,6 +1008,7 @@ async fn enormous_page_number_is_clamped() {
until: None,
page: u32::MAX,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.expect("huge page number should be bounded, not error");
@@ -786,6 +1037,7 @@ async fn very_long_query_is_bounded_before_pg_parse() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.expect("long search query should be capped before Postgres parses it");
@@ -912,6 +1164,7 @@ async fn excluded_kinds_are_storage_level_unsearchable() {
until: None,
page: 1,
per_page: 10,
mode: buzz_search::SearchMode::FullText,
})
.await
.expect("search ok");
@@ -1006,6 +1259,7 @@ async fn author_only_kinds_are_storage_level_unsearchable() {
until: None,
page: 1,
per_page: 100,
mode: buzz_search::SearchMode::FullText,
})
.await
.expect("search ok");
@@ -1112,6 +1366,7 @@ async fn p_gated_persistent_kinds_have_storage_null_tsvector() {
until: None,
page: 1,
per_page: 100,
mode: buzz_search::SearchMode::FullText,
})
.await
.expect("search ok");

View File

@@ -100,6 +100,24 @@ pub async fn get_feed(
})
}
fn build_search_messages_filter(q: &str, cap: u32, channel_id: Option<&str>) -> serde_json::Value {
let mut filter = serde_json::Map::new();
filter.insert(
"kinds".to_string(),
serde_json::json!([9, 40002, 45001, 45003]),
);
filter.insert("search".to_string(), serde_json::json!(q.trim()));
// The desktop topbar is a typeahead surface. This bridge-only extension is
// consumed before nostr::Filter parsing on the relay, so general WS/NIP-50
// search remains word/lexeme-based.
filter.insert("search_mode".to_string(), serde_json::json!("prefix"));
filter.insert("limit".to_string(), serde_json::json!(cap));
if let Some(cid) = channel_id {
filter.insert("#h".to_string(), serde_json::json!([cid]));
}
serde_json::Value::Object(filter)
}
#[tauri::command]
pub async fn search_messages(
q: String,
@@ -108,18 +126,9 @@ pub async fn search_messages(
state: State<'_, AppState>,
) -> Result<SearchResponse, String> {
let cap = limit.unwrap_or(20).min(100);
let mut filter = serde_json::Map::new();
filter.insert(
"kinds".to_string(),
serde_json::json!([9, 40002, 45001, 45003]),
);
filter.insert("search".to_string(), serde_json::json!(q.trim()));
filter.insert("limit".to_string(), serde_json::json!(cap));
if let Some(cid) = channel_id {
filter.insert("#h".to_string(), serde_json::json!([cid]));
}
let filter = build_search_messages_filter(&q, cap, channel_id.as_deref());
let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?;
let events = query_relay(&state, &[filter]).await?;
Ok(nostr_convert::search_response_from_events(&events))
}
@@ -568,6 +577,16 @@ mod tests {
);
}
#[test]
fn search_messages_filter_requests_prefix_mode_for_topbar_typeahead() {
let filter = build_search_messages_filter(" pro ", 12, Some("channel-1"));
assert_eq!(filter["search"], serde_json::json!("pro"));
assert_eq!(filter["search_mode"], serde_json::json!("prefix"));
assert_eq!(filter["limit"], serde_json::json!(12));
assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
}
#[test]
fn stored_managed_agent_auth_tag_trims_blank_values() {
assert_eq!(