commit bd43385d6bd0efe71e9d91c21cf572eabebf7fe7
parent 97682a264ca1929fde4dbc9e7ca664960e723d0e
Author: triesap <tyson@radroots.org>
Date: Fri, 5 Jun 2026 21:52:10 -0700
core: add nostr filter compiler
Diffstat:
3 files changed, 277 insertions(+), 6 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -377,6 +377,7 @@ version = "0.1.0"
name = "tangle_core"
version = "0.1.0"
dependencies = [
+ "serde_json",
"tangle_crypto",
"tangle_nips",
"tangle_protocol",
diff --git a/crates/tangle_core/Cargo.toml b/crates/tangle_core/Cargo.toml
@@ -14,6 +14,7 @@ tangle_protocol = { path = "../tangle_protocol" }
tangle_store = { path = "../tangle_store" }
[dev-dependencies]
+serde_json = "1"
tangle_test_support = { path = "../tangle_test_support" }
[lints]
diff --git a/crates/tangle_core/src/lib.rs b/crates/tangle_core/src/lib.rs
@@ -7,7 +7,7 @@ use tangle_nips::{
DeletionRequest, ListingProjectionEvaluation, RelayAuthEvent, evaluate_listing_projection,
parse_deletion_request, parse_relay_auth_event,
};
-use tangle_protocol::{Event, EventId, PublicKeyHex, UnixTimestamp, event_to_value};
+use tangle_protocol::{Event, EventId, Filter, PublicKeyHex, UnixTimestamp, event_to_value};
use tangle_store::{
DeletionMarker, DeletionMarkerRepository, ListingProjectionRepository, RawEventRepository,
RepositoryError, StoreEventOutcome, StoreProjectionOutcome, StoredEvent,
@@ -1556,6 +1556,150 @@ where
.collect()
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct NostrFilterCompiler {
+ limits: RuntimeLimits,
+}
+
+impl NostrFilterCompiler {
+ 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, NostrFilterCompileError> {
+ self.limits
+ .validate_filters(filters.len() as u64, filter_complexity(filters))
+ .map_err(NostrFilterCompileError::RuntimeLimit)?;
+ let branches = filters
+ .iter()
+ .map(compile_filter_branch)
+ .collect::<Result<Vec<_>, _>>()?;
+ let source = if branches.iter().any(|branch| branch.search().is_some()) {
+ QuerySource::SearchDocuments
+ } else {
+ QuerySource::RawEvents
+ };
+ let sort = if source == QuerySource::SearchDocuments {
+ QuerySort::ScoreDescCreatedAtDescEventIdAsc
+ } else {
+ QuerySort::CreatedAtDescEventIdAsc
+ };
+ QueryPlan::new(source, mode, sort, branches).map_err(NostrFilterCompileError::QueryPlan)
+ }
+}
+
+impl Default for NostrFilterCompiler {
+ fn default() -> Self {
+ Self::new(RuntimeLimits::default())
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum NostrFilterCompileError {
+ RuntimeLimit(RuntimeLimitViolation),
+ QueryPlan(QueryPlanError),
+}
+
+impl NostrFilterCompileError {
+ pub fn kind(&self) -> NostrFilterCompileErrorKind {
+ match self {
+ Self::RuntimeLimit(_) => NostrFilterCompileErrorKind::RuntimeLimit,
+ Self::QueryPlan(_) => NostrFilterCompileErrorKind::QueryPlan,
+ }
+ }
+}
+
+impl fmt::Display for NostrFilterCompileError {
+ 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}"),
+ }
+ }
+}
+
+impl std::error::Error for NostrFilterCompileError {}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum NostrFilterCompileErrorKind {
+ RuntimeLimit,
+ QueryPlan,
+}
+
+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 search = filter
+ .search()
+ .map(|raw| {
+ QuerySearch::new(
+ raw,
+ raw.split_whitespace()
+ .map(str::to_owned)
+ .collect::<Vec<_>>(),
+ )
+ .map_err(NostrFilterCompileError::QueryPlan)
+ })
+ .transpose()?;
+ 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,
+ })
+ .map_err(NostrFilterCompileError::QueryPlan)
+}
+
+fn filter_complexity(filters: &[Filter]) -> u64 {
+ filters
+ .iter()
+ .map(|filter| {
+ let tag_value_count = filter.tag_filters().values().map(Vec::len).sum::<usize>();
+ let search_terms = filter
+ .search()
+ .map_or(0, |search| search.split_whitespace().count());
+ 1 + filter.ids().len()
+ + filter.authors().len()
+ + filter.kinds().len()
+ + filter.tag_filters().len()
+ + tag_value_count
+ + usize::from(filter.since().is_some())
+ + usize::from(filter.until().is_some())
+ + usize::from(filter.limit().is_some())
+ + search_terms
+ })
+ .sum::<usize>() as u64
+}
+
fn require_positive(field: &'static str, value: u64) -> Result<(), RuntimeLimitConfigError> {
if value == 0 {
Err(RuntimeLimitConfigError::Zero { field })
@@ -1582,15 +1726,15 @@ mod tests {
AdmissionContext, AdmissionEffect, AdmissionEvent, AdmissionEventKind, AdmissionPolicy,
AdmissionRejectionKind, EventIngestionEffect, EventIngestionRejectionKind, EventIngestor,
EventParser, EventValidationRejection, EventValidationRejectionKind, EventValidator,
- ProjectionExclusionReason, QueryExecutionMode, QueryPlan, QueryPlanBranch,
- QueryPlanBranchSpec, QueryPlanError, QuerySearch, QuerySort, QuerySource, QueryTagFilter,
- RuntimeLimitConfigError, RuntimeLimitKind, RuntimeLimitValues, RuntimeLimits,
- UnapprovedSellerAction,
+ 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::{
AddressCoordinate, Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp,
- UnsignedEvent,
+ UnsignedEvent, filter_from_value,
};
use tangle_store::{
DeletionMarker, DeletionMarkerRepository, ListingProjectionRepository, RawEventRepository,
@@ -3050,6 +3194,131 @@ mod tests {
);
}
+ #[test]
+ fn nostr_filter_compiler_builds_search_backed_query_plans() {
+ let filter = filter_from_value(&serde_json::json!({
+ "ids": ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
+ "authors": ["1111111111111111111111111111111111111111111111111111111111111111"],
+ "kinds": [1, 30402],
+ "#t": ["vegetables", "carrots", "vegetables"],
+ "since": 10,
+ "until": 20,
+ "limit": 25,
+ "search": "fresh carrots"
+ }))
+ .expect("filter");
+ let compiler = NostrFilterCompiler::default();
+ let plan = compiler
+ .compile(&[filter], QueryExecutionMode::HistoricalThenLive)
+ .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::HistoricalThenLive);
+ assert!(plan.requires_historical_query());
+ assert!(plan.subscribes_to_live_events());
+ assert_eq!(branch.ids()[0].as_str(), &"b".repeat(EventId::HEX_LENGTH));
+ assert_eq!(branch.authors()[0], pubkey("1"));
+ assert_eq!(
+ branch.kinds(),
+ &[
+ Kind::new(1).expect("kind"),
+ 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(25));
+ assert_eq!(
+ branch.search().expect("search").terms(),
+ &["carrots".to_owned(), "fresh".to_owned()]
+ );
+ }
+
+ #[test]
+ fn nostr_filter_compiler_preserves_limit_zero_historical_skip() {
+ let filter = filter_from_value(&serde_json::json!({
+ "limit": 0,
+ "#p": ["1111111111111111111111111111111111111111111111111111111111111111"]
+ }))
+ .expect("filter");
+ let plan = NostrFilterCompiler::default()
+ .compile(&[filter], QueryExecutionMode::HistoricalThenLive)
+ .expect("plan");
+
+ assert_eq!(plan.source(), QuerySource::RawEvents);
+ assert_eq!(plan.sort(), QuerySort::CreatedAtDescEventIdAsc);
+ assert!(!plan.requires_historical_query());
+ assert!(plan.subscribes_to_live_events());
+ assert_eq!(
+ plan.branches()[0].tag_filters().get(&'p').expect("p"),
+ &["1111111111111111111111111111111111111111111111111111111111111111".to_owned()]
+ );
+ }
+
+ #[test]
+ fn nostr_filter_compiler_rejects_limit_and_plan_errors() {
+ let empty_filters = NostrFilterCompiler::default()
+ .compile(&[], QueryExecutionMode::Historical)
+ .expect_err("empty filters");
+ let too_many_filters = NostrFilterCompiler::new(limits_with(|values| {
+ values.max_filters_per_subscription = 1;
+ }))
+ .compile(
+ &[
+ tangle_protocol::Filter::empty(),
+ tangle_protocol::Filter::empty(),
+ ],
+ QueryExecutionMode::Historical,
+ )
+ .expect_err("filter count");
+ let too_complex = NostrFilterCompiler::new(limits_with(|values| {
+ values.max_filter_complexity = 1;
+ }))
+ .compile(
+ &[filter_from_value(&serde_json::json!({
+ "ids": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
+ "authors": ["1111111111111111111111111111111111111111111111111111111111111111"]
+ }))
+ .expect("filter")],
+ QueryExecutionMode::Historical,
+ )
+ .expect_err("complexity");
+ let blank_search = NostrFilterCompiler::default()
+ .compile(
+ &[filter_from_value(&serde_json::json!({ "search": " " })).expect("filter")],
+ QueryExecutionMode::Historical,
+ )
+ .expect_err("blank search");
+
+ assert_eq!(empty_filters.kind(), NostrFilterCompileErrorKind::QueryPlan);
+ assert_eq!(
+ too_many_filters.kind(),
+ NostrFilterCompileErrorKind::RuntimeLimit
+ );
+ assert_eq!(
+ too_complex.kind(),
+ NostrFilterCompileErrorKind::RuntimeLimit
+ );
+ assert_eq!(blank_search.kind(), NostrFilterCompileErrorKind::QueryPlan);
+ assert_eq!(
+ empty_filters.to_string(),
+ "query plan: query plan must include at least one branch"
+ );
+ assert!(too_many_filters.to_string().starts_with("runtime limit:"));
+ assert!(too_complex.to_string().starts_with("runtime limit:"));
+ assert_eq!(
+ blank_search.to_string(),
+ "query plan: search query must include terms"
+ );
+ }
+
fn limits_with(update: impl FnOnce(&mut RuntimeLimitValues)) -> RuntimeLimits {
let mut values = RuntimeLimitValues::default();
update(&mut values);