diff --git a/Justfile b/Justfile index 4dd4b8eb8b..984fa7bdc9 100644 --- a/Justfile +++ b/Justfile @@ -531,7 +531,7 @@ test-unit: # are infra-free; config parses env into a struct and never connects. # `^config::` is anchored so it does not also select nip_fi_config. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::system_channels::tests::/) + test(/^nip11::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds) + test(=api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case) + test(=api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' # Note on audio::join::tests scope: the full suite is infra-free (no DB, # no Redis). The infra-free audio/FI regression witnesses — bootstrap # ordering barrier, CommitConfirmed arm, pending-close invisibility, diff --git a/NOSTR.md b/NOSTR.md index 52a12fcdee..62e438d30d 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -67,7 +67,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **NIP-42 authentication** | ✅ | Proactive challenge; optional pubkey allowlist | | **NIP-11 relay info** | ✅ | `GET /` with `Accept: application/nostr+json` | | **Blossom media** | ✅ | `PUT /media/upload` (BUD-02), `GET /media/{sha256}.{ext}` (BUD-01) | -| **NIP-50 search** | ✅ | One-shot search REQs: `{"search":"query","kinds":[9],"#h":[""]}` → relevance-sorted results → EOSE. Not registered as persistent subscriptions. | +| **NIP-50 search** | ✅ | One-shot search REQs: `{"search":"query","kinds":[9],"#h":[""]}` → relevance-sorted results → EOSE. Not registered as persistent subscriptions. HTTP `POST /query` also accepts `search_sort: "newest"` (advertised as `buzz-search-sort` in NIP-11 `supported_extensions`) for newest-first results, paged with `page`. | | **NIP-10 threads** | ✅ | WS-submitted replies with `["e","","","reply"]` tags create `thread_metadata` atomically. Visible in REST thread queries. Unknown parents rejected. | | **NIP-17 DMs (gift wrap)** | ✅ | kind:1059 accepted with ephemeral signing keys. Stored community-globally (`channel_id=None` inside the connected community). Delivered via `#p`-filtered subscriptions. Not indexed in search. | | **DM discovery** | ✅ | DM creation emits kind:39000 (with `hidden` tag) + kind:44100 membership notifications. NIP-29 clients discover DMs via standard group discovery flow. | diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 685d275c42..bc4b1d1a78 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -547,6 +547,15 @@ fn extract_search_mode(raw: &Value) -> buzz_search::SearchMode { } } +/// Bridge-only `search_sort` extension, advertised as `buzz-search-sort`. +/// Absent or unrecognized values keep relevance order. +fn extract_search_sort(raw: &Value) -> buzz_search::SearchSort { + match raw.get("search_sort").and_then(Value::as_str) { + Some("newest") => buzz_search::SearchSort::Newest, + _ => buzz_search::SearchSort::Relevance, + } +} + fn extract_search_page(raw: &Value) -> u32 { raw.get("page") .or_else(|| raw.get("search_page")) @@ -2427,6 +2436,7 @@ async fn handle_bridge_search( page: search_page, per_page: limit, mode: search_mode, + sort: extract_search_sort(raw), }; let search_result = state @@ -2450,7 +2460,7 @@ async fn handle_bridge_search( .await .map_err(|e| db_read_error("search fetch error", &e))?; - // Build lookup map to preserve FTS relevance ordering. + // Build lookup map to preserve FTS ordering. let event_map: std::collections::HashMap<[u8; 32], &buzz_core::StoredEvent> = stored_events .iter() .map(|ev| (ev.event.id.to_bytes(), ev)) @@ -3251,6 +3261,26 @@ pub(crate) mod postgres_tests { ); } + #[test] + fn bridge_search_sort_extension_selects_newest_only_when_named() { + for (raw, expected) in [ + ( + serde_json::json!({ "search": "pro" }), + buzz_search::SearchSort::Relevance, + ), + ( + serde_json::json!({ "search": "pro", "search_sort": "oldest" }), + buzz_search::SearchSort::Relevance, + ), + ( + serde_json::json!({ "search": "pro", "search_sort": "newest" }), + buzz_search::SearchSort::Newest, + ), + ] { + assert_eq!(extract_search_sort(&raw), expected); + } + } + #[test] fn bridge_search_mode_extension_accepts_prefix_snake_or_camel_case() { assert_eq!( diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 19c6173804..70f8005e79 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -844,6 +844,7 @@ async fn handle_search_req( page, per_page: SEARCH_PAGE_SIZE, mode: buzz_search::SearchMode::FullText, + sort: buzz_search::SearchSort::Relevance, }; let search_result = match state.search.search(&search_query).await { diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 2be8be2748..d61566efbb 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -247,6 +247,8 @@ impl RelayInfo { "nip-ar".to_string(), // NIP-CL: identity tags, labels and system channels. "nip-cl".to_string(), + // Bridge `/query` search accepts `search_sort: "newest"`. + "buzz-search-sort".to_string(), ]; let gif = gif_provider.map(|provider| { supported_extensions.push("buzz-gif".to_string()); @@ -666,10 +668,11 @@ mod tests { .contains(&"buzz-gif".to_string())); } - /// NIP-CL: clients refuse labels and system channels unless the relay - /// says it supports them, so every document must advertise it. + /// Clients refuse NIP-CL labels and system channels, and newest-first + /// search, unless the relay says it supports them, so every document must + /// advertise both. #[test] - fn advertises_nip_cl_extension() { + fn advertises_nip_cl_and_search_sort_extensions() { let info = RelayInfo::build( None, None, @@ -679,10 +682,9 @@ mod tests { None, None, ); - assert!(info - .supported_extensions - .expect("extensions") - .contains(&"nip-cl".to_string())); + let extensions = info.supported_extensions.expect("extensions"); + assert!(extensions.contains(&"nip-cl".to_string())); + assert!(extensions.contains(&"buzz-search-sort".to_string())); } /// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard diff --git a/crates/buzz-search/src/lib.rs b/crates/buzz-search/src/lib.rs index 3c206327b6..45ef48899d 100644 --- a/crates/buzz-search/src/lib.rs +++ b/crates/buzz-search/src/lib.rs @@ -28,7 +28,9 @@ pub mod query; pub use buzz_core::CommunityId; pub use error::SearchError; -pub use query::{search, ChannelScope, SearchHit, SearchMode, SearchQuery, SearchResult}; +pub use query::{ + search, ChannelScope, SearchHit, SearchMode, SearchQuery, SearchResult, SearchSort, +}; use sqlx::PgPool; diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs index a2bed24e22..c45fa2818e 100644 --- a/crates/buzz-search/src/query.rs +++ b/crates/buzz-search/src/query.rs @@ -67,6 +67,15 @@ pub enum SearchMode { Prefix, } +/// Result ordering for a search. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchSort { + /// `ts_rank_cd` relevance, newest first among equal ranks. + Relevance, + /// Newest first, ignoring relevance; ties ordered by event id. + Newest, +} + /// A community-scoped FTS query. /// /// The community is REQUIRED at the type level — there is no construction path @@ -98,6 +107,8 @@ pub struct SearchQuery { pub per_page: u32, /// Matching semantics for the search text. pub mode: SearchMode, + /// Result ordering. Paging is over this order. + pub sort: SearchSort, } /// A single FTS hit. The relay refetches the canonical `StoredEvent` and @@ -122,7 +133,7 @@ pub struct SearchHit { /// Result of a search. #[derive(Debug, Clone)] pub struct SearchResult { - /// Hits on this page, ordered by relevance then created_at desc. + /// Hits on this page, in the query's `sort` order. pub hits: Vec, /// 1-indexed page returned. pub page: u32, @@ -209,7 +220,7 @@ fn normalized_search_text(q: &str) -> Option { /// AND deleted_at IS NULL /// AND search_tsv @@ query /// [+ channel scope, kinds, authors, since, until] -/// ORDER BY rank DESC, created_at DESC, id +/// ORDER BY rank DESC, created_at DESC, id -- SearchSort::Newest: created_at DESC, id /// LIMIT $per_page OFFSET (($page - 1) * $per_page) /// ``` /// @@ -237,7 +248,8 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result Result>() + } + }; + + let newest_order = [ + page(1, buzz_search::SearchSort::Newest).await, + page(2, buzz_search::SearchSort::Newest).await, + page(3, buzz_search::SearchSort::Newest).await, + ] + .concat(); + assert_eq!(newest_order, vec![newest, tie_low, tie_high, most_relevant]); + assert_eq!( + page(1, buzz_search::SearchSort::Relevance).await[0], + most_relevant + ); + + teardown(pool, &schema).await; +} + #[tokio::test] #[ignore = "requires Postgres"] async fn channel_less_only_excludes_per_channel_events() { @@ -952,6 +1051,7 @@ async fn channel_less_only_excludes_per_channel_events() { page: 1, per_page: 10, mode: buzz_search::SearchMode::FullText, + sort: buzz_search::SearchSort::Relevance, }) .await .unwrap(); @@ -1000,6 +1100,7 @@ async fn nul_bytes_in_query_are_sanitized() { page: 1, per_page: 10, mode: buzz_search::SearchMode::FullText, + sort: buzz_search::SearchSort::Relevance, }) .await .expect("NUL-containing search query should not bubble a Postgres error"); @@ -1043,6 +1144,7 @@ async fn enormous_page_number_is_clamped() { page: u32::MAX, per_page: 10, mode: buzz_search::SearchMode::FullText, + sort: buzz_search::SearchSort::Relevance, }) .await .expect("huge page number should be bounded, not error"); @@ -1072,6 +1174,7 @@ async fn very_long_query_is_bounded_before_pg_parse() { page: 1, per_page: 10, mode: buzz_search::SearchMode::FullText, + sort: buzz_search::SearchSort::Relevance, }) .await .expect("long search query should be capped before Postgres parses it"); @@ -1213,6 +1316,7 @@ async fn excluded_kinds_are_storage_level_unsearchable() { page: 1, per_page: 10, mode: buzz_search::SearchMode::FullText, + sort: buzz_search::SearchSort::Relevance, }) .await .expect("search ok"); @@ -1309,6 +1413,7 @@ async fn author_only_kinds_are_storage_level_unsearchable() { page: 1, per_page: 100, mode: buzz_search::SearchMode::FullText, + sort: buzz_search::SearchSort::Relevance, }) .await .expect("search ok"); @@ -1416,6 +1521,7 @@ async fn p_gated_persistent_kinds_have_storage_null_tsvector() { page: 1, per_page: 100, mode: buzz_search::SearchMode::FullText, + sort: buzz_search::SearchSort::Relevance, }) .await .expect("search ok"); @@ -1491,6 +1597,7 @@ async fn artifact_search_uses_only_live_current_head() { page: 1, per_page: 10, mode: buzz_search::SearchMode::FullText, + sort: buzz_search::SearchSort::Relevance, }; let result = svc.search(&query).await.unwrap(); assert_eq!(result.hits.len(), 1); diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index fb2645b5e0..0dbd43195c 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -255,6 +255,13 @@ run_unit_tests() { handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions \ handlers::req::tests::group_state_t_and_creator_tags_reach_sql_only_for_group_state_kinds + # Infra-free bridge search extension parser tests, by exact name: the rest of + # api::bridge::postgres_tests needs a database. + run_test_step "buzz-relay bridge search extension tests" \ + cargo test -p buzz-relay --lib -- --exact --nocapture \ + api::bridge::postgres_tests::bridge_search_mode_extension_accepts_prefix_snake_or_camel_case \ + api::bridge::postgres_tests::bridge_search_sort_extension_selects_newest_only_when_named + run_test_step "buzz-relay readiness tests" \ cargo test -p buzz-relay --lib readiness::tests:: -- --nocapture