tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 3018e3eef4d895a375df342fc1cc11b4ad3ae0c7
parent bd43385d6bd0efe71e9d91c21cf572eabebf7fe7
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 21:58:15 -0700

core: add nip50 query compiler

Diffstat:
Mcrates/tangle_core/src/lib.rs | 345+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 321 insertions(+), 24 deletions(-)

diff --git a/crates/tangle_core/src/lib.rs b/crates/tangle_core/src/lib.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use tangle_crypto::verify_event_signature; use tangle_nips::{ DeletionRequest, ListingProjectionEvaluation, RelayAuthEvent, evaluate_listing_projection, - parse_deletion_request, parse_relay_auth_event, + parse_deletion_request, parse_nip50_filter_search, parse_relay_auth_event, }; use tangle_protocol::{Event, EventId, Filter, PublicKeyHex, UnixTimestamp, event_to_value}; use tangle_store::{ @@ -1634,26 +1634,95 @@ pub enum NostrFilterCompileErrorKind { QueryPlan, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Nip50QueryCompiler { + limits: RuntimeLimits, +} + +impl Nip50QueryCompiler { + pub fn new(limits: RuntimeLimits) -> Self { + Self { limits } + } + + pub fn limits(self) -> RuntimeLimits { + self.limits + } + + pub fn compile( + &self, + filters: &[Filter], + mode: QueryExecutionMode, + ) -> Result<QueryPlan, Nip50QueryCompileError> { + self.limits + .validate_filters(filters.len() as u64, filter_complexity(filters)) + .map_err(Nip50QueryCompileError::RuntimeLimit)?; + let branches = filters + .iter() + .map(|filter| compile_nip50_filter_branch(filter, self.limits)) + .collect::<Result<Vec<_>, _>>()? + .into_iter() + .flatten() + .collect::<Vec<_>>(); + if branches.is_empty() { + return Err(Nip50QueryCompileError::MissingSearchTerms); + } + QueryPlan::new( + QuerySource::SearchDocuments, + mode, + QuerySort::ScoreDescCreatedAtDescEventIdAsc, + branches, + ) + .map_err(Nip50QueryCompileError::QueryPlan) + } +} + +impl Default for Nip50QueryCompiler { + fn default() -> Self { + Self::new(RuntimeLimits::default()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Nip50QueryCompileError { + RuntimeLimit(RuntimeLimitViolation), + QueryPlan(QueryPlanError), + MissingSearchTerms, +} + +impl Nip50QueryCompileError { + pub fn kind(&self) -> Nip50QueryCompileErrorKind { + match self { + Self::RuntimeLimit(_) => Nip50QueryCompileErrorKind::RuntimeLimit, + Self::QueryPlan(_) => Nip50QueryCompileErrorKind::QueryPlan, + Self::MissingSearchTerms => Nip50QueryCompileErrorKind::MissingSearchTerms, + } + } +} + +impl fmt::Display for Nip50QueryCompileError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RuntimeLimit(violation) => write!(formatter, "runtime limit: {violation}"), + Self::QueryPlan(error) => write!(formatter, "query plan: {error}"), + Self::MissingSearchTerms => { + formatter.write_str("nip50 query must include plain search terms") + } + } + } +} + +impl std::error::Error for Nip50QueryCompileError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Nip50QueryCompileErrorKind { + RuntimeLimit, + QueryPlan, + MissingSearchTerms, +} + fn compile_filter_branch(filter: &Filter) -> Result<QueryPlanBranch, NostrFilterCompileError> { - let tag_filters = filter - .tag_filters() - .iter() - .map(|(name, values)| { - let name = name - .as_str() - .chars() - .next() - .expect("protocol tag filters are non-empty"); - QueryTagFilter::new( - name, - values - .iter() - .map(|value| value.as_str().to_owned()) - .collect(), - ) - .map_err(NostrFilterCompileError::QueryPlan) - }) - .collect::<Result<Vec<_>, _>>()?; + let tag_filters = + compile_filter_tag_constraints(filter).map_err(NostrFilterCompileError::QueryPlan)?; let search = filter .search() .map(|raw| { @@ -1679,6 +1748,60 @@ fn compile_filter_branch(filter: &Filter) -> Result<QueryPlanBranch, NostrFilter .map_err(NostrFilterCompileError::QueryPlan) } +fn compile_nip50_filter_branch( + filter: &Filter, + limits: RuntimeLimits, +) -> Result<Option<QueryPlanBranch>, Nip50QueryCompileError> { + if let Some(raw) = filter.search() { + limits + .validate_search_query(raw) + .map_err(Nip50QueryCompileError::RuntimeLimit)?; + } + let search = match parse_nip50_filter_search(filter) + .expect("validated protocol filters are valid nip50 parser input") + { + Some(search) => search, + None => return Ok(None), + }; + let search = QuerySearch::new(search.text(), search.terms().to_vec()) + .expect("nip50 parser only returns search queries with plain terms"); + let tag_filters = + compile_filter_tag_constraints(filter).map_err(Nip50QueryCompileError::QueryPlan)?; + QueryPlanBranch::from_spec(QueryPlanBranchSpec { + ids: filter.ids().to_vec(), + authors: filter.authors().to_vec(), + kinds: filter.kinds().to_vec(), + tag_filters, + since: filter.since(), + until: filter.until(), + limit: filter.limit(), + search: Some(search), + }) + .map(Some) + .map_err(Nip50QueryCompileError::QueryPlan) +} + +fn compile_filter_tag_constraints(filter: &Filter) -> Result<Vec<QueryTagFilter>, QueryPlanError> { + filter + .tag_filters() + .iter() + .map(|(name, values)| { + let name = name + .as_str() + .chars() + .next() + .expect("protocol tag filters are non-empty"); + QueryTagFilter::new( + name, + values + .iter() + .map(|value| value.as_str().to_owned()) + .collect(), + ) + }) + .collect() +} + fn filter_complexity(filters: &[Filter]) -> u64 { filters .iter() @@ -1726,10 +1849,11 @@ mod tests { AdmissionContext, AdmissionEffect, AdmissionEvent, AdmissionEventKind, AdmissionPolicy, AdmissionRejectionKind, EventIngestionEffect, EventIngestionRejectionKind, EventIngestor, EventParser, EventValidationRejection, EventValidationRejectionKind, EventValidator, - NostrFilterCompileErrorKind, NostrFilterCompiler, ProjectionExclusionReason, - QueryExecutionMode, QueryPlan, QueryPlanBranch, QueryPlanBranchSpec, QueryPlanError, - QuerySearch, QuerySort, QuerySource, QueryTagFilter, RuntimeLimitConfigError, - RuntimeLimitKind, RuntimeLimitValues, RuntimeLimits, UnapprovedSellerAction, + Nip50QueryCompileErrorKind, Nip50QueryCompiler, NostrFilterCompileErrorKind, + NostrFilterCompiler, ProjectionExclusionReason, QueryExecutionMode, QueryPlan, + QueryPlanBranch, QueryPlanBranchSpec, QueryPlanError, QuerySearch, QuerySort, QuerySource, + QueryTagFilter, RuntimeLimitConfigError, RuntimeLimitKind, RuntimeLimitValues, + RuntimeLimits, UnapprovedSellerAction, }; use tangle_nips::{ListingProjection, evaluate_listing_projection, parse_deletion_request}; use tangle_protocol::{ @@ -3296,6 +3420,12 @@ mod tests { QueryExecutionMode::Historical, ) .expect_err("blank search"); + let empty_tag = NostrFilterCompiler::default() + .compile( + &[filter_from_value(&serde_json::json!({ "#t": [""] })).expect("filter")], + QueryExecutionMode::Historical, + ) + .expect_err("empty tag"); assert_eq!(empty_filters.kind(), NostrFilterCompileErrorKind::QueryPlan); assert_eq!( @@ -3307,6 +3437,7 @@ mod tests { NostrFilterCompileErrorKind::RuntimeLimit ); assert_eq!(blank_search.kind(), NostrFilterCompileErrorKind::QueryPlan); + assert_eq!(empty_tag.kind(), NostrFilterCompileErrorKind::QueryPlan); assert_eq!( empty_filters.to_string(), "query plan: query plan must include at least one branch" @@ -3317,6 +3448,172 @@ mod tests { blank_search.to_string(), "query plan: search query must include terms" ); + assert_eq!( + empty_tag.to_string(), + "query plan: tag filter `t` values must not be empty" + ); + } + + #[test] + fn nip50_query_compiler_builds_search_document_plan_from_plain_terms() { + let filter = filter_from_value(&serde_json::json!({ + "authors": ["1111111111111111111111111111111111111111111111111111111111111111"], + "kinds": [30402], + "#t": ["carrots", "vegetables"], + "since": 10, + "until": 20, + "limit": 10, + "search": "fresh seller:ignored carrots status:ignored carrots" + })) + .expect("filter"); + let compiler = Nip50QueryCompiler::default(); + let plan = compiler + .compile(&[filter], QueryExecutionMode::Historical) + .expect("plan"); + let branch = &plan.branches()[0]; + + assert_eq!(compiler.limits(), RuntimeLimits::default()); + assert_eq!(plan.source(), QuerySource::SearchDocuments); + assert_eq!(plan.sort(), QuerySort::ScoreDescCreatedAtDescEventIdAsc); + assert_eq!(plan.mode(), QueryExecutionMode::Historical); + assert!(plan.requires_historical_query()); + assert!(!plan.subscribes_to_live_events()); + assert_eq!(branch.authors()[0], pubkey("1")); + assert_eq!(branch.kinds(), &[Kind::new(30_402).expect("kind")]); + assert_eq!( + branch.tag_filters().get(&'t').expect("tag"), + &["carrots".to_owned(), "vegetables".to_owned()] + ); + assert_eq!(branch.since(), Some(UnixTimestamp::new(10))); + assert_eq!(branch.until(), Some(UnixTimestamp::new(20))); + assert_eq!(branch.limit(), Some(10)); + assert_eq!( + branch.search().expect("search").raw(), + "fresh carrots carrots" + ); + assert_eq!( + branch.search().expect("search").terms(), + &["carrots".to_owned(), "fresh".to_owned()] + ); + } + + #[test] + fn nip50_query_compiler_ignores_extension_only_filters() { + let extension_only = filter_from_value(&serde_json::json!({ + "search": "seller:ignored status:ignored", + "limit": 0 + })) + .expect("extension"); + let searchable = filter_from_value(&serde_json::json!({ + "search": "greens", + "kinds": [1] + })) + .expect("search"); + let plan = Nip50QueryCompiler::default() + .compile( + &[extension_only, tangle_protocol::Filter::empty(), searchable], + QueryExecutionMode::HistoricalThenLive, + ) + .expect("plan"); + + assert_eq!(plan.branches().len(), 1); + assert_eq!(plan.branches()[0].search().expect("search").raw(), "greens"); + assert_eq!(plan.branches()[0].kinds(), &[Kind::new(1).expect("kind")]); + assert!(plan.requires_historical_query()); + assert!(plan.subscribes_to_live_events()); + } + + #[test] + fn nip50_query_compiler_rejects_missing_terms_limits_and_bad_plans() { + let empty = Nip50QueryCompiler::default() + .compile(&[], QueryExecutionMode::Historical) + .expect_err("empty"); + let extension_only = Nip50QueryCompiler::default() + .compile( + &[filter_from_value(&serde_json::json!({ + "search": "seller:ignored status:ignored" + })) + .expect("filter")], + QueryExecutionMode::Historical, + ) + .expect_err("extension only"); + let too_many_filters = Nip50QueryCompiler::new(limits_with(|values| { + values.max_filters_per_subscription = 1; + })) + .compile( + &[ + filter_from_value(&serde_json::json!({ "search": "carrots" })).expect("filter"), + filter_from_value(&serde_json::json!({ "search": "greens" })).expect("filter"), + ], + QueryExecutionMode::Historical, + ) + .expect_err("filter count"); + let too_many_tokens = Nip50QueryCompiler::new(limits_with(|values| { + values.max_search_tokens = 1; + })) + .compile( + &[ + filter_from_value(&serde_json::json!({ "search": "fresh carrots" })) + .expect("filter"), + ], + QueryExecutionMode::Historical, + ) + .expect_err("tokens"); + let bad_plan = Nip50QueryCompiler::default() + .compile( + &[filter_from_value(&serde_json::json!({ + "search": "carrots", + "since": 20, + "until": 10 + })) + .expect("filter")], + QueryExecutionMode::Historical, + ) + .expect_err("plan"); + let empty_tag = Nip50QueryCompiler::default() + .compile( + &[filter_from_value(&serde_json::json!({ + "search": "carrots", + "#t": [""] + })) + .expect("filter")], + QueryExecutionMode::Historical, + ) + .expect_err("tag"); + + assert_eq!(empty.kind(), Nip50QueryCompileErrorKind::MissingSearchTerms); + assert_eq!( + extension_only.kind(), + Nip50QueryCompileErrorKind::MissingSearchTerms + ); + assert_eq!( + too_many_filters.kind(), + Nip50QueryCompileErrorKind::RuntimeLimit + ); + assert_eq!( + too_many_tokens.kind(), + Nip50QueryCompileErrorKind::RuntimeLimit + ); + assert_eq!(bad_plan.kind(), Nip50QueryCompileErrorKind::QueryPlan); + assert_eq!(empty_tag.kind(), Nip50QueryCompileErrorKind::QueryPlan); + assert_eq!( + empty.to_string(), + "nip50 query must include plain search terms" + ); + assert!( + too_many_filters + .to_string() + .contains("filters per subscription") + ); + assert!(too_many_tokens.to_string().contains("search tokens")); + assert_eq!( + bad_plan.to_string(), + "query plan: query time range is invalid: since 20 > until 10" + ); + assert_eq!( + empty_tag.to_string(), + "query plan: tag filter `t` values must not be empty" + ); } fn limits_with(update: impl FnOnce(&mut RuntimeLimitValues)) -> RuntimeLimits {