diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-13 13:24:46 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-13 17:29:23 +0000 |
| commit | 1d09e4bdea7e328cf2740818df9df660c5532a99 (patch) | |
| tree | dcb758a70a2e9b84709df247cc685a2f6423094e /src/purgatory | |
| parent | a2a99d5a4137b57e4141cf2840f2f51b38035cfa (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.rs | 260 | ||||
| -rw-r--r-- | src/purgatory/sync/context.rs | 7 | ||||
| -rw-r--r-- | src/purgatory/types.rs | 39 |
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; | |||
| 17 | mod types; | 17 | mod types; |
| 18 | 18 | ||
| 19 | pub use helpers::{can_apply_state, can_satisfy_state, extract_refs_from_state, get_unpushed_refs}; | 19 | pub use helpers::{can_apply_state, can_satisfy_state, extract_refs_from_state, get_unpushed_refs}; |
| 20 | pub use types::{PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry}; | 20 | pub use types::{AnnouncementPurgatoryEntry, PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry}; |
| 21 | 21 | ||
| 22 | use dashmap::DashMap; | 22 | use dashmap::DashMap; |
| 23 | use nostr_sdk::prelude::*; | 23 | use 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)] |
| 123 | pub struct Purgatory { | 124 | pub 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 | ||
| 7 | use nostr_sdk::prelude::*; | 7 | use nostr_sdk::prelude::*; |
| 8 | use serde::{Deserialize, Serialize}; | 8 | use serde::{Deserialize, Serialize}; |
| 9 | use std::collections::HashSet; | ||
| 10 | use std::path::PathBuf; | ||
| 9 | use std::time::Instant; | 11 | use 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)] | ||
| 128 | pub 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 | } | ||