mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
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:
parent
1b53c2ffcb
commit
a0f8b9a2b1
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user