tangle


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

commit a8150c91d949c02f286240953efff5c79a97eaf3
parent bbe69bb83ea89727fd182662011959cde658c9be
Author: triesap <tyson@radroots.org>
Date:   Fri,  5 Jun 2026 22:54:41 -0700

store-surreal: apply deletion markers

Diffstat:
MCargo.lock | 1+
Mcrates/tangle_store_surreal/Cargo.toml | 1+
Mcrates/tangle_store_surreal/src/lib.rs | 277++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 276 insertions(+), 3 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3895,6 +3895,7 @@ dependencies = [ "serde_json", "sha2", "surrealdb", + "tangle_nips", "tangle_protocol", "tangle_store", "tangle_test_support", diff --git a/crates/tangle_store_surreal/Cargo.toml b/crates/tangle_store_surreal/Cargo.toml @@ -11,6 +11,7 @@ description = "SurrealDB storage backend for tangle" serde_json = "1" sha2 = "0.10" surrealdb = { version = "3.1.3", default-features = false, features = ["kv-mem"] } +tangle_nips = { path = "../tangle_nips" } tangle_protocol = { path = "../tangle_protocol" } tangle_store = { path = "../tangle_store" } diff --git a/crates/tangle_store_surreal/src/lib.rs b/crates/tangle_store_surreal/src/lib.rs @@ -4,6 +4,7 @@ use core::fmt; use sha2::{Digest, Sha256}; use surrealdb::Surreal; use surrealdb::engine::local::{Db, Mem}; +use tangle_nips::{DeletionTarget, parse_deletion_request}; use tangle_protocol::{AddressCoordinate, Event, EventId, event_to_value}; use tangle_store::{StoreEventOutcome, StoredEvent}; @@ -657,6 +658,12 @@ pub enum CurrentEventOutcome { Unchanged, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeletionMarkerOutcome { + NotDeletion, + Applied { targets: usize }, +} + #[derive(Clone)] pub struct SurrealStore { db: Surreal<Db>, @@ -952,6 +959,77 @@ UPSERT type::record('event_current', $address_key) CONTENT { response.take(0).map_err(SurrealStoreError::from) } + pub async fn apply_deletion_markers( + &self, + event: &Event, + ) -> Result<DeletionMarkerOutcome, SurrealStoreError> { + let Some(request) = + parse_deletion_request(event).map_err(|message| SurrealStoreError::new(&message))? + else { + return Ok(DeletionMarkerOutcome::NotDeletion); + }; + for target in request.targets() { + let (target_type, target_ref) = deletion_target_parts(target); + let marker_id = format!("{}:{}:{}", event.id().as_str(), target_type, target_ref); + self.db + .query( + r#" +UPSERT type::record('deletion_marker', $marker_id) CONTENT { + deletion_event_id: $deletion_event_id, + target_type: $target_type, + target_ref: $target_ref, + author_pubkey: $author_pubkey, + deletion_created_at: $deletion_created_at +}; +"#, + ) + .bind(("marker_id", marker_id)) + .bind(("deletion_event_id", event.id().as_str())) + .bind(("target_type", target_type)) + .bind(("target_ref", target_ref.as_str())) + .bind(("author_pubkey", event.unsigned().pubkey().as_str())) + .bind(( + "deletion_created_at", + event.unsigned().created_at().as_u64(), + )) + .await + .map_err(SurrealStoreError::from)? + .check() + .map_err(SurrealStoreError::from)?; + match target_type { + "event" => { + self.mark_raw_event_deleted(&target_ref, event.unsigned().pubkey().as_str()) + .await?; + } + "address" => { + self.mark_address_deleted(&target_ref, event.unsigned().pubkey().as_str()) + .await?; + } + _ => {} + } + } + Ok(DeletionMarkerOutcome::Applied { + targets: request.targets().len(), + }) + } + + pub async fn deletion_marker_rows( + &self, + deletion_event_id: &EventId, + ) -> Result<Vec<serde_json::Value>, SurrealStoreError> { + let mut response = self + .db + .query( + "SELECT * FROM deletion_marker WHERE deletion_event_id = $deletion_event_id ORDER BY target_type ASC, target_ref ASC;", + ) + .bind(("deletion_event_id", deletion_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, @@ -978,6 +1056,45 @@ UPSERT type::record('event_current', $address_key) CONTENT { .unwrap_or(false)) } + async fn mark_raw_event_deleted( + &self, + event_id: &str, + author_pubkey: &str, + ) -> Result<(), SurrealStoreError> { + self.db + .query( + "UPDATE nostr_event SET deleted = true WHERE event_id = $event_id AND pubkey = $author_pubkey;", + ) + .bind(("event_id", event_id)) + .bind(("author_pubkey", author_pubkey)) + .await + .map_err(SurrealStoreError::from)? + .check() + .map_err(SurrealStoreError::from)?; + Ok(()) + } + + async fn mark_address_deleted( + &self, + address_key: &str, + author_pubkey: &str, + ) -> Result<(), SurrealStoreError> { + self.db + .query( + "UPDATE nostr_event SET deleted = true WHERE address_key = $address_key AND pubkey = $author_pubkey;", + ) + .query( + "UPDATE event_current SET deleted = true WHERE address_key = $address_key AND pubkey = $author_pubkey;", + ) + .bind(("address_key", address_key)) + .bind(("author_pubkey", author_pubkey)) + .await + .map_err(SurrealStoreError::from)? + .check() + .map_err(SurrealStoreError::from)?; + Ok(()) + } + async fn record_migration( &self, migration: &SurrealMigration, @@ -1067,6 +1184,13 @@ fn current_event_replacement_outcome( } } +fn deletion_target_parts(target: &DeletionTarget) -> (&'static str, String) { + match target { + DeletionTarget::Event(event_id) => ("event", event_id.as_str().to_owned()), + DeletionTarget::Address(address) => ("address", address.key().to_string()), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SurrealStoreError { message: String, @@ -1101,9 +1225,9 @@ impl From<surrealdb::Error> for SurrealStoreError { #[cfg(test)] mod tests { use super::{ - CurrentEventOutcome, MigrationApplyOutcome, SurrealConfigError, SurrealConnectionConfig, - SurrealConnectionMode, SurrealMigration, SurrealMigrationError, SurrealMigrationPlan, - SurrealStore, base_migration_plan, migration_tracking_schema, + CurrentEventOutcome, DeletionMarkerOutcome, MigrationApplyOutcome, SurrealConfigError, + SurrealConnectionConfig, SurrealConnectionMode, SurrealMigration, SurrealMigrationError, + SurrealMigrationPlan, SurrealStore, base_migration_plan, migration_tracking_schema, }; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, @@ -1988,6 +2112,153 @@ mod tests { assert_eq!(addressable_row["event_id"], addressable.id().as_str()); } + #[tokio::test] + async fn apply_deletion_markers_persists_markers_and_author_scoped_deletes() { + let store = memory_store().await; + store + .apply_plan(&base_migration_plan()) + .await + .expect("apply plan"); + let pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH); + let other_pubkey = "b".repeat(PublicKeyHex::HEX_LENGTH); + let raw = synthetic_event("7", "3", &pubkey, 1_714_124_800, 1, Vec::new(), "delete me"); + let foreign_target = + synthetic_event("8", "4", &pubkey, 1_714_124_801, 1, Vec::new(), "keep me"); + let addressable_key = format!("30402:{pubkey}:listing-delete"); + let addressable = synthetic_event( + "9", + "5", + &pubkey, + 1_714_124_802, + 30_402, + vec![Tag::from_parts("d", &["listing-delete"]).expect("d tag")], + "delete listing", + ); + for event in [&raw, &foreign_target, &addressable] { + assert_eq!( + store + .store_raw_event(&StoredEvent::new( + event.clone(), + UnixTimestamp::new(1_714_124_900) + )) + .await + .expect("raw insert"), + StoreEventOutcome::Inserted + ); + } + assert_eq!( + store + .maintain_current_event(&addressable) + .await + .expect("current"), + CurrentEventOutcome::Inserted + ); + + let deletion = synthetic_event( + "b", + "6", + &pubkey, + 1_714_124_903, + 5, + vec![ + Tag::from_parts("e", &[raw.id().as_str()]).expect("e tag"), + Tag::from_parts("a", &[&addressable_key]).expect("a tag"), + ], + "remove stale events", + ); + let not_deletion = synthetic_event( + "c", + "7", + &pubkey, + 1_714_124_904, + 1, + Vec::new(), + "plain note", + ); + let unauthorized = synthetic_event( + "d", + "8", + &other_pubkey, + 1_714_124_905, + 5, + vec![Tag::from_parts("e", &[foreign_target.id().as_str()]).expect("foreign e")], + "foreign delete", + ); + + assert_eq!( + store + .apply_deletion_markers(&not_deletion) + .await + .expect("not deletion"), + DeletionMarkerOutcome::NotDeletion + ); + assert_eq!( + store + .apply_deletion_markers(&deletion) + .await + .expect("delete"), + DeletionMarkerOutcome::Applied { targets: 2 } + ); + assert_eq!( + store + .apply_deletion_markers(&unauthorized) + .await + .expect("unauthorized"), + DeletionMarkerOutcome::Applied { targets: 1 } + ); + + let markers = store + .deletion_marker_rows(deletion.id()) + .await + .expect("markers"); + assert_eq!(markers.len(), 2); + assert_eq!(markers[0]["target_type"], "address"); + assert_eq!(markers[0]["target_ref"], addressable_key); + assert_eq!(markers[0]["author_pubkey"], pubkey); + assert_eq!(markers[1]["target_type"], "event"); + assert_eq!(markers[1]["target_ref"], raw.id().as_str()); + assert_eq!(markers[1]["deletion_created_at"], 1_714_124_903_u64); + + let unauthorized_markers = store + .deletion_marker_rows(unauthorized.id()) + .await + .expect("unauthorized markers"); + assert_eq!(unauthorized_markers.len(), 1); + + assert_eq!( + store + .raw_event_row(raw.id()) + .await + .expect("raw row") + .expect("raw exists")["deleted"], + true + ); + assert_eq!( + store + .raw_event_row(addressable.id()) + .await + .expect("address row") + .expect("address exists")["deleted"], + true + ); + assert_eq!( + store + .current_event_row(&addressable_key) + .await + .expect("current row") + .expect("current exists")["deleted"], + true + ); + assert_eq!( + store + .raw_event_row(foreign_target.id()) + .await + .expect("foreign row") + .expect("foreign exists")["deleted"], + false + ); + } + fn synthetic_event( id_digit: &str, sig_digit: &str,