diff --git a/src/nostr/lifecycle/deletion/cascade/orchestration.rs b/src/nostr/lifecycle/deletion/cascade/orchestration.rs index b93210e..106b80c 100644 --- a/src/nostr/lifecycle/deletion/cascade/orchestration.rs +++ b/src/nostr/lifecycle/deletion/cascade/orchestration.rs @@ -23,6 +23,12 @@ enum RefKey { Address(String), } +struct ParsedAddressRef { + kind: Kind, + pubkey: PublicKey, + identifier: Option, +} + #[derive(Debug, Default)] struct CascadeGraph { nodes: HashMap, @@ -139,9 +145,10 @@ impl DeletionPolicy { Ok(graph) => graph, Err(e) => { tracing::warn!(error = %e, "Cascade deletion: graph expansion failed; falling back to simple coordinate deletion"); - self.delete_coordinate_from_main_db( + self.delete_announcement_coordinate_and_maybe_state( author, announcement_addr, + identifier, deletion_created_at, &mut moved_ids, source, @@ -182,9 +189,10 @@ impl DeletionPolicy { max = MAX_CASCADE_ORPHAN_DELETES, "Cascade deletion orphan set exceeds limit; deleting announcement coordinate only" ); - self.delete_coordinate_from_main_db( + self.delete_announcement_coordinate_and_maybe_state( author, announcement_addr, + identifier, deletion_created_at, &mut moved_ids, source, @@ -222,21 +230,11 @@ impl DeletionPolicy { } // Step 5b: hard-delete the announcement coordinate itself (up to the - // deletion's created_at, per NIP-09), exactly as the simple path does. - self.delete_coordinate_from_main_db( + // deletion's created_at, per NIP-09), exactly as the simple path does, + // then clean state if this identifier is now unanchored. + self.delete_announcement_coordinate_and_maybe_state( author, announcement_addr, - deletion_created_at, - &mut moved_ids, - source, - ) - .await; - - // Step 5c: repository state (30618) is keyed by identifier, not by - // graph edges. After deleting this announcement coordinate, delete - // state events for the identifier only when no announcement for that - // identifier remains in the main DB. - self.delete_repo_state_if_identifier_is_unanchored( identifier, deletion_created_at, &mut moved_ids, @@ -371,6 +369,37 @@ impl DeletionPolicy { .await; } + async fn delete_announcement_coordinate_and_maybe_state( + &self, + author: &PublicKey, + announcement_addr: &str, + identifier: &str, + deletion_created_at: Timestamp, + moved_ids: &mut HashSet, + source: DeletionSource, + ) { + self.delete_coordinate_from_main_db( + author, + announcement_addr, + deletion_created_at, + moved_ids, + source, + ) + .await; + + // Repository state (30618) is keyed by identifier, not by graph edges. + // After deleting this announcement coordinate, delete state events for + // the identifier only when no announcement for that identifier remains + // in the main DB. This must also run on coordinate-only fallbacks. + self.delete_repo_state_if_identifier_is_unanchored( + identifier, + deletion_created_at, + moved_ids, + source, + ) + .await; + } + async fn expand_cascade_graph( &self, seed_event_ids: HashSet, @@ -522,23 +551,13 @@ async fn query_address_events( database: &SharedDatabase, address: &str, ) -> Result, String> { - let parts: Vec<&str> = address.splitn(3, ':').collect(); - if parts.len() < 2 { + let Some(parsed) = parse_accepted_address_ref(address) else { return Ok(Vec::new()); - } - let kind_num = parts[0] - .parse::() - .map_err(|e| format!("invalid address kind {address}: {e}"))?; - let pubkey = PublicKey::from_hex(parts[1]) - .map_err(|e| format!("invalid address pubkey {address}: {e}"))?; + }; - let kind = Kind::from(kind_num); - let mut filter = Filter::new().kind(kind).author(pubkey); - if parts.len() == 3 && kind.is_addressable() { - filter = filter.custom_tag( - SingleLetterTag::lowercase(Alphabet::D), - parts[2].to_string(), - ); + let mut filter = Filter::new().kind(parsed.kind).author(parsed.pubkey); + if let Some(identifier) = parsed.identifier { + filter = filter.custom_tag(SingleLetterTag::lowercase(Alphabet::D), identifier); } database @@ -548,6 +567,35 @@ async fn query_address_events( .map_err(|e| format!("query address {address} failed: {e}")) } +fn parse_accepted_address_ref(address: &str) -> Option { + let parts: Vec<&str> = address.splitn(3, ':').collect(); + if parts.len() < 2 { + return None; + } + + let kind = Kind::from(parts[0].parse::().ok()?); + let pubkey = PublicKey::from_hex(parts[1]).ok()?; + + if kind.is_addressable() { + let identifier = parts.get(2)?; + return Some(ParsedAddressRef { + kind, + pubkey, + identifier: Some((*identifier).to_string()), + }); + } + + if kind.is_replaceable() && parts.len() == 2 { + return Some(ParsedAddressRef { + kind, + pubkey, + identifier: None, + }); + } + + None +} + async fn query_incoming_event_refs( database: &SharedDatabase, event_id: EventId, @@ -772,7 +820,9 @@ fn extract_accepted_reference_keys(event: &Event) -> HashSet { } match tag_vec[0].as_str() { "a" | "A" => { - refs.insert(RefKey::Address(tag_vec[1].to_string())); + if parse_accepted_address_ref(&tag_vec[1]).is_some() { + refs.insert(RefKey::Address(tag_vec[1].to_string())); + } } "e" | "E" => { if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) { @@ -780,7 +830,9 @@ fn extract_accepted_reference_keys(event: &Event) -> HashSet { } } "q" if tag_vec[1].contains(':') => { - refs.insert(RefKey::Address(tag_vec[1].to_string())); + if parse_accepted_address_ref(&tag_vec[1]).is_some() { + refs.insert(RefKey::Address(tag_vec[1].to_string())); + } } "q" => { if let Ok(event_id) = EventId::from_hex(&tag_vec[1]) {