upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/purgatory
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-13 13:24:46 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-13 17:29:23 +0000
commit1d09e4bdea7e328cf2740818df9df660c5532a99 (patch)
treedcb758a70a2e9b84709df247cc685a2f6423094e /src/purgatory
parenta2a99d5a4137b57e4141cf2840f2f51b38035cfa (diff)
feat: implement announcement purgatory core (breaks archive sync test)
Route new announcements to purgatory instead of accepting immediately. Announcements are promoted to the database when git data arrives, ensuring we only serve announcements for repos with actual content. Implemented: - AnnouncementPurgatoryEntry type and DashMap store - Route new announcements to purgatory (replacement announcements skip) - Promote announcements on git data arrival (process_purgatory_announcements) - Authorization checks purgatory announcements (fetch_repository_data_with_purgatory) - State policy uses purgatory announcements for maintainer validation - Cleanup task handles announcement expiry - Updated count()/cleanup() to 3-tuples Known broken: - test_archive_read_only_creates_bare_repo fails: sync module does not treat purgatory announcements as confirmed repos, so per-repo sync (state events, PRs) is never triggered for purgatory announcements - Announcement persistence (save/restore) not implemented - SyncLevel (StateOnly vs Full) not implemented - Soft expiry two-phase not implemented - Expiry extension on state event / git auth not wired up
Diffstat (limited to 'src/purgatory')
-rw-r--r--src/purgatory/mod.rs260
-rw-r--r--src/purgatory/sync/context.rs7
-rw-r--r--src/purgatory/types.rs39
3 files changed, 273 insertions, 33 deletions
diff --git a/src/purgatory/mod.rs b/src/purgatory/mod.rs
index 47798a6..3b5514b 100644
--- a/src/purgatory/mod.rs
+++ b/src/purgatory/mod.rs
@@ -17,7 +17,7 @@ pub mod sync;
17mod types; 17mod types;
18 18
19pub use helpers::{can_apply_state, can_satisfy_state, extract_refs_from_state, get_unpushed_refs}; 19pub use helpers::{can_apply_state, can_satisfy_state, extract_refs_from_state, get_unpushed_refs};
20pub use types::{PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry}; 20pub use types::{AnnouncementPurgatoryEntry, PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry};
21 21
22use dashmap::DashMap; 22use dashmap::DashMap;
23use nostr_sdk::prelude::*; 23use nostr_sdk::prelude::*;
@@ -100,7 +100,8 @@ struct PurgatoryState {
100 100
101/// Main purgatory structure holding events awaiting git data. 101/// Main purgatory structure holding events awaiting git data.
102/// 102///
103/// Provides thread-safe concurrent access to two separate stores: 103/// Provides thread-safe concurrent access to three separate stores:
104/// - Announcements indexed by (pubkey, identifier)
104/// - State events indexed by repository identifier 105/// - State events indexed by repository identifier
105/// - PR events indexed by event ID 106/// - PR events indexed by event ID
106/// 107///
@@ -121,6 +122,10 @@ struct PurgatoryState {
121/// that we've already determined have no git data available. 122/// that we've already determined have no git data available.
122#[derive(Clone)] 123#[derive(Clone)]
123pub struct Purgatory { 124pub struct Purgatory {
125 /// Repository announcements (kind 30617) indexed by (owner pubkey, identifier).
126 /// Key: (PublicKey, String) where String is the repository identifier.
127 announcement_purgatory: Arc<DashMap<(PublicKey, String), AnnouncementPurgatoryEntry>>,
128
124 /// State events (kind 30618) indexed by repository identifier. 129 /// State events (kind 30618) indexed by repository identifier.
125 /// Multiple state events can wait for the same identifier (different maintainers). 130 /// Multiple state events can wait for the same identifier (different maintainers).
126 state_events: Arc<DashMap<String, Vec<StatePurgatoryEntry>>>, 131 state_events: Arc<DashMap<String, Vec<StatePurgatoryEntry>>>,
@@ -145,6 +150,7 @@ impl Purgatory {
145 /// Create a new empty purgatory. 150 /// Create a new empty purgatory.
146 pub fn new(git_data_path: impl Into<PathBuf>) -> Self { 151 pub fn new(git_data_path: impl Into<PathBuf>) -> Self {
147 Self { 152 Self {
153 announcement_purgatory: Arc::new(DashMap::new()),
148 state_events: Arc::new(DashMap::new()), 154 state_events: Arc::new(DashMap::new()),
149 pr_events: Arc::new(DashMap::new()), 155 pr_events: Arc::new(DashMap::new()),
150 sync_queue: Arc::new(DashMap::new()), 156 sync_queue: Arc::new(DashMap::new()),
@@ -513,9 +519,171 @@ impl Purgatory {
513 self.pr_events.remove(event_id); 519 self.pr_events.remove(event_id);
514 } 520 }
515 521
522 // =========================================================================
523 // Announcement Purgatory Methods
524 // =========================================================================
525
526 /// Add a repository announcement to purgatory.
527 ///
528 /// The announcement will be held until git data arrives, at which point
529 /// it will be promoted to the database and served to clients.
530 ///
531 /// # Arguments
532 /// * `event` - The announcement event (kind 30617)
533 /// * `identifier` - The repository identifier from the 'd' tag
534 /// * `owner` - The owner pubkey (event author)
535 /// * `repo_path` - Path to the bare git repository
536 /// * `relays` - Relay URLs from the announcement (for sync registration)
537 pub fn add_announcement(
538 &self,
539 event: Event,
540 identifier: String,
541 owner: PublicKey,
542 repo_path: PathBuf,
543 relays: HashSet<String>,
544 ) {
545 let now = Instant::now();
546 let entry = AnnouncementPurgatoryEntry {
547 event,
548 identifier: identifier.clone(),
549 owner,
550 repo_path,
551 relays,
552 created_at: now,
553 expires_at: now + DEFAULT_EXPIRY,
554 soft_expired: false,
555 };
556
557 let key = (owner, identifier);
558 self.announcement_purgatory.insert(key.clone(), entry);
559
560 tracing::debug!(
561 owner = %key.0,
562 identifier = %key.1,
563 "Added announcement to purgatory"
564 );
565 }
566
567 /// Find an announcement in purgatory by owner and identifier.
568 ///
569 /// # Arguments
570 /// * `owner` - The owner pubkey
571 /// * `identifier` - The repository identifier
572 ///
573 /// # Returns
574 /// The announcement entry if found, None otherwise
575 pub fn find_announcement(&self, owner: &PublicKey, identifier: &str) -> Option<AnnouncementPurgatoryEntry> {
576 let key = (*owner, identifier.to_string());
577 self.announcement_purgatory.get(&key).map(|entry| entry.clone())
578 }
579
580 /// Get all announcements in purgatory for a given identifier.
581 ///
582 /// This is used for authorization - state events and git pushes need to
583 /// check purgatory announcements for maintainer validation.
584 ///
585 /// # Arguments
586 /// * `identifier` - The repository identifier
587 ///
588 /// # Returns
589 /// Vector of announcement entries for this identifier
590 pub fn get_announcements_by_identifier(&self, identifier: &str) -> Vec<AnnouncementPurgatoryEntry> {
591 self.announcement_purgatory
592 .iter()
593 .filter(|entry| entry.key().1 == identifier)
594 .map(|entry| entry.value().clone())
595 .collect()
596 }
597
598 /// Remove an announcement from purgatory.
599 ///
600 /// # Arguments
601 /// * `owner` - The owner pubkey
602 /// * `identifier` - The repository identifier
603 pub fn remove_announcement(&self, owner: &PublicKey, identifier: &str) {
604 let key = (*owner, identifier.to_string());
605 self.announcement_purgatory.remove(&key);
606 tracing::debug!(
607 owner = %owner,
608 identifier = %identifier,
609 "Removed announcement from purgatory"
610 );
611 }
612
613 /// Promote an announcement from purgatory to active status.
614 ///
615 /// This is called when git data arrives. The announcement event is returned
616 /// so it can be saved to the database.
617 ///
618 /// # Arguments
619 /// * `owner` - The owner pubkey
620 /// * `identifier` - The repository identifier
621 ///
622 /// # Returns
623 /// The announcement event if found, None otherwise
624 pub fn promote_announcement(&self, owner: &PublicKey, identifier: &str) -> Option<Event> {
625 let key = (*owner, identifier.to_string());
626 self.announcement_purgatory.remove(&key).map(|(_, entry)| {
627 tracing::info!(
628 owner = %owner,
629 identifier = %identifier,
630 "Promoted announcement from purgatory to database"
631 );
632 entry.event
633 })
634 }
635
636 /// Check if there's an announcement in purgatory for the given owner and identifier.
637 ///
638 /// # Arguments
639 /// * `owner` - The owner pubkey
640 /// * `identifier` - The repository identifier
641 ///
642 /// # Returns
643 /// true if an announcement exists in purgatory, false otherwise
644 pub fn has_purgatory_announcement(&self, owner: &PublicKey, identifier: &str) -> bool {
645 let key = (*owner, identifier.to_string());
646 self.announcement_purgatory.contains_key(&key)
647 }
648
649 /// Extend the expiry for an announcement in purgatory.
650 ///
651 /// This is called when state events arrive for a purgatory announcement,
652 /// indicating the repository is actively receiving metadata.
653 ///
654 /// # Arguments
655 /// * `owner` - The owner pubkey
656 /// * `identifier` - The repository identifier
657 /// * `duration` - Minimum duration to guarantee from now
658 pub fn extend_announcement_expiry(&self, owner: &PublicKey, identifier: &str, duration: Duration) {
659 let key = (*owner, identifier.to_string());
660 if let Some(mut entry) = self.announcement_purgatory.get_mut(&key) {
661 let now = Instant::now();
662 let new_expiry = now + duration;
663 if entry.expires_at < new_expiry {
664 entry.expires_at = new_expiry;
665 // If soft-expired, revive it
666 if entry.soft_expired {
667 entry.soft_expired = false;
668 tracing::debug!(
669 owner = %owner,
670 identifier = %identifier,
671 "Revived soft-expired announcement"
672 );
673 }
674 }
675 }
676 }
677
678 /// Get count of announcements in purgatory.
679 pub fn announcement_count(&self) -> usize {
680 self.announcement_purgatory.len()
681 }
682
516 /// Get all event IDs currently stored in purgatory AND previously expired events. 683 /// Get all event IDs currently stored in purgatory AND previously expired events.
517 /// 684 ///
518 /// Returns a HashSet of all event IDs for: 685 /// Returns a HashSet of all event IDs for:
686 /// - Announcements currently held in purgatory
519 /// - State events currently held in purgatory 687 /// - State events currently held in purgatory
520 /// - PR events currently held in purgatory 688 /// - PR events currently held in purgatory
521 /// - Events that previously expired from purgatory without finding git data 689 /// - Events that previously expired from purgatory without finding git data
@@ -530,6 +698,11 @@ impl Purgatory {
530 pub fn event_ids(&self) -> HashSet<EventId> { 698 pub fn event_ids(&self) -> HashSet<EventId> {
531 let mut ids = HashSet::new(); 699 let mut ids = HashSet::new();
532 700
701 // Collect announcement event IDs
702 for entry in self.announcement_purgatory.iter() {
703 ids.insert(entry.value().event.id);
704 }
705
533 // Collect state event IDs 706 // Collect state event IDs
534 for entry in self.state_events.iter() { 707 for entry in self.state_events.iter() {
535 for state_entry in entry.value().iter() { 708 for state_entry in entry.value().iter() {
@@ -609,9 +782,28 @@ impl Purgatory {
609 /// will be filtered out during future negentropy/REQ sync operations. 782 /// will be filtered out during future negentropy/REQ sync operations.
610 /// 783 ///
611 /// # Returns 784 /// # Returns
612 /// Tuple of (num_state_removed, num_pr_removed) 785 /// Tuple of (num_announcement_removed, num_state_removed, num_pr_removed)
613 pub fn cleanup(&self) -> (usize, usize) { 786 pub fn cleanup(&self) -> (usize, usize, usize) {
614 let now = Instant::now(); 787 let now = Instant::now();
788
789 // Remove expired announcements and mark them as expired
790 let expired_announcements: Vec<(PublicKey, String, EventId)> = self
791 .announcement_purgatory
792 .iter()
793 .filter(|entry| entry.value().expires_at <= now)
794 .map(|entry| {
795 let key = entry.key();
796 let event_id = entry.value().event.id;
797 (key.0.clone(), key.1.clone(), event_id)
798 })
799 .collect();
800
801 let announcement_removed = expired_announcements.len();
802 for (owner, identifier, event_id) in expired_announcements {
803 self.mark_expired(event_id);
804 self.announcement_purgatory.remove(&(owner, identifier));
805 }
806
615 let mut state_removed = 0; 807 let mut state_removed = 0;
616 808
617 // Remove expired state events and mark them as expired 809 // Remove expired state events and mark them as expired
@@ -655,17 +847,17 @@ impl Purgatory {
655 self.pr_events.remove(&event_id_str); 847 self.pr_events.remove(&event_id_str);
656 } 848 }
657 849
658 (state_removed, pr_removed) 850 (announcement_removed, state_removed, pr_removed)
659 } 851 }
660 852
661 /// Remove expired entries from purgatory (legacy method). 853 /// Remove expired entries from purgatory (legacy method).
662 /// 854 ///
663 /// # Returns 855 /// # Returns
664 /// Total number of entries removed (state + PR events) 856 /// Total number of entries removed (announcement + state + PR events)
665 #[deprecated(since = "0.1.0", note = "Use cleanup() instead for separate counts")] 857 #[deprecated(since = "0.1.0", note = "Use cleanup() instead for separate counts")]
666 pub fn remove_expired(&self) -> usize { 858 pub fn remove_expired(&self) -> usize {
667 let (state, pr) = self.cleanup(); 859 let (announcement, state, pr) = self.cleanup();
668 state + pr 860 announcement + state + pr
669 } 861 }
670 862
671 /// Remove old expired event records. 863 /// Remove old expired event records.
@@ -699,11 +891,12 @@ impl Purgatory {
699 /// Get current count of entries in purgatory. 891 /// Get current count of entries in purgatory.
700 /// 892 ///
701 /// # Returns 893 /// # Returns
702 /// Tuple of (state_event_count, pr_event_count) 894 /// Tuple of (announcement_count, state_event_count, pr_event_count)
703 pub fn count(&self) -> (usize, usize) { 895 pub fn count(&self) -> (usize, usize, usize) {
896 let announcement_count = self.announcement_purgatory.len();
704 let state_count: usize = self.state_events.iter().map(|e| e.value().len()).sum(); 897 let state_count: usize = self.state_events.iter().map(|e| e.value().len()).sum();
705 let pr_count = self.pr_events.len(); 898 let pr_count = self.pr_events.len();
706 (state_count, pr_count) 899 (announcement_count, state_count, pr_count)
707 } 900 }
708 901
709 /// Get count of expired events being tracked. 902 /// Get count of expired events being tracked.
@@ -717,6 +910,7 @@ impl Purgatory {
717 /// Clear all entries from purgatory (for testing). 910 /// Clear all entries from purgatory (for testing).
718 #[cfg(test)] 911 #[cfg(test)]
719 pub fn clear(&self) { 912 pub fn clear(&self) {
913 self.announcement_purgatory.clear();
720 self.state_events.clear(); 914 self.state_events.clear();
721 self.pr_events.clear(); 915 self.pr_events.clear();
722 self.sync_queue.clear(); 916 self.sync_queue.clear();
@@ -990,7 +1184,8 @@ mod tests {
990 #[test] 1184 #[test]
991 fn test_purgatory_creation() { 1185 fn test_purgatory_creation() {
992 let purgatory = Purgatory::new(PathBuf::new()); 1186 let purgatory = Purgatory::new(PathBuf::new());
993 let (state_count, pr_count) = purgatory.count(); 1187 let (announcement_count, state_count, pr_count) = purgatory.count();
1188 assert_eq!(announcement_count, 0);
994 assert_eq!(state_count, 0); 1189 assert_eq!(state_count, 0);
995 assert_eq!(pr_count, 0); 1190 assert_eq!(pr_count, 0);
996 } 1191 }
@@ -1008,7 +1203,8 @@ mod tests {
1008 purgatory.add_state(event.clone(), "test-repo".to_string(), keys.public_key()); 1203 purgatory.add_state(event.clone(), "test-repo".to_string(), keys.public_key());
1009 purgatory.add_pr(event, "test-event-id".to_string(), "abc123".to_string()); 1204 purgatory.add_pr(event, "test-event-id".to_string(), "abc123".to_string());
1010 1205
1011 let (state_count, pr_count) = purgatory.count(); 1206 let (announcement_count, state_count, pr_count) = purgatory.count();
1207 assert_eq!(announcement_count, 0);
1012 assert_eq!(state_count, 1); 1208 assert_eq!(state_count, 1);
1013 assert_eq!(pr_count, 1); 1209 assert_eq!(pr_count, 1);
1014 } 1210 }
@@ -1213,7 +1409,7 @@ fn test_cleanup_removes_expired_entries() {
1213 purgatory.add_pr_placeholder("pr-456".to_string(), "commit-def".to_string()); 1409 purgatory.add_pr_placeholder("pr-456".to_string(), "commit-def".to_string());
1214 1410
1215 // Verify entries are there 1411 // Verify entries are there
1216 let (state_count, pr_count) = purgatory.count(); 1412 let (_, state_count, pr_count) = purgatory.count();
1217 assert_eq!(state_count, 1); 1413 assert_eq!(state_count, 1);
1218 assert_eq!(pr_count, 2); 1414 assert_eq!(pr_count, 2);
1219 1415
@@ -1231,14 +1427,14 @@ fn test_cleanup_removes_expired_entries() {
1231 } 1427 }
1232 1428
1233 // Run cleanup 1429 // Run cleanup
1234 let (state_removed, pr_removed) = purgatory.cleanup(); 1430 let (_, state_removed, pr_removed) = purgatory.cleanup();
1235 1431
1236 // Verify counts 1432 // Verify counts
1237 assert_eq!(state_removed, 1); 1433 assert_eq!(state_removed, 1);
1238 assert_eq!(pr_removed, 2); 1434 assert_eq!(pr_removed, 2);
1239 1435
1240 // Verify entries are gone 1436 // Verify entries are gone
1241 let (state_count, pr_count) = purgatory.count(); 1437 let (_, state_count, pr_count) = purgatory.count();
1242 assert_eq!(state_count, 0); 1438 assert_eq!(state_count, 0);
1243 assert_eq!(pr_count, 0); 1439 assert_eq!(pr_count, 0);
1244} 1440}
@@ -1260,14 +1456,14 @@ fn test_cleanup_preserves_non_expired_entries() {
1260 purgatory.add_pr(pr_event, "pr-123".to_string(), "commit-abc".to_string()); 1456 purgatory.add_pr(pr_event, "pr-123".to_string(), "commit-abc".to_string());
1261 1457
1262 // Run cleanup 1458 // Run cleanup
1263 let (state_removed, pr_removed) = purgatory.cleanup(); 1459 let (_, state_removed, pr_removed) = purgatory.cleanup();
1264 1460
1265 // Nothing should be removed 1461 // Nothing should be removed
1266 assert_eq!(state_removed, 0); 1462 assert_eq!(state_removed, 0);
1267 assert_eq!(pr_removed, 0); 1463 assert_eq!(pr_removed, 0);
1268 1464
1269 // Verify entries are still there 1465 // Verify entries are still there
1270 let (state_count, pr_count) = purgatory.count(); 1466 let (_, state_count, pr_count) = purgatory.count();
1271 assert_eq!(state_count, 1); 1467 assert_eq!(state_count, 1);
1272 assert_eq!(pr_count, 1); 1468 assert_eq!(pr_count, 1);
1273} 1469}
@@ -1314,14 +1510,14 @@ fn test_cleanup_mixed_expired_and_fresh() {
1314 } 1510 }
1315 1511
1316 // Run cleanup 1512 // Run cleanup
1317 let (state_removed, pr_removed) = purgatory.cleanup(); 1513 let (_, state_removed, pr_removed) = purgatory.cleanup();
1318 1514
1319 // One of each should be removed 1515 // One of each should be removed
1320 assert_eq!(state_removed, 1); 1516 assert_eq!(state_removed, 1);
1321 assert_eq!(pr_removed, 1); 1517 assert_eq!(pr_removed, 1);
1322 1518
1323 // Verify remaining counts 1519 // Verify remaining counts
1324 let (state_count, pr_count) = purgatory.count(); 1520 let (_, state_count, pr_count) = purgatory.count();
1325 assert_eq!(state_count, 1); // One state event remains 1521 assert_eq!(state_count, 1); // One state event remains
1326 assert_eq!(pr_count, 1); // One PR event remains 1522 assert_eq!(pr_count, 1); // One PR event remains
1327} 1523}
@@ -1391,7 +1587,7 @@ fn test_expired_event_tracking() {
1391 } 1587 }
1392 1588
1393 // Run cleanup 1589 // Run cleanup
1394 let (state_removed, pr_removed) = purgatory.cleanup(); 1590 let (_, state_removed, pr_removed) = purgatory.cleanup();
1395 assert_eq!(state_removed, 1); 1591 assert_eq!(state_removed, 1);
1396 assert_eq!(pr_removed, 1); 1592 assert_eq!(pr_removed, 1);
1397 1593
@@ -1501,7 +1697,7 @@ fn test_expired_events_prevent_readdition() {
1501 } 1697 }
1502 1698
1503 // Event should NOT be re-added 1699 // Event should NOT be re-added
1504 let (state_count, _) = purgatory.count(); 1700 let (_, state_count, _) = purgatory.count();
1505 assert_eq!(state_count, 0, "Event should not be re-added to purgatory"); 1701 assert_eq!(state_count, 0, "Event should not be re-added to purgatory");
1506} 1702}
1507 1703
@@ -1520,7 +1716,7 @@ fn test_pr_placeholder_not_marked_expired() {
1520 } 1716 }
1521 1717
1522 // Run cleanup 1718 // Run cleanup
1523 let (_, pr_removed) = purgatory.cleanup(); 1719 let (_, _, pr_removed) = purgatory.cleanup();
1524 assert_eq!(pr_removed, 1); 1720 assert_eq!(pr_removed, 1);
1525 1721
1526 // Expired count should be 0 (placeholders don't have event IDs to track) 1722 // Expired count should be 0 (placeholders don't have event IDs to track)
@@ -1606,7 +1802,7 @@ async fn test_save_and_restore_state_events() {
1606 assert!(!state_file.exists()); 1802 assert!(!state_file.exists());
1607 1803
1608 // Verify state events were restored 1804 // Verify state events were restored
1609 let (state_count, _) = purgatory2.count(); 1805 let (_, state_count, _) = purgatory2.count();
1610 assert_eq!(state_count, 2); 1806 assert_eq!(state_count, 2);
1611 1807
1612 let restored_entries = purgatory2.find_state("test-repo"); 1808 let restored_entries = purgatory2.find_state("test-repo");
@@ -1662,7 +1858,7 @@ async fn test_save_and_restore_pr_events() {
1662 purgatory2.restore_from_disk(&state_file).unwrap(); 1858 purgatory2.restore_from_disk(&state_file).unwrap();
1663 1859
1664 // Verify PR event was restored 1860 // Verify PR event was restored
1665 let (_, pr_count) = purgatory2.count(); 1861 let (_, _, pr_count) = purgatory2.count();
1666 assert_eq!(pr_count, 1); 1862 assert_eq!(pr_count, 1);
1667 1863
1668 let restored_entry = purgatory2.find_pr("pr-event-id").unwrap(); 1864 let restored_entry = purgatory2.find_pr("pr-event-id").unwrap();
@@ -1691,7 +1887,7 @@ async fn test_save_and_restore_pr_placeholders() {
1691 purgatory2.restore_from_disk(&state_file).unwrap(); 1887 purgatory2.restore_from_disk(&state_file).unwrap();
1692 1888
1693 // Verify placeholder was restored 1889 // Verify placeholder was restored
1694 let (_, pr_count) = purgatory2.count(); 1890 let (_, _, pr_count) = purgatory2.count();
1695 assert_eq!(pr_count, 1); 1891 assert_eq!(pr_count, 1);
1696 1892
1697 let restored_entry = purgatory2.find_pr("placeholder-id").unwrap(); 1893 let restored_entry = purgatory2.find_pr("placeholder-id").unwrap();
@@ -1769,7 +1965,7 @@ async fn test_save_and_restore_empty_purgatory() {
1769 purgatory2.restore_from_disk(&state_file).unwrap(); 1965 purgatory2.restore_from_disk(&state_file).unwrap();
1770 1966
1771 // Verify purgatory is still empty 1967 // Verify purgatory is still empty
1772 let (state_count, pr_count) = purgatory2.count(); 1968 let (_, state_count, pr_count) = purgatory2.count();
1773 assert_eq!(state_count, 0); 1969 assert_eq!(state_count, 0);
1774 assert_eq!(pr_count, 0); 1970 assert_eq!(pr_count, 0);
1775 assert_eq!(purgatory2.expired_count(), 0); 1971 assert_eq!(purgatory2.expired_count(), 0);
@@ -1789,7 +1985,7 @@ async fn test_restore_missing_file() {
1789 assert!(result.is_err()); 1985 assert!(result.is_err());
1790 1986
1791 // Purgatory should remain empty 1987 // Purgatory should remain empty
1792 let (state_count, pr_count) = purgatory.count(); 1988 let (_, state_count, pr_count) = purgatory.count();
1793 assert_eq!(state_count, 0); 1989 assert_eq!(state_count, 0);
1794 assert_eq!(pr_count, 0); 1990 assert_eq!(pr_count, 0);
1795} 1991}
@@ -1811,7 +2007,7 @@ async fn test_restore_corrupted_json() {
1811 assert!(result.is_err()); 2007 assert!(result.is_err());
1812 2008
1813 // Purgatory should remain empty 2009 // Purgatory should remain empty
1814 let (state_count, pr_count) = purgatory.count(); 2010 let (_, state_count, pr_count) = purgatory.count();
1815 assert_eq!(state_count, 0); 2011 assert_eq!(state_count, 0);
1816 assert_eq!(pr_count, 0); 2012 assert_eq!(pr_count, 0);
1817} 2013}
@@ -2044,7 +2240,7 @@ async fn test_mixed_pr_events_and_placeholders() {
2044 purgatory2.restore_from_disk(&state_file).unwrap(); 2240 purgatory2.restore_from_disk(&state_file).unwrap();
2045 2241
2046 // Verify both were restored correctly 2242 // Verify both were restored correctly
2047 let (_, pr_count) = purgatory2.count(); 2243 let (_, _, pr_count) = purgatory2.count();
2048 assert_eq!(pr_count, 2); 2244 assert_eq!(pr_count, 2);
2049 2245
2050 // Verify PR event 2246 // Verify PR event
@@ -2141,7 +2337,7 @@ async fn test_comprehensive_roundtrip() {
2141 purgatory.cleanup(); 2337 purgatory.cleanup();
2142 2338
2143 // Verify initial state 2339 // Verify initial state
2144 let (state_count, pr_count) = purgatory.count(); 2340 let (_, state_count, pr_count) = purgatory.count();
2145 assert_eq!(state_count, 2); // state1, state2 (expired_event was cleaned up) 2341 assert_eq!(state_count, 2); // state1, state2 (expired_event was cleaned up)
2146 assert_eq!(pr_count, 2); // pr-1, pr-2 2342 assert_eq!(pr_count, 2); // pr-1, pr-2
2147 assert_eq!(purgatory.expired_count(), 1); // expired_event 2343 assert_eq!(purgatory.expired_count(), 1); // expired_event
@@ -2154,7 +2350,7 @@ async fn test_comprehensive_roundtrip() {
2154 purgatory2.restore_from_disk(&state_file).unwrap(); 2350 purgatory2.restore_from_disk(&state_file).unwrap();
2155 2351
2156 // Verify all data was restored correctly 2352 // Verify all data was restored correctly
2157 let (state_count2, pr_count2) = purgatory2.count(); 2353 let (_, state_count2, pr_count2) = purgatory2.count();
2158 assert_eq!(state_count2, 2); 2354 assert_eq!(state_count2, 2);
2159 assert_eq!(pr_count2, 2); 2355 assert_eq!(pr_count2, 2);
2160 assert_eq!(purgatory2.expired_count(), 1); 2356 assert_eq!(purgatory2.expired_count(), 1);
diff --git a/src/purgatory/sync/context.rs b/src/purgatory/sync/context.rs
index 33c2d12..778cdb8 100644
--- a/src/purgatory/sync/context.rs
+++ b/src/purgatory/sync/context.rs
@@ -279,7 +279,12 @@ impl SyncContext for RealSyncContext {
279 } 279 }
280 280
281 async fn fetch_repository_data(&self, identifier: &str) -> Result<RepositoryData> { 281 async fn fetch_repository_data(&self, identifier: &str) -> Result<RepositoryData> {
282 crate::git::authorization::fetch_repository_data(&self.database, identifier).await 282 crate::git::authorization::fetch_repository_data_with_purgatory(
283 &self.database,
284 &self.purgatory,
285 identifier,
286 )
287 .await
283 } 288 }
284 289
285 fn collect_needed_oids(&self, identifier: &str) -> HashSet<String> { 290 fn collect_needed_oids(&self, identifier: &str) -> HashSet<String> {
diff --git a/src/purgatory/types.rs b/src/purgatory/types.rs
index 919504b..d891bc9 100644
--- a/src/purgatory/types.rs
+++ b/src/purgatory/types.rs
@@ -6,6 +6,8 @@
6 6
7use nostr_sdk::prelude::*; 7use nostr_sdk::prelude::*;
8use serde::{Deserialize, Serialize}; 8use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10use std::path::PathBuf;
9use std::time::Instant; 11use std::time::Instant;
10 12
11/// Default value for Instant fields during deserialization 13/// Default value for Instant fields during deserialization
@@ -113,3 +115,40 @@ pub struct PrPurgatoryEntry {
113 #[serde(skip, default = "instant_now")] 115 #[serde(skip, default = "instant_now")]
114 pub expires_at: Instant, 116 pub expires_at: Instant,
115} 117}
118
119/// Entry for a repository announcement (kind 30617) waiting in purgatory.
120///
121/// Announcements are held in purgatory until git data arrives, proving
122/// the repository has actual content. This prevents serving announcements
123/// for empty repositories.
124///
125/// Note: `Instant` fields cannot be serialized directly. Use the `persistence`
126/// module to convert to/from serializable wrapper types.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct AnnouncementPurgatoryEntry {
129 /// The nostr announcement event (kind 30617)
130 pub event: Event,
131
132 /// The repository identifier from the event's 'd' tag
133 pub identifier: String,
134
135 /// The owner pubkey (event author)
136 pub owner: PublicKey,
137
138 /// Path to the bare git repository
139 pub repo_path: PathBuf,
140
141 /// Relay URLs from the announcement (for sync registration)
142 pub relays: HashSet<String>,
143
144 /// When this entry was added to purgatory
145 #[serde(skip, default = "instant_now")]
146 pub created_at: Instant,
147
148 /// Expiry deadline (30 min from creation, may be extended)
149 #[serde(skip, default = "instant_now")]
150 pub expires_at: Instant,
151
152 /// Whether the bare repo has been deleted (soft expiry)
153 pub soft_expired: bool,
154}