tangle


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

commit 3df6423d5da20734ef5c3049af5c81aa00c0ec2f
parent ab51b22ffa78549783bd985df6258157be761712
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 22:45:34 -0700

store-surreal: store raw events

Diffstat:
MCargo.lock | 4++++
Mcrates/tangle_store_surreal/Cargo.toml | 4++++
Mcrates/tangle_store_surreal/src/lib.rs | 160++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 167 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3892,8 +3892,12 @@ dependencies = [ name = "tangle_store_surreal" version = "0.1.0" dependencies = [ + "serde_json", "sha2", "surrealdb", + "tangle_protocol", + "tangle_store", + "tangle_test_support", "tokio", ] diff --git a/crates/tangle_store_surreal/Cargo.toml b/crates/tangle_store_surreal/Cargo.toml @@ -8,10 +8,14 @@ license.workspace = true description = "SurrealDB storage backend for tangle" [dependencies] +serde_json = "1" sha2 = "0.10" surrealdb = { version = "3.1.3", default-features = false, features = ["kv-mem"] } +tangle_protocol = { path = "../tangle_protocol" } +tangle_store = { path = "../tangle_store" } [dev-dependencies] +tangle_test_support = { path = "../tangle_test_support" } tokio = { version = "1", features = ["macros", "rt"] } [lints] diff --git a/crates/tangle_store_surreal/src/lib.rs b/crates/tangle_store_surreal/src/lib.rs @@ -4,6 +4,8 @@ use core::fmt; use sha2::{Digest, Sha256}; use surrealdb::Surreal; use surrealdb::engine::local::{Db, Mem}; +use tangle_protocol::{AddressCoordinate, Event, EventId, event_to_value}; +use tangle_store::{StoreEventOutcome, StoredEvent}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SurrealConnectionMode { @@ -304,7 +306,7 @@ DEFINE FIELD IF NOT EXISTS kind ON TABLE nostr_event TYPE int; DEFINE FIELD IF NOT EXISTS tags ON TABLE nostr_event TYPE array; DEFINE FIELD IF NOT EXISTS content ON TABLE nostr_event TYPE string; DEFINE FIELD IF NOT EXISTS sig ON TABLE nostr_event TYPE string; -DEFINE FIELD IF NOT EXISTS raw_json ON TABLE nostr_event TYPE object; +DEFINE FIELD IF NOT EXISTS raw_json ON TABLE nostr_event TYPE string; DEFINE FIELD IF NOT EXISTS received_at ON TABLE nostr_event TYPE int; DEFINE FIELD IF NOT EXISTS content_len ON TABLE nostr_event TYPE int; DEFINE FIELD IF NOT EXISTS tag_count ON TABLE nostr_event TYPE int; @@ -758,6 +760,72 @@ impl SurrealStore { .unwrap_or_else(|| "None".to_owned())) } + pub async fn store_raw_event( + &self, + stored: &StoredEvent, + ) -> Result<StoreEventOutcome, SurrealStoreError> { + if self.raw_event_row(stored.event().id()).await?.is_some() { + return Ok(StoreEventOutcome::Duplicate); + } + let event = stored.event(); + self.db + .query( + r#" +CREATE type::record('nostr_event', $event_id) CONTENT { + event_id: $event_id, + pubkey: $pubkey, + created_at: $created_at, + kind: $kind, + tags: $tags, + content: $content, + sig: $sig, + raw_json: $raw_json, + received_at: $received_at, + content_len: $content_len, + tag_count: $tag_count, + d_tag: $d_tag, + address_key: $address_key, + deleted: false, + hidden: false, + rejection_reason: NONE +}; +"#, + ) + .bind(("event_id", event.id().as_str())) + .bind(("pubkey", event.unsigned().pubkey().as_str())) + .bind(("created_at", event.unsigned().created_at().as_u64())) + .bind(("kind", event.unsigned().kind().as_u32())) + .bind(("tags", event_tags_json(event))) + .bind(("content", event.unsigned().content())) + .bind(("sig", event.sig().as_str())) + .bind(("raw_json", event_to_value(event).to_string())) + .bind(("received_at", stored.received_at().as_u64())) + .bind(("content_len", stored.content_len() as u64)) + .bind(("tag_count", stored.tag_count() as u64)) + .bind(("d_tag", d_tag_value(event))) + .bind(("address_key", address_key_value(event)?)) + .await + .map_err(SurrealStoreError::from)? + .check() + .map_err(SurrealStoreError::from)?; + Ok(StoreEventOutcome::Inserted) + } + + pub async fn raw_event_row( + &self, + event_id: &EventId, + ) -> Result<Option<serde_json::Value>, SurrealStoreError> { + let mut response = self + .db + .query("SELECT * FROM ONLY type::record('nostr_event', $event_id);") + .bind(("event_id", event_id.as_str())) + .await + .map_err(SurrealStoreError::from)? + .check() + .map_err(SurrealStoreError::from)?; + response.take(0).map_err(SurrealStoreError::from) + } + async fn applied_migration( &self, name: &str, @@ -802,6 +870,37 @@ impl SurrealStore { } } +fn event_tags_json(event: &Event) -> Vec<serde_json::Value> { + event + .unsigned() + .tags() + .iter() + .map(|tag| { + serde_json::Value::Array( + tag.values() + .iter() + .map(|value| serde_json::Value::String(value.clone())) + .collect(), + ) + }) + .collect() +} + +fn d_tag_value(event: &Event) -> Option<String> { + event + .unsigned() + .tags() + .iter() + .find_map(|tag| tag.indexed_pair()) + .and_then(|(name, value)| (name == "d").then(|| value.to_owned())) +} + +fn address_key_value(event: &Event) -> Result<Option<String>, SurrealStoreError> { + AddressCoordinate::from_event(event) + .map(|address| address.map(|address| address.key().to_string())) + .map_err(|message| SurrealStoreError::new(&message)) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SurrealStoreError { message: String, @@ -840,6 +939,9 @@ mod tests { SurrealMigration, SurrealMigrationError, SurrealMigrationPlan, SurrealStore, base_migration_plan, migration_tracking_schema, }; + use tangle_protocol::UnixTimestamp; + use tangle_store::{StoreEventOutcome, StoredEvent}; + use tangle_test_support::{build_fixture_event, valid_public_listing_spec}; #[test] fn memory_config_normalizes_namespace_and_database() { @@ -1473,4 +1575,60 @@ mod tests { } } } + + #[tokio::test] + async fn store_raw_event_persists_canonical_nostr_event_row() { + let store = memory_store().await; + store + .apply_plan(&base_migration_plan()) + .await + .expect("apply plan"); + let event = build_fixture_event(&valid_public_listing_spec()).expect("event"); + let stored = StoredEvent::new(event.clone(), UnixTimestamp::new(1_714_124_500)); + + assert_eq!( + store.store_raw_event(&stored).await.expect("insert"), + StoreEventOutcome::Inserted + ); + assert_eq!( + store.store_raw_event(&stored).await.expect("duplicate"), + StoreEventOutcome::Duplicate + ); + + let row = store + .raw_event_row(event.id()) + .await + .expect("row query") + .expect("row exists"); + assert_eq!(row["event_id"], event.id().as_str()); + assert_eq!(row["pubkey"], event.unsigned().pubkey().as_str()); + assert_eq!(row["created_at"], event.unsigned().created_at().as_u64()); + assert_eq!(row["kind"], event.unsigned().kind().as_u32()); + assert_eq!(row["content"], "Sweet storage carrots."); + assert_eq!(row["sig"], event.sig().as_str()); + assert_eq!(row["received_at"], 1_714_124_500_u64); + assert_eq!(row["content_len"], 22_u64); + assert_eq!(row["tag_count"], 10_u64); + assert_eq!(row["d_tag"], "listing-a"); + assert_eq!( + row["address_key"], + format!( + "{}:{}:{}", + event.unsigned().kind().as_u32(), + event.unsigned().pubkey().as_str(), + event.unsigned().tags()[0].values()[1] + ) + ); + assert_eq!(row["deleted"], false); + assert_eq!(row["hidden"], false); + assert_eq!( + row["raw_json"] + .as_str() + .expect("raw json string") + .parse::<serde_json::Value>() + .expect("raw json parses")["id"], + event.id().as_str() + ); + assert_eq!(row["tags"].as_array().expect("tags").len(), 10); + } }