commit 7c98c1f7599be14a62bfdbc2468366fa4ca777cd
parent 6d5823cbb6da79f9490414be91881cf9181729d8
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 01:34:09 -0700
event_store: add projection tag queries
- add bounded projection-eligible event lookup by exact tag name and value
- reject empty tag names and out-of-range query limits
- dedupe duplicate tag rows through an event-level EXISTS query
- validate with cargo fmt, check, and tests for radroots_event_store
Diffstat:
3 files changed, 110 insertions(+), 1 deletion(-)
diff --git a/crates/event_store/src/error.rs b/crates/event_store/src/error.rs
@@ -16,6 +16,10 @@ pub enum RadrootsEventStoreError {
IdParse(#[from] RadrootsIdParseError),
#[error("stored event `{0}` was not found")]
MissingEvent(String),
+ #[error("event-store tag query tag name cannot be empty")]
+ EmptyTagName,
+ #[error("event-store query limit {actual} is outside {min}..={max}")]
+ QueryLimitOutOfRange { min: u32, max: u32, actual: u32 },
#[error("invalid stored enum value `{value}` for {field}")]
InvalidStoredEnum { field: &'static str, value: String },
#[error("integer value `{value}` is outside {field} range")]
diff --git a/crates/event_store/src/lib.rs b/crates/event_store/src/lib.rs
@@ -21,4 +21,4 @@ pub use model::{
RadrootsStoredEventHead, RadrootsStoredEventTag, StoredEventClass,
};
#[cfg(feature = "sqlite")]
-pub use store::RadrootsEventStore;
+pub use store::{RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, RadrootsEventStore};
diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs
@@ -22,6 +22,8 @@ use sqlx::{Row, SqlitePool};
use std::path::Path;
use std::str::FromStr;
+pub const RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX: u32 = 500;
+
#[derive(Clone)]
pub struct RadrootsEventStore {
pool: SqlitePool,
@@ -264,6 +266,24 @@ impl RadrootsEventStore {
.await?;
rows.into_iter().map(stored_event_from_row).collect()
}
+
+ pub async fn events_by_tag(
+ &self,
+ tag_name: &str,
+ tag_value: &str,
+ limit: u32,
+ ) -> Result<Vec<RadrootsStoredEvent>, RadrootsEventStoreError> {
+ validate_tag_query(tag_name, limit)?;
+ let rows = sqlx::query(
+ "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM nostr_event AS event WHERE projection_eligible = 1 AND EXISTS (SELECT 1 FROM nostr_event_tag AS tag WHERE tag.event_id = event.event_id AND tag.tag_name = ? AND tag.tag_value = ?) ORDER BY event.seq ASC LIMIT ?",
+ )
+ .bind(tag_name)
+ .bind(tag_value)
+ .bind(i64::from(limit))
+ .fetch_all(&self.pool)
+ .await?;
+ rows.into_iter().map(stored_event_from_row).collect()
+ }
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -716,6 +736,20 @@ fn bool_i64(value: bool) -> i64 {
if value { 1 } else { 0 }
}
+fn validate_tag_query(tag_name: &str, limit: u32) -> Result<(), RadrootsEventStoreError> {
+ if tag_name.is_empty() {
+ return Err(RadrootsEventStoreError::EmptyTagName);
+ }
+ if !(1..=RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX).contains(&limit) {
+ return Err(RadrootsEventStoreError::QueryLimitOutOfRange {
+ min: 1,
+ max: RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX,
+ actual: limit,
+ });
+ }
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1139,6 +1173,77 @@ mod tests {
}
#[tokio::test]
+ async fn events_by_tag_validates_inputs_and_returns_projection_events_in_sequence_order() {
+ let store = RadrootsEventStore::open_memory().await.expect("store");
+
+ assert!(matches!(
+ store.events_by_tag("", "soil", 1).await,
+ Err(RadrootsEventStoreError::EmptyTagName)
+ ));
+ assert!(matches!(
+ store.events_by_tag("t", "soil", 0).await,
+ Err(RadrootsEventStoreError::QueryLimitOutOfRange { .. })
+ ));
+ assert!(matches!(
+ store
+ .events_by_tag("t", "soil", RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX + 1)
+ .await,
+ Err(RadrootsEventStoreError::QueryLimitOutOfRange { .. })
+ ));
+
+ let unsupported = signed_event(
+ 999,
+ 40,
+ vec![vec!["t".to_owned(), "soil".to_owned()]],
+ "unsupported",
+ );
+ let high_created_at = signed_event(
+ KIND_POST,
+ 60,
+ vec![
+ vec!["t".to_owned(), "soil".to_owned()],
+ vec!["t".to_owned(), "soil".to_owned()],
+ ],
+ "high-created-at",
+ );
+ let low_created_at = signed_event(
+ KIND_POST,
+ 50,
+ vec![vec!["t".to_owned(), "soil".to_owned()]],
+ "low-created-at",
+ );
+
+ store
+ .ingest_event(RadrootsEventIngest::new(unsupported.clone(), 3_300))
+ .await
+ .expect("unsupported ingest");
+ store
+ .ingest_event(RadrootsEventIngest::new(high_created_at.clone(), 3_400))
+ .await
+ .expect("high ingest");
+ store
+ .ingest_event(RadrootsEventIngest::new(low_created_at.clone(), 3_500))
+ .await
+ .expect("low ingest");
+
+ let events = store
+ .events_by_tag("t", "soil", 10)
+ .await
+ .expect("tag query");
+ assert_eq!(events.len(), 2);
+ assert_eq!(events[0].event_id, high_created_at.id);
+ assert_eq!(events[1].event_id, low_created_at.id);
+ assert!(events.iter().all(|event| event.projection_eligible));
+
+ let limited = store
+ .events_by_tag("t", "soil", 1)
+ .await
+ .expect("limited tag query");
+ assert_eq!(limited.len(), 1);
+ assert_eq!(limited[0].event_id, high_created_at.id);
+ }
+
+ #[tokio::test]
async fn tag_rows_preserve_order_and_contract_metadata() {
let store = RadrootsEventStore::open_memory().await.expect("open");
let event = signed_event(