lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit cf169684d5001a5893d73ababa434f624091e3f2
parent 7c98c1f7599be14a62bfdbc2468366fa4ca777cd
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 01:37:47 -0700

event_store: add contract tag queries

- add bounded contract-and-tag lookup for projection events
- reject empty and oversized contract filter lists
- bind contract ids, tag filters, and limits in the SQL query
- validate with cargo fmt, check, and tests for radroots_event_store

Diffstat:
Mcrates/event_store/src/error.rs | 4++++
Mcrates/event_store/src/lib.rs | 5++++-
Mcrates/event_store/src/store.rs | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 150 insertions(+), 1 deletion(-)

diff --git a/crates/event_store/src/error.rs b/crates/event_store/src/error.rs @@ -18,6 +18,10 @@ pub enum RadrootsEventStoreError { MissingEvent(String), #[error("event-store tag query tag name cannot be empty")] EmptyTagName, + #[error("event-store contract tag query contract list cannot be empty")] + EmptyContractList, + #[error("event-store contract list length {actual} exceeds {max}")] + ContractListTooLarge { max: usize, actual: usize }, #[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}")] diff --git a/crates/event_store/src/lib.rs b/crates/event_store/src/lib.rs @@ -21,4 +21,7 @@ pub use model::{ RadrootsStoredEventHead, RadrootsStoredEventTag, StoredEventClass, }; #[cfg(feature = "sqlite")] -pub use store::{RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, RadrootsEventStore}; +pub use store::{ + RADROOTS_EVENT_STORE_CONTRACT_QUERY_LIMIT_MAX, RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, + RadrootsEventStore, +}; diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs @@ -23,6 +23,7 @@ use std::path::Path; use std::str::FromStr; pub const RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX: u32 = 500; +pub const RADROOTS_EVENT_STORE_CONTRACT_QUERY_LIMIT_MAX: usize = 16; #[derive(Clone)] pub struct RadrootsEventStore { @@ -284,6 +285,36 @@ impl RadrootsEventStore { .await?; rows.into_iter().map(stored_event_from_row).collect() } + + pub async fn events_by_contract_and_tag<S>( + &self, + contract_ids: &[S], + tag_name: &str, + tag_value: &str, + limit: u32, + ) -> Result<Vec<RadrootsStoredEvent>, RadrootsEventStoreError> + where + S: AsRef<str>, + { + validate_contract_tag_query(contract_ids, tag_name, limit)?; + let placeholders = core::iter::repeat_n("?", contract_ids.len()) + .collect::<Vec<_>>() + .join(", "); + let sql = format!( + "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 contract_id IN ({placeholders}) 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 ?" + ); + let mut query = sqlx::query(sql.as_str()); + for contract_id in contract_ids { + query = query.bind(contract_id.as_ref()); + } + let rows = query + .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)] @@ -750,6 +781,26 @@ fn validate_tag_query(tag_name: &str, limit: u32) -> Result<(), RadrootsEventSto Ok(()) } +fn validate_contract_tag_query<S>( + contract_ids: &[S], + tag_name: &str, + limit: u32, +) -> Result<(), RadrootsEventStoreError> +where + S: AsRef<str>, +{ + if contract_ids.is_empty() { + return Err(RadrootsEventStoreError::EmptyContractList); + } + if contract_ids.len() > RADROOTS_EVENT_STORE_CONTRACT_QUERY_LIMIT_MAX { + return Err(RadrootsEventStoreError::ContractListTooLarge { + max: RADROOTS_EVENT_STORE_CONTRACT_QUERY_LIMIT_MAX, + actual: contract_ids.len(), + }); + } + validate_tag_query(tag_name, limit) +} + #[cfg(test)] mod tests { use super::*; @@ -1244,6 +1295,97 @@ mod tests { } #[tokio::test] + async fn events_by_contract_and_tag_enforces_contract_tag_and_projection_filters() { + let store = RadrootsEventStore::open_memory().await.expect("store"); + + assert!(matches!( + store + .events_by_contract_and_tag::<&str>(&[], "p", FIXTURE_ALICE_PUBLIC_KEY_HEX, 1) + .await, + Err(RadrootsEventStoreError::EmptyContractList) + )); + let too_many_contracts = + vec!["radroots.order.request.v1"; RADROOTS_EVENT_STORE_CONTRACT_QUERY_LIMIT_MAX + 1]; + assert!(matches!( + store + .events_by_contract_and_tag( + too_many_contracts.as_slice(), + "p", + FIXTURE_ALICE_PUBLIC_KEY_HEX, + 1, + ) + .await, + Err(RadrootsEventStoreError::ContractListTooLarge { .. }) + )); + + let matching_order = signed_event( + KIND_ORDER_REQUEST, + 70, + vec![ + vec!["d".to_owned(), "order-1".to_owned()], + vec!["p".to_owned(), FIXTURE_ALICE_PUBLIC_KEY_HEX.to_owned()], + ], + "{}", + ); + let wrong_tag_order = signed_event( + KIND_ORDER_REQUEST, + 71, + vec![ + vec!["d".to_owned(), "order-2".to_owned()], + vec!["p".to_owned(), event_id('b')], + ], + "{}", + ); + let same_tag_wrong_contract = signed_event( + KIND_POST, + 72, + vec![vec![ + "p".to_owned(), + FIXTURE_ALICE_PUBLIC_KEY_HEX.to_owned(), + ]], + "hello", + ); + let unsupported_same_tag = signed_event( + 999, + 73, + vec![vec![ + "p".to_owned(), + FIXTURE_ALICE_PUBLIC_KEY_HEX.to_owned(), + ]], + "unsupported", + ); + + for (event, observed_at_ms) in [ + (matching_order.clone(), 3_600), + (wrong_tag_order, 3_700), + (same_tag_wrong_contract, 3_800), + (unsupported_same_tag, 3_900), + ] { + store + .ingest_event(RadrootsEventIngest::new(event, observed_at_ms)) + .await + .expect("ingest"); + } + + let events = store + .events_by_contract_and_tag( + &["radroots.order.request.v1"], + "p", + FIXTURE_ALICE_PUBLIC_KEY_HEX, + 10, + ) + .await + .expect("contract tag query"); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_id, matching_order.id); + assert_eq!( + events[0].contract_id.as_deref(), + Some("radroots.order.request.v1") + ); + assert!(events[0].projection_eligible); + } + + #[tokio::test] async fn tag_rows_preserve_order_and_contract_metadata() { let store = RadrootsEventStore::open_memory().await.expect("open"); let event = signed_event(