tangle


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

commit f76d67a836e92e60fec682f0a5f219e6c05c877d
parent 28d3f49017353aa3c23c0ae87f99420c636e9ec5
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 04:07:27 -0700

store-surreal: project labels

Diffstat:
Mcrates/tangle_store_surreal/src/lib.rs | 529+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 517 insertions(+), 12 deletions(-)

diff --git a/crates/tangle_store_surreal/src/lib.rs b/crates/tangle_store_surreal/src/lib.rs @@ -6,10 +6,11 @@ use std::collections::BTreeSet; use surrealdb::Surreal; use surrealdb::engine::local::{Db, Mem, RocksDb}; use tangle_nips::{ - CommentEvent, DeletionTarget, ForumThreadEvent, ListingProjection, ListingProjectionEvaluation, - LongFormEvent, LongFormKind, NIP99_DRAFT_LISTING_KIND, NIP99_PUBLIC_LISTING_KIND, - ReactionEvent, ReactionValue, evaluate_listing_projection, parse_comment_event, - parse_deletion_request, parse_forum_thread_event, parse_long_form_event, parse_reaction_event, + CommentEvent, DeletionTarget, ForumThreadEvent, LabelEvent, ListingProjection, + ListingProjectionEvaluation, LongFormEvent, LongFormKind, NIP99_DRAFT_LISTING_KIND, + NIP99_PUBLIC_LISTING_KIND, ReactionEvent, ReactionValue, evaluate_listing_projection, + parse_comment_event, parse_deletion_request, parse_forum_thread_event, parse_label_event, + parse_long_form_event, parse_reaction_event, }; use tangle_protocol::{AddressCoordinate, Event, EventId, Filter, UnixTimestamp, event_to_value}; use tangle_store::{StoreEventOutcome, StoredEvent}; @@ -918,6 +919,13 @@ pub enum ForumThreadProjectionOutcome { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LabelProjectionOutcome { + NotLabel, + Ineligible, + Projected, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HiddenEventOutcome { NotFound, Hidden, @@ -1163,6 +1171,48 @@ impl ForumThreadProjectionQuery { } } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LabelProjectionQuery { + target_type: Option<String>, + target_ref: Option<String>, + namespace: Option<String>, + label: Option<String>, + pubkey: Option<String>, + limit: Option<u64>, +} + +impl LabelProjectionQuery { + pub fn new() -> Self { + Self::default() + } + + pub fn with_target(mut self, target_type: &str, target_ref: &str) -> Self { + self.target_type = Some(target_type.to_owned()); + self.target_ref = Some(target_ref.to_owned()); + self + } + + pub fn with_namespace(mut self, namespace: &str) -> Self { + self.namespace = Some(namespace.to_owned()); + self + } + + pub fn with_label(mut self, label: &str) -> Self { + self.label = Some(label.to_owned()); + self + } + + pub fn with_pubkey(mut self, pubkey: &str) -> Self { + self.pubkey = Some(pubkey.to_owned()); + self + } + + pub fn with_limit(mut self, limit: u64) -> Self { + self.limit = Some(limit); + self + } +} + #[derive(Clone)] pub struct SurrealStore { db: Surreal<Db>, @@ -2551,6 +2601,132 @@ UPSERT type::record('search_doc', $thread_id) CONTENT { response.take(0).map_err(SurrealStoreError::from) } + pub async fn project_label( + &self, + event: &Event, + projected_at: UnixTimestamp, + ) -> Result<LabelProjectionOutcome, SurrealStoreError> { + let label = match parse_label_event(event) { + Ok(Some(label)) => label, + Ok(None) => return Ok(LabelProjectionOutcome::NotLabel), + Err(_) => return Ok(LabelProjectionOutcome::Ineligible), + }; + let fields = label_projection_fields(&label, projected_at); + self.db + .query("DELETE label_projection WHERE event_id = $event_id;") + .bind(("event_id", label.event_id().as_str())) + .await + .map_err(SurrealStoreError::from)? + .check() + .map_err(SurrealStoreError::from)?; + for field in fields { + self.db + .query( + r#" +UPSERT type::record('label_projection', $label_id) CONTENT { + label_id: $label_id, + event_id: $event_id, + pubkey: $pubkey, + created_at: $created_at, + content: $content, + namespace: $namespace, + label: $label, + target_type: $target_type, + target_ref: $target_ref, + hidden: false, + deleted: false, + projected_at: $projected_at +}; +"#, + ) + .bind(("label_id", field.label_id.as_str())) + .bind(("event_id", field.event_id.as_str())) + .bind(("pubkey", field.pubkey.as_str())) + .bind(("created_at", field.created_at)) + .bind(("content", field.content.as_str())) + .bind(("namespace", field.namespace.as_str())) + .bind(("label", field.label.as_str())) + .bind(("target_type", field.target_type.as_str())) + .bind(("target_ref", field.target_ref.as_str())) + .bind(("projected_at", field.projected_at)) + .await + .map_err(SurrealStoreError::from)? + .check() + .map_err(SurrealStoreError::from)?; + } + Ok(LabelProjectionOutcome::Projected) + } + + pub async fn label_projection_rows( + &self, + event_id: &EventId, + ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { + let mut response = self + .db + .query( + "SELECT * FROM label_projection WHERE event_id = $event_id ORDER BY target_type ASC, target_ref ASC, namespace ASC, label ASC, label_id ASC;", + ) + .bind(("event_id", event_id.as_str())) + .await + .map_err(SurrealStoreError::from)? + .check() + .map_err(SurrealStoreError::from)?; + response.take(0).map_err(SurrealStoreError::from) + } + + pub async fn query_label_projections( + &self, + query: &LabelProjectionQuery, + ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { + let mut statement = + "SELECT * FROM label_projection WHERE hidden = false AND deleted = false".to_owned(); + if query.target_type.is_some() { + statement.push_str(" AND target_type = $target_type"); + } + if query.target_ref.is_some() { + statement.push_str(" AND target_ref = $target_ref"); + } + if query.namespace.is_some() { + statement.push_str(" AND namespace = $namespace"); + } + if query.label.is_some() { + statement.push_str(" AND label = $label"); + } + if query.pubkey.is_some() { + statement.push_str(" AND pubkey = $pubkey"); + } + statement.push_str(" ORDER BY created_at DESC, event_id ASC, label_id ASC"); + if query.limit.is_some() { + statement.push_str(" LIMIT $limit"); + } + statement.push(';'); + let mut surreal_query = self.db.query(statement); + if let Some(value) = &query.target_type { + surreal_query = surreal_query.bind(("target_type", value.as_str())); + } + if let Some(value) = &query.target_ref { + surreal_query = surreal_query.bind(("target_ref", value.as_str())); + } + if let Some(value) = &query.namespace { + surreal_query = surreal_query.bind(("namespace", value.as_str())); + } + if let Some(value) = &query.label { + surreal_query = surreal_query.bind(("label", value.as_str())); + } + if let Some(value) = &query.pubkey { + surreal_query = surreal_query.bind(("pubkey", value.as_str())); + } + if let Some(value) = query.limit { + surreal_query = surreal_query.bind(("limit", value)); + } + let mut response = surreal_query + .await + .map_err(SurrealStoreError::from)? + .check() + .map_err(SurrealStoreError::from)?; + response.take(0).map_err(SurrealStoreError::from) + } + pub async fn project_listing_helpers( &self, event: &Event, @@ -2852,6 +3028,7 @@ UPDATE long_form_current SET hidden = true WHERE event_id = $event_id; UPDATE long_form_topic SET hidden = true WHERE event_id = $event_id; UPDATE forum_thread_projection SET hidden = true WHERE event_id = $event_id; UPDATE forum_thread_topic SET hidden = true WHERE event_id = $event_id; +UPDATE label_projection SET hidden = true WHERE event_id = $event_id; UPDATE search_doc SET visible = false WHERE event_id = $event_id OR current_event_id = $event_id; "#, ) @@ -2907,6 +3084,7 @@ UPDATE long_form_current SET hidden = false WHERE event_id = $event_id; UPDATE long_form_topic SET hidden = false WHERE event_id = $event_id; UPDATE forum_thread_projection SET hidden = false WHERE event_id = $event_id; UPDATE forum_thread_topic SET hidden = false WHERE event_id = $event_id; +UPDATE label_projection SET hidden = false WHERE event_id = $event_id; UPDATE search_doc SET visible = true WHERE (event_id = $event_id OR current_event_id = $event_id) AND (status = "active" OR status = "published" OR status = "open"); UPDATE search_doc SET visible = false WHERE (event_id = $event_id OR current_event_id = $event_id) AND status != "active" AND status != "published" AND status != "open"; "#, @@ -3507,6 +3685,7 @@ UPDATE long_form_current SET deleted = true WHERE event_id = $event_id AND autho UPDATE long_form_topic SET deleted = true WHERE event_id = $event_id; UPDATE forum_thread_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey; UPDATE forum_thread_topic SET deleted = true WHERE event_id = $event_id; +UPDATE label_projection SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey; UPDATE search_doc SET visible = false WHERE event_id = $event_id OR current_event_id = $event_id; "#, ) @@ -3827,6 +4006,19 @@ struct ForumThreadProjectionFields { search_title: String, } +struct LabelProjectionFields { + label_id: String, + event_id: String, + pubkey: String, + created_at: u64, + content: String, + namespace: String, + label: String, + target_type: String, + target_ref: String, + projected_at: u64, +} + struct ListingCurrentFields { listing_key: String, listing_key_hash: String, @@ -4014,6 +4206,63 @@ fn fallback_thread_title(thread: &ForumThreadEvent) -> String { fallback } +fn label_projection_fields( + label: &LabelEvent, + projected_at: UnixTimestamp, +) -> Vec<LabelProjectionFields> { + let mut pairs = BTreeSet::new(); + for target in label.targets() { + let target_type = target.target_type().to_owned(); + let target_ref = target.target_ref(); + for value in label.labels() { + pairs.insert(( + target_type.clone(), + target_ref.clone(), + value.namespace().to_owned(), + value.value().to_owned(), + )); + } + } + pairs + .into_iter() + .map(|(target_type, target_ref, namespace, value)| { + let event_id = label.event_id().as_str().to_owned(); + let pubkey = label.pubkey().as_str().to_owned(); + LabelProjectionFields { + label_id: label_projection_id( + &event_id, + &target_type, + &target_ref, + &namespace, + &value, + ), + event_id, + pubkey, + created_at: label.created_at().as_u64(), + content: label.content().to_owned(), + namespace, + label: value, + target_type, + target_ref, + projected_at: projected_at.as_u64(), + } + }) + .collect() +} + +fn label_projection_id( + event_id: &str, + target_type: &str, + target_ref: &str, + namespace: &str, + label: &str, +) -> String { + checksum( + &serde_json::to_string(&[event_id, target_type, target_ref, namespace, label]) + .expect("label projection identity serializes"), + ) +} + struct SearchDocumentFields { doc_key: String, address_key: Option<String>, @@ -4241,17 +4490,17 @@ mod tests { use super::{ CommentProjectionOutcome, CommentProjectionQuery, CurrentEventOutcome, DeletionMarkerOutcome, DurableRateLimitDecision, ForumThreadProjectionOutcome, - ForumThreadProjectionQuery, HiddenEventOutcome, ListingCurrentOutcome, - ListingHelperOutcome, ListingProjectionQuery, ListingRevisionOutcome, - LongFormProjectionOutcome, LongFormProjectionQuery, MigrationApplyOutcome, - ReactionProjectionOutcome, SearchDocumentOutcome, SearchDocumentQuery, SurrealConfigError, - SurrealConnectionConfig, SurrealConnectionMode, SurrealMigration, SurrealMigrationError, - SurrealMigrationPlan, SurrealStore, SurrealStoreError, base_migration_plan, - migration_tracking_schema, + ForumThreadProjectionQuery, HiddenEventOutcome, LabelProjectionOutcome, + LabelProjectionQuery, ListingCurrentOutcome, ListingHelperOutcome, ListingProjectionQuery, + ListingRevisionOutcome, LongFormProjectionOutcome, LongFormProjectionQuery, + MigrationApplyOutcome, ReactionProjectionOutcome, SearchDocumentOutcome, + SearchDocumentQuery, SurrealConfigError, SurrealConnectionConfig, SurrealConnectionMode, + SurrealMigration, SurrealMigrationError, SurrealMigrationPlan, SurrealStore, + SurrealStoreError, base_migration_plan, migration_tracking_schema, }; use tangle_nips::{ ListingProjectionEvaluation, NIP7D_THREAD_KIND, NIP23_LONG_FORM_DRAFT_KIND, - NIP23_LONG_FORM_KIND, + NIP23_LONG_FORM_KIND, NIP32_LABEL_KIND, }; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, @@ -7317,6 +7566,239 @@ mod tests { } #[tokio::test] + async fn project_labels_persists_deterministic_target_label_rows() { + let store = memory_store().await; + store + .apply_plan(&base_migration_plan()) + .await + .expect("apply plan"); + let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); + let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); + let label = listing_label( + &listing, + 1_714_125_100, + &["reviewed", "market"], + "moderator labels listing", + ); + let invalid_label = build_fixture_event_from_parts( + FixtureKey::Buyer, + 1_714_125_099, + u64::from(NIP32_LABEL_KIND), + vec![vec!["l".to_owned(), "reviewed".to_owned()]], + "missing target", + ) + .expect("invalid label"); + + assert_eq!( + store + .project_label(&listing, UnixTimestamp::new(1_714_125_101)) + .await + .expect("not label"), + LabelProjectionOutcome::NotLabel + ); + assert_eq!( + store + .project_label(&invalid_label, UnixTimestamp::new(1_714_125_101)) + .await + .expect("invalid label"), + LabelProjectionOutcome::Ineligible + ); + assert_eq!( + store + .project_label(&label, UnixTimestamp::new(1_714_125_102)) + .await + .expect("project label"), + LabelProjectionOutcome::Projected + ); + assert_eq!( + store + .project_label(&label, UnixTimestamp::new(1_714_125_103)) + .await + .expect("reproject label"), + LabelProjectionOutcome::Projected + ); + + let rows = store + .label_projection_rows(label.id()) + .await + .expect("label rows"); + assert_eq!(rows.len(), 4); + assert!( + rows.iter() + .all(|row| row["label_id"].as_str().expect("label id").len() == 64) + ); + assert!( + rows.iter() + .all(|row| row["event_id"] == label.id().as_str()) + ); + assert!( + rows.iter() + .all(|row| row["pubkey"] == FixtureKey::Buyer.public_key().as_str()) + ); + assert!( + rows.iter() + .all(|row| row["created_at"] == 1_714_125_100_u64) + ); + assert!( + rows.iter() + .all(|row| row["content"] == "moderator labels listing") + ); + assert!( + rows.iter() + .all(|row| row["namespace"] == "com.radroots.moderation") + ); + assert!( + rows.iter() + .all(|row| row["projected_at"] == 1_714_125_103_u64) + ); + + let address_rows = store + .query_label_projections( + &LabelProjectionQuery::new() + .with_target("address", &listing_key) + .with_namespace("com.radroots.moderation") + .with_label("reviewed") + .with_pubkey(FixtureKey::Buyer.public_key().as_str()) + .with_limit(5), + ) + .await + .expect("label query"); + assert_eq!(address_rows.len(), 1); + assert_eq!(address_rows[0]["target_type"], "address"); + assert_eq!(address_rows[0]["target_ref"], listing_key); + assert_eq!(address_rows[0]["label"], "reviewed"); + assert_eq!(address_rows[0]["hidden"], false); + assert_eq!(address_rows[0]["deleted"], false); + + let event_rows = store + .query_label_projections( + &LabelProjectionQuery::new() + .with_target("event", listing.id().as_str()) + .with_namespace("com.radroots.moderation") + .with_limit(5), + ) + .await + .expect("event label query"); + assert_eq!(event_rows.len(), 2); + } + + #[tokio::test] + async fn label_projection_visibility_tracks_hidden_and_deleted_events() { + let store = memory_store().await; + store + .apply_plan(&base_migration_plan()) + .await + .expect("apply plan"); + let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing"); + let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); + let label = listing_label(&listing, 1_714_125_110, &["reviewed"], "label under review"); + let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); + store + .store_raw_event(&StoredEvent::new( + label.clone(), + UnixTimestamp::new(1_714_125_111), + )) + .await + .expect("raw label"); + store + .project_label(&label, UnixTimestamp::new(1_714_125_112)) + .await + .expect("project label"); + + let query = LabelProjectionQuery::new() + .with_target("address", &listing_key) + .with_namespace("com.radroots.moderation") + .with_label("reviewed"); + assert_eq!( + store + .query_label_projections(&query) + .await + .expect("visible labels") + .len(), + 1 + ); + assert_eq!( + store + .hide_event( + label.id(), + "label moderation", + "admin_api", + &admin_pubkey, + UnixTimestamp::new(1_714_125_113), + ) + .await + .expect("hide label"), + HiddenEventOutcome::Hidden + ); + assert!( + store + .query_label_projections(&query) + .await + .expect("hidden labels") + .is_empty() + ); + assert!( + store + .label_projection_rows(label.id()) + .await + .expect("label rows") + .iter() + .all(|row| row["hidden"] == true) + ); + assert_eq!( + store + .unhide_event( + label.id(), + "label restored", + &admin_pubkey, + UnixTimestamp::new(1_714_125_114), + ) + .await + .expect("unhide label"), + HiddenEventOutcome::Unhidden + ); + assert_eq!( + store + .query_label_projections(&query) + .await + .expect("restored labels") + .len(), + 1 + ); + + let deletion = build_fixture_event_from_parts( + FixtureKey::Buyer, + 1_714_125_115, + 5, + vec![vec!["e".to_owned(), label.id().as_str().to_owned()]], + "", + ) + .expect("deletion event"); + assert_eq!( + store + .apply_deletion_markers(&deletion) + .await + .expect("delete label"), + DeletionMarkerOutcome::Applied { targets: 1 } + ); + assert!( + store + .query_label_projections(&query) + .await + .expect("deleted labels") + .is_empty() + ); + assert!( + store + .label_projection_rows(label.id()) + .await + .expect("label rows") + .iter() + .all(|row| row["deleted"] == true) + ); + } + + #[tokio::test] async fn hidden_event_overlay_excludes_events_from_public_read_models() { let store = memory_store().await; store @@ -7904,6 +8386,29 @@ mod tests { .expect("reaction event") } + fn listing_label(listing: &Event, created_at: u64, labels: &[&str], content: &str) -> Event { + let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str()); + let namespace = "com.radroots.moderation"; + let mut tags = vec![ + vec!["L".to_owned(), namespace.to_owned()], + vec!["e".to_owned(), listing.id().as_str().to_owned()], + vec!["a".to_owned(), listing_key], + ]; + tags.extend( + labels + .iter() + .map(|label| vec!["l".to_owned(), (*label).to_owned(), namespace.to_owned()]), + ); + build_fixture_event_from_parts( + FixtureKey::Buyer, + created_at, + u64::from(NIP32_LABEL_KIND), + tags, + content, + ) + .expect("label event") + } + fn long_form_article( created_at: u64, d: &str,