commit 4cf5c573dd202bc5c8106ba76d2b83a6ad4d519a
parent 41060be766604e8019fd965b07bc2fcf1fb9a193
Author: triesap <tyson@radroots.org>
Date: Thu, 19 Feb 2026 18:43:22 +0000
nostr-ndb: add query and profile lookup api
- add typed query specs and note summary output models
- add note query execution with mapped ids, authors, and payload fields
- add profile lookup by pubkey hex with metadata field extraction
- add retry-backed tests for query and profile retrieval consistency
Diffstat:
5 files changed, 212 insertions(+), 5 deletions(-)
diff --git a/nostr-ndb/src/error.rs b/nostr-ndb/src/error.rs
@@ -6,10 +6,7 @@ pub enum RadrootsNostrNdbError {
NonUtf8Path,
#[error("invalid hex for {field}: {reason}")]
- InvalidHex {
- field: &'static str,
- reason: String,
- },
+ InvalidHex { field: &'static str, reason: String },
#[error("invalid hex length for {field}: expected {expected} bytes, got {actual}")]
InvalidHexLength {
diff --git a/nostr-ndb/src/filter.rs b/nostr-ndb/src/filter.rs
@@ -135,7 +135,10 @@ impl RadrootsNostrNdbFilterSpec {
}
}
-fn parse_hex_32(value: &str, field: &'static str) -> Result<[u8; 32], RadrootsNostrNdbError> {
+pub(crate) fn parse_hex_32(
+ value: &str,
+ field: &'static str,
+) -> Result<[u8; 32], RadrootsNostrNdbError> {
let bytes = hex::decode(value).map_err(|source| RadrootsNostrNdbError::InvalidHex {
field,
reason: source.to_string(),
diff --git a/nostr-ndb/src/lib.rs b/nostr-ndb/src/lib.rs
@@ -15,6 +15,8 @@ pub mod filter;
pub mod ingest;
#[cfg(feature = "ndb")]
pub mod ndb;
+#[cfg(feature = "ndb")]
+pub mod query;
#[cfg(all(feature = "ndb", feature = "runtime-adapter"))]
pub mod runtime_adapter;
#[cfg(feature = "ndb")]
@@ -30,6 +32,10 @@ pub mod prelude {
pub use crate::ingest::RadrootsNostrNdbIngestSource;
#[cfg(feature = "ndb")]
pub use crate::ndb::RadrootsNostrNdb;
+ #[cfg(feature = "ndb")]
+ pub use crate::query::{
+ RadrootsNostrNdbNote, RadrootsNostrNdbProfile, RadrootsNostrNdbQuerySpec,
+ };
#[cfg(all(feature = "ndb", feature = "runtime-adapter"))]
pub use crate::runtime_adapter::RadrootsNostrNdbEventStoreAdapter;
#[cfg(all(feature = "ndb", feature = "rt"))]
diff --git a/nostr-ndb/src/ndb.rs b/nostr-ndb/src/ndb.rs
@@ -1,6 +1,8 @@
use crate::config::RadrootsNostrNdbConfig;
use crate::error::RadrootsNostrNdbError;
+use crate::filter::parse_hex_32;
use crate::ingest::RadrootsNostrNdbIngestSource;
+use crate::query::{RadrootsNostrNdbNote, RadrootsNostrNdbProfile, RadrootsNostrNdbQuerySpec};
use crate::subscription::{
RadrootsNostrNdbNoteKey, RadrootsNostrNdbSubscriptionHandle, RadrootsNostrNdbSubscriptionSpec,
RadrootsNostrNdbSubscriptionStream,
@@ -120,6 +122,75 @@ impl RadrootsNostrNdb {
.notes_per_await(notes_per_await.max(1));
RadrootsNostrNdbSubscriptionStream { inner: stream }
}
+
+ pub fn query_notes(
+ &self,
+ spec: &RadrootsNostrNdbQuerySpec,
+ ) -> Result<Vec<RadrootsNostrNdbNote>, RadrootsNostrNdbError> {
+ if spec.filters().is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let filters = spec
+ .filters()
+ .iter()
+ .map(|filter_spec| filter_spec.to_ndb_filter())
+ .collect::<Result<Vec<_>, _>>()?;
+ let txn = nostrdb::Transaction::new(&self.inner)?;
+ let query_results =
+ self.inner
+ .query(&txn, filters.as_slice(), spec.max_results() as i32)?;
+
+ query_results
+ .into_iter()
+ .map(|query_result| {
+ let note = query_result.note;
+ let json = note.json()?;
+ Ok(RadrootsNostrNdbNote {
+ note_key: query_result.note_key.as_u64(),
+ id_hex: hex::encode(note.id()),
+ author_hex: hex::encode(note.pubkey()),
+ kind: note.kind(),
+ created_at_unix: note.created_at(),
+ content: note.content().to_owned(),
+ json,
+ })
+ })
+ .collect::<Result<Vec<_>, nostrdb::Error>>()
+ .map_err(Into::into)
+ }
+
+ pub fn get_profile_by_pubkey_hex(
+ &self,
+ pubkey_hex: &str,
+ ) -> Result<Option<RadrootsNostrNdbProfile>, RadrootsNostrNdbError> {
+ let pubkey = parse_hex_32(pubkey_hex, "pubkey")?;
+ let txn = nostrdb::Transaction::new(&self.inner)?;
+
+ let profile_record = match self.inner.get_profile_by_pubkey(&txn, &pubkey) {
+ Ok(profile_record) => profile_record,
+ Err(nostrdb::Error::NotFound) => return Ok(None),
+ Err(source) => return Err(source.into()),
+ };
+
+ let profile = match profile_record.record().profile() {
+ Some(profile) => profile,
+ None => return Ok(None),
+ };
+
+ Ok(Some(RadrootsNostrNdbProfile {
+ profile_key: profile_record.key().map(|profile_key| profile_key.as_u64()),
+ pubkey_hex: pubkey_hex.to_owned(),
+ name: profile.name().map(ToOwned::to_owned),
+ display_name: profile.display_name().map(ToOwned::to_owned),
+ about: profile.about().map(ToOwned::to_owned),
+ picture: profile.picture().map(ToOwned::to_owned),
+ banner: profile.banner().map(ToOwned::to_owned),
+ website: profile.website().map(ToOwned::to_owned),
+ nip05: profile.nip05().map(ToOwned::to_owned),
+ lud16: profile.lud16().map(ToOwned::to_owned),
+ }))
+ }
}
#[cfg(test)]
@@ -127,7 +198,9 @@ mod tests {
use super::*;
use crate::filter::RadrootsNostrNdbFilterSpec;
use crate::ingest::RadrootsNostrNdbIngestSource;
+ use crate::query::RadrootsNostrNdbQuerySpec;
use radroots_nostr::prelude::{RadrootsNostrEventBuilder, RadrootsNostrKeys};
+ use radroots_nostr::prelude::{RadrootsNostrMetadata, radroots_nostr_build_metadata_event};
use std::time::Duration;
use tempfile::TempDir;
@@ -245,4 +318,72 @@ mod tests {
.expect("wait should succeed");
assert!(!notes.is_empty());
}
+
+ #[test]
+ fn query_notes_returns_ingested_results() {
+ let tmp_dir = TempDir::new().expect("tempdir should open");
+ let db_dir = tmp_dir.path().join("ndb");
+ let config = RadrootsNostrNdbConfig::new(&db_dir);
+ let ndb = RadrootsNostrNdb::open(config).expect("database should open");
+
+ let keys = RadrootsNostrKeys::generate();
+ let event = RadrootsNostrEventBuilder::text_note("query note")
+ .sign_with_keys(&keys)
+ .expect("event should sign");
+ ndb.ingest_event(&event, RadrootsNostrNdbIngestSource::client())
+ .expect("ingest should succeed");
+
+ let query_spec = RadrootsNostrNdbQuerySpec::text_notes(Some(50), None, 50);
+ let mut notes = Vec::new();
+ for _ in 0..40 {
+ notes = ndb.query_notes(&query_spec).expect("query should succeed");
+ if !notes.is_empty() {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(25));
+ }
+ assert!(!notes.is_empty());
+ assert!(
+ notes
+ .iter()
+ .any(|note| note.id_hex == event.id.to_hex() && note.content == "query note")
+ );
+ }
+
+ #[test]
+ fn profile_lookup_returns_metadata_fields() {
+ let tmp_dir = TempDir::new().expect("tempdir should open");
+ let db_dir = tmp_dir.path().join("ndb");
+ let config = RadrootsNostrNdbConfig::new(&db_dir);
+ let ndb = RadrootsNostrNdb::open(config).expect("database should open");
+
+ let keys = RadrootsNostrKeys::generate();
+ let pubkey_hex = keys.public_key().to_hex();
+ let metadata = RadrootsNostrMetadata::new()
+ .name("alice")
+ .display_name("Alice")
+ .about("coffee operator")
+ .lud16("alice@example.com");
+ let metadata_event = radroots_nostr_build_metadata_event(&metadata)
+ .sign_with_keys(&keys)
+ .expect("metadata event should sign");
+ ndb.ingest_event(&metadata_event, RadrootsNostrNdbIngestSource::client())
+ .expect("ingest should succeed");
+
+ let mut profile = None;
+ for _ in 0..40 {
+ profile = ndb
+ .get_profile_by_pubkey_hex(pubkey_hex.as_str())
+ .expect("profile lookup should succeed");
+ if profile.is_some() {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(25));
+ }
+ let profile = profile.expect("profile should exist");
+ assert_eq!(profile.pubkey_hex, pubkey_hex);
+ assert_eq!(profile.name.as_deref(), Some("alice"));
+ assert_eq!(profile.display_name.as_deref(), Some("Alice"));
+ assert_eq!(profile.lud16.as_deref(), Some("alice@example.com"));
+ }
}
diff --git a/nostr-ndb/src/query.rs b/nostr-ndb/src/query.rs
@@ -0,0 +1,60 @@
+use crate::filter::RadrootsNostrNdbFilterSpec;
+
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub struct RadrootsNostrNdbQuerySpec {
+ filters: Vec<RadrootsNostrNdbFilterSpec>,
+ max_results: u32,
+}
+
+impl RadrootsNostrNdbQuerySpec {
+ pub fn new(filters: Vec<RadrootsNostrNdbFilterSpec>, max_results: u32) -> Self {
+ Self {
+ filters,
+ max_results: max_results.max(1),
+ }
+ }
+
+ pub fn single(filter: RadrootsNostrNdbFilterSpec, max_results: u32) -> Self {
+ Self::new(vec![filter], max_results)
+ }
+
+ pub fn text_notes(limit: Option<u64>, since_unix: Option<u64>, max_results: u32) -> Self {
+ Self::single(
+ RadrootsNostrNdbFilterSpec::text_notes(limit, since_unix),
+ max_results,
+ )
+ }
+
+ pub fn filters(&self) -> &[RadrootsNostrNdbFilterSpec] {
+ &self.filters
+ }
+
+ pub fn max_results(&self) -> u32 {
+ self.max_results
+ }
+}
+
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub struct RadrootsNostrNdbNote {
+ pub note_key: u64,
+ pub id_hex: String,
+ pub author_hex: String,
+ pub kind: u32,
+ pub created_at_unix: u64,
+ pub content: String,
+ pub json: String,
+}
+
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub struct RadrootsNostrNdbProfile {
+ pub profile_key: Option<u64>,
+ pub pubkey_hex: String,
+ pub name: Option<String>,
+ pub display_name: Option<String>,
+ pub about: Option<String>,
+ pub picture: Option<String>,
+ pub banner: Option<String>,
+ pub website: Option<String>,
+ pub nip05: Option<String>,
+ pub lud16: Option<String>,
+}