upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/git/authorization.rs38
-rw-r--r--src/git/sync.rs110
-rw-r--r--src/main.rs8
-rw-r--r--src/nostr/builder.rs23
-rw-r--r--src/nostr/policy/announcement.rs117
-rw-r--r--src/nostr/policy/state.rs10
-rw-r--r--src/purgatory/mod.rs260
-rw-r--r--src/purgatory/sync/context.rs7
-rw-r--r--src/purgatory/types.rs39
-rw-r--r--src/sync/mod.rs68
10 files changed, 630 insertions, 50 deletions
diff --git a/src/git/authorization.rs b/src/git/authorization.rs
index e174b51..9d53c4f 100644
--- a/src/git/authorization.rs
+++ b/src/git/authorization.rs
@@ -287,6 +287,39 @@ pub async fn fetch_repository_data(
287 }) 287 })
288} 288}
289 289
290/// Fetch repository data including announcements from purgatory
291///
292/// This combines database announcements with purgatory announcements,
293/// which is needed for authorization when the announcement hasn't been
294/// promoted yet (no git data has arrived).
295pub async fn fetch_repository_data_with_purgatory(
296 database: &SharedDatabase,
297 purgatory: &crate::purgatory::Purgatory,
298 identifier: &str,
299) -> Result<RepositoryData> {
300 // First, fetch from database
301 let mut repo_data = fetch_repository_data(database, identifier).await?;
302
303 // Then, add announcements from purgatory
304 let purgatory_announcements = purgatory.get_announcements_by_identifier(identifier);
305 let purgatory_count = purgatory_announcements.len();
306
307 for entry in purgatory_announcements {
308 if let Ok(announcement) = RepositoryAnnouncement::from_event(entry.event) {
309 repo_data.announcements.push(announcement);
310 }
311 }
312
313 debug!(
314 "Fetched repository data with purgatory: {} announcements ({} from purgatory), {} states",
315 repo_data.announcements.len(),
316 purgatory_count,
317 repo_data.states.len()
318 );
319
320 Ok(repo_data)
321}
322
290pub fn pubkey_authorised_for_repo_owners( 323pub fn pubkey_authorised_for_repo_owners(
291 pubkey: &PublicKey, 324 pubkey: &PublicKey,
292 db_repo_data: &RepositoryData, 325 db_repo_data: &RepositoryData,
@@ -539,8 +572,9 @@ pub async fn get_state_authorization_for_specific_owner_repo(
539 use crate::git::list_refs; 572 use crate::git::list_refs;
540 use crate::purgatory::RefUpdate; 573 use crate::purgatory::RefUpdate;
541 574
542 // Fetch announcements only - we don't need database states 575 // Fetch announcements from database AND purgatory - needed for authorization
543 let repo_data = fetch_repository_data(database, identifier).await?; 576 // when the announcement hasn't been promoted yet (no git data has arrived)
577 let repo_data = fetch_repository_data_with_purgatory(database, purgatory, identifier).await?;
544 578
545 if repo_data.announcements.is_empty() { 579 if repo_data.announcements.is_empty() {
546 return Ok(AuthorizationResult::denied( 580 return Ok(AuthorizationResult::denied(
diff --git a/src/git/sync.rs b/src/git/sync.rs
index e8e9655..13f30b6 100644
--- a/src/git/sync.rs
+++ b/src/git/sync.rs
@@ -51,6 +51,8 @@ use crate::purgatory::{can_apply_state, Purgatory};
51/// or from purgatory sync fetching OIDs from remote servers). 51/// or from purgatory sync fetching OIDs from remote servers).
52#[derive(Debug, Default, Clone)] 52#[derive(Debug, Default, Clone)]
53pub struct ProcessResult { 53pub struct ProcessResult {
54 /// Number of announcements released from purgatory
55 pub announcements_released: usize,
54 /// Number of state events released from purgatory 56 /// Number of state events released from purgatory
55 pub states_released: usize, 57 pub states_released: usize,
56 /// Number of PR events released from purgatory 58 /// Number of PR events released from purgatory
@@ -70,11 +72,12 @@ pub struct ProcessResult {
70impl ProcessResult { 72impl ProcessResult {
71 /// Check if any events were released 73 /// Check if any events were released
72 pub fn released_any(&self) -> bool { 74 pub fn released_any(&self) -> bool {
73 self.states_released > 0 || self.prs_released > 0 75 self.announcements_released > 0 || self.states_released > 0 || self.prs_released > 0
74 } 76 }
75 77
76 /// Merge another ProcessResult into this one 78 /// Merge another ProcessResult into this one
77 pub fn merge(&mut self, other: ProcessResult) { 79 pub fn merge(&mut self, other: ProcessResult) {
80 self.announcements_released += other.announcements_released;
78 self.states_released += other.states_released; 81 self.states_released += other.states_released;
79 self.prs_released += other.prs_released; 82 self.prs_released += other.prs_released;
80 self.repos_synced += other.repos_synced; 83 self.repos_synced += other.repos_synced;
@@ -836,6 +839,18 @@ pub async fn process_newly_available_git_data(
836 "Processing newly available git data" 839 "Processing newly available git data"
837 ); 840 );
838 841
842 // Process announcements from purgatory
843 let announcement_result = process_purgatory_announcements(
844 &identifier,
845 source_repo_path,
846 database,
847 local_relay,
848 purgatory,
849 git_data_path,
850 )
851 .await;
852 result.merge(announcement_result);
853
839 // Process state events from purgatory 854 // Process state events from purgatory
840 let state_result = process_purgatory_state_events( 855 let state_result = process_purgatory_state_events(
841 &identifier, 856 &identifier,
@@ -863,6 +878,7 @@ pub async fn process_newly_available_git_data(
863 if result.released_any() { 878 if result.released_any() {
864 info!( 879 info!(
865 identifier = %identifier, 880 identifier = %identifier,
881 announcements_released = result.announcements_released,
866 states_released = result.states_released, 882 states_released = result.states_released,
867 prs_released = result.prs_released, 883 prs_released = result.prs_released,
868 repos_synced = result.repos_synced, 884 repos_synced = result.repos_synced,
@@ -1250,6 +1266,90 @@ async fn process_purgatory_pr_events(
1250 result 1266 result
1251} 1267}
1252 1268
1269/// Process announcements from purgatory that can now be promoted.
1270///
1271/// When git data arrives for a repository, any announcements in purgatory
1272/// for that repository should be promoted to the database and served to clients.
1273async fn process_purgatory_announcements(
1274 identifier: &str,
1275 source_repo_path: &Path,
1276 database: &SharedDatabase,
1277 local_relay: Option<&nostr_relay_builder::LocalRelay>,
1278 purgatory: &Purgatory,
1279 git_data_path: &Path,
1280) -> ProcessResult {
1281 let mut result = ProcessResult::default();
1282
1283 // Extract owner pubkey from the source repo path
1284 let owner_pubkey = match extract_owner_from_repo_path(source_repo_path, git_data_path) {
1285 Some(npub) => npub,
1286 None => {
1287 debug!(
1288 identifier = %identifier,
1289 "Could not extract owner from repo path"
1290 );
1291 return result;
1292 }
1293 };
1294
1295 // Parse the npub back to PublicKey
1296 let owner = match nostr_sdk::PublicKey::parse(&owner_pubkey) {
1297 Ok(pk) => pk,
1298 Err(e) => {
1299 warn!(
1300 identifier = %identifier,
1301 owner_pubkey = %owner_pubkey,
1302 error = %e,
1303 "Failed to parse owner pubkey"
1304 );
1305 result.errors.push(format!("Failed to parse owner pubkey: {}", e));
1306 return result;
1307 }
1308 };
1309
1310 // Check if there's an announcement in purgatory for this owner and identifier
1311 let announcement_event = purgatory.promote_announcement(&owner, identifier);
1312
1313 if let Some(event) = announcement_event {
1314 // Save to database
1315 match database.save_event(&event).await {
1316 Ok(_) => {
1317 info!(
1318 identifier = %identifier,
1319 event_id = %event.id,
1320 "Promoted announcement from purgatory to database"
1321 );
1322
1323 // Notify WebSocket subscribers
1324 if let Some(relay) = local_relay {
1325 if relay.notify_event(event.clone()) {
1326 debug!(
1327 identifier = %identifier,
1328 event_id = %event.id,
1329 "Broadcast announcement event to WebSocket listeners"
1330 );
1331 }
1332 }
1333
1334 result.announcements_released += 1;
1335 }
1336 Err(e) => {
1337 warn!(
1338 identifier = %identifier,
1339 event_id = %event.id,
1340 error = %e,
1341 "Failed to save announcement to database"
1342 );
1343 result
1344 .errors
1345 .push(format!("Failed to save announcement: {}", e));
1346 }
1347 }
1348 }
1349
1350 result
1351}
1352
1253/// Extract owner pubkey from a repository path. 1353/// Extract owner pubkey from a repository path.
1254/// 1354///
1255/// Given a path like `{git_data_path}/{npub}/{identifier}.git`, extracts the npub. 1355/// Given a path like `{git_data_path}/{npub}/{identifier}.git`, extracts the npub.
@@ -1271,6 +1371,7 @@ mod tests {
1271 #[test] 1371 #[test]
1272 fn test_process_result_default() { 1372 fn test_process_result_default() {
1273 let result = ProcessResult::default(); 1373 let result = ProcessResult::default();
1374 assert_eq!(result.announcements_released, 0);
1274 assert_eq!(result.states_released, 0); 1375 assert_eq!(result.states_released, 0);
1275 assert_eq!(result.prs_released, 0); 1376 assert_eq!(result.prs_released, 0);
1276 assert_eq!(result.repos_synced, 0); 1377 assert_eq!(result.repos_synced, 0);
@@ -1282,6 +1383,10 @@ mod tests {
1282 let mut result = ProcessResult::default(); 1383 let mut result = ProcessResult::default();
1283 assert!(!result.released_any()); 1384 assert!(!result.released_any());
1284 1385
1386 result.announcements_released = 1;
1387 assert!(result.released_any());
1388
1389 result.announcements_released = 0;
1285 result.states_released = 1; 1390 result.states_released = 1;
1286 assert!(result.released_any()); 1391 assert!(result.released_any());
1287 1392
@@ -1293,6 +1398,7 @@ mod tests {
1293 #[test] 1398 #[test]
1294 fn test_process_result_merge() { 1399 fn test_process_result_merge() {
1295 let mut result1 = ProcessResult { 1400 let mut result1 = ProcessResult {
1401 announcements_released: 0,
1296 states_released: 1, 1402 states_released: 1,
1297 prs_released: 2, 1403 prs_released: 2,
1298 repos_synced: 3, 1404 repos_synced: 3,
@@ -1303,6 +1409,7 @@ mod tests {
1303 }; 1409 };
1304 1410
1305 let result2 = ProcessResult { 1411 let result2 = ProcessResult {
1412 announcements_released: 5,
1306 states_released: 10, 1413 states_released: 10,
1307 prs_released: 20, 1414 prs_released: 20,
1308 repos_synced: 30, 1415 repos_synced: 30,
@@ -1314,6 +1421,7 @@ mod tests {
1314 1421
1315 result1.merge(result2); 1422 result1.merge(result2);
1316 1423
1424 assert_eq!(result1.announcements_released, 5);
1317 assert_eq!(result1.states_released, 11); 1425 assert_eq!(result1.states_released, 11);
1318 assert_eq!(result1.prs_released, 22); 1426 assert_eq!(result1.prs_released, 22);
1319 assert_eq!(result1.repos_synced, 33); 1427 assert_eq!(result1.repos_synced, 33);
diff --git a/src/main.rs b/src/main.rs
index 5e5b83a..ab6ede7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -142,11 +142,11 @@ async fn main() -> Result<()> {
142 let mut interval = tokio::time::interval(Duration::from_secs(60)); 142 let mut interval = tokio::time::interval(Duration::from_secs(60));
143 loop { 143 loop {
144 interval.tick().await; 144 interval.tick().await;
145 let (state_removed, pr_removed) = cleanup_purgatory.cleanup(); 145 let (announcement_removed, state_removed, pr_removed) = cleanup_purgatory.cleanup();
146 if state_removed > 0 || pr_removed > 0 { 146 if announcement_removed > 0 || state_removed > 0 || pr_removed > 0 {
147 info!( 147 info!(
148 "Purgatory cleanup: removed {} state events, {} PR events", 148 "Purgatory cleanup: removed {} announcements, {} state events, {} PR events",
149 state_removed, pr_removed 149 announcement_removed, state_removed, pr_removed
150 ); 150 );
151 } 151 }
152 } 152 }
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs
index 34014db..aff12a6 100644
--- a/src/nostr/builder.rs
+++ b/src/nostr/builder.rs
@@ -138,6 +138,29 @@ impl Nip34WritePolicy {
138 } 138 }
139 } 139 }
140 } 140 }
141 AnnouncementResult::AcceptPurgatory => {
142 // New announcement - add to purgatory
143 match self.announcement_policy.add_to_purgatory(event) {
144 Ok(()) => {
145 tracing::info!(
146 "Accepted announcement to purgatory: {} (waiting for git data)",
147 event_id_str
148 );
149 WritePolicyResult::Reject {
150 status: true, // Client sees OK
151 message: "purgatory: won't be served until git data arrives".into(),
152 }
153 }
154 Err(e) => {
155 tracing::warn!(
156 "Failed to add announcement to purgatory {}: {}",
157 event_id_str,
158 e
159 );
160 WritePolicyResult::reject(e)
161 }
162 }
163 }
141 AnnouncementResult::AcceptMaintainer => { 164 AnnouncementResult::AcceptMaintainer => {
142 // Parse announcement to get details for logging 165 // Parse announcement to get details for logging
143 match RepositoryAnnouncement::from_event(event.clone()) { 166 match RepositoryAnnouncement::from_event(event.clone()) {
diff --git a/src/nostr/policy/announcement.rs b/src/nostr/policy/announcement.rs
index 15a6e58..1118497 100644
--- a/src/nostr/policy/announcement.rs
+++ b/src/nostr/policy/announcement.rs
@@ -3,6 +3,7 @@
3/// Handles validation of NIP-34 repository announcements (kind 30617) 3/// Handles validation of NIP-34 repository announcements (kind 30617)
4/// according to GRASP-01 specification. 4/// according to GRASP-01 specification.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; 5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6use std::collections::HashSet;
6 7
7use super::PolicyContext; 8use super::PolicyContext;
8use crate::config::Config; 9use crate::config::Config;
@@ -11,12 +12,14 @@ use crate::nostr::events::{validate_announcement, RepositoryAnnouncement};
11/// Result of announcement policy evaluation 12/// Result of announcement policy evaluation
12#[derive(Debug, Clone, PartialEq)] 13#[derive(Debug, Clone, PartialEq)]
13pub enum AnnouncementResult { 14pub enum AnnouncementResult {
14 /// Accept: Event lists our service (GRASP-01 compliant) 15 /// Accept: Event lists our service (GRASP-01 compliant) - replacement announcement
15 Accept, 16 Accept,
16 /// Accept as maintainer: Event accepted via maintainer exception (multi-maintainer) 17 /// Accept as maintainer: Event accepted via maintainer exception (multi-maintainer)
17 AcceptMaintainer, 18 AcceptMaintainer,
18 /// Accept as archive: Event accepted via GRASP-05 archive whitelist (read-only) 19 /// Accept as archive: Event accepted via GRASP-05 archive whitelist (read-only)
19 AcceptArchive, 20 AcceptArchive,
21 /// Accept to purgatory: New announcement, waiting for git data
22 AcceptPurgatory,
20 /// Reject: Event fails validation with reason 23 /// Reject: Event fails validation with reason
21 Reject(String), 24 Reject(String),
22} 25}
@@ -35,10 +38,12 @@ impl AnnouncementPolicy {
35 38
36 /// Validate a repository announcement event 39 /// Validate a repository announcement event
37 /// 40 ///
38 /// Returns `Accept` if the announcement lists the service properly, 41 /// Returns:
39 /// `AcceptMaintainer` if accepted via maintainer exception, 42 /// - `Accept` if this is a replacement announcement (active announcement exists)
40 /// `AcceptArchive` if accepted via GRASP-05 archive config, 43 /// - `AcceptPurgatory` if this is a new announcement (no active announcement exists)
41 /// or `Reject` with reason. 44 /// - `AcceptMaintainer` if accepted via maintainer exception
45 /// - `AcceptArchive` if accepted via GRASP-05 archive config
46 /// - `Reject` with reason if validation fails
42 pub async fn validate(&self, event: &Event) -> AnnouncementResult { 47 pub async fn validate(&self, event: &Event) -> AnnouncementResult {
43 // First, try validation (GRASP-01 + GRASP-05) 48 // First, try validation (GRASP-01 + GRASP-05)
44 let validation_result = validate_announcement(event, &self.config); 49 let validation_result = validate_announcement(event, &self.config);
@@ -67,11 +72,111 @@ impl AnnouncementPolicy {
67 Err(_) => AnnouncementResult::Reject(reason), 72 Err(_) => AnnouncementResult::Reject(reason),
68 } 73 }
69 } 74 }
70 // Accept, AcceptArchive, or AcceptMaintainer - return as-is 75 AnnouncementResult::Accept | AnnouncementResult::AcceptArchive => {
76 // Parse announcement to check for existing active announcement
77 match RepositoryAnnouncement::from_event(event.clone()) {
78 Ok(announcement) => {
79 // Check if there's already an active announcement for this (pubkey, identifier)
80 match self
81 .has_active_announcement(&event.pubkey, &announcement.identifier)
82 .await
83 {
84 Ok(true) => {
85 // Replacement announcement - accept immediately
86 tracing::debug!(
87 identifier = %announcement.identifier,
88 "Replacement announcement - accepting immediately"
89 );
90 validation_result
91 }
92 Ok(false) => {
93 // New announcement - route to purgatory
94 tracing::debug!(
95 identifier = %announcement.identifier,
96 "New announcement - routing to purgatory"
97 );
98 AnnouncementResult::AcceptPurgatory
99 }
100 Err(e) => {
101 tracing::warn!(
102 error = %e,
103 "Failed to check for existing announcement - rejecting"
104 );
105 AnnouncementResult::Reject(format!(
106 "Database error checking existing announcement: {}",
107 e
108 ))
109 }
110 }
111 }
112 Err(e) => AnnouncementResult::Reject(format!(
113 "Failed to parse announcement: {}",
114 e
115 )),
116 }
117 }
118 // AcceptPurgatory shouldn't come from validate_announcement, but handle it
71 result => result, 119 result => result,
72 } 120 }
73 } 121 }
74 122
123 /// Check if there's an active announcement in the database for this (pubkey, identifier)
124 async fn has_active_announcement(
125 &self,
126 pubkey: &PublicKey,
127 identifier: &str,
128 ) -> Result<bool, String> {
129 let filter = Filter::new()
130 .kind(Kind::GitRepoAnnouncement)
131 .author(*pubkey)
132 .custom_tag(
133 SingleLetterTag::lowercase(Alphabet::D),
134 identifier.to_string(),
135 );
136
137 let events: Vec<Event> = match self.ctx.database.query(filter).await {
138 Ok(events) => events.into_iter().collect(),
139 Err(e) => return Err(format!("Database query failed: {}", e)),
140 };
141
142 Ok(!events.is_empty())
143 }
144
145 /// Add an announcement to purgatory
146 ///
147 /// Creates the bare repository and stores the announcement in purgatory
148 /// until git data arrives.
149 pub fn add_to_purgatory(&self, event: &Event) -> Result<(), String> {
150 let announcement = RepositoryAnnouncement::from_event(event.clone())
151 .map_err(|e| format!("Failed to parse announcement: {}", e))?;
152
153 // Create bare repository
154 self.ensure_bare_repository(&announcement)?;
155
156 // Build repo path
157 let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
158
159 // Extract relays from announcement
160 let relays: HashSet<String> = announcement.relays.iter().cloned().collect();
161
162 // Add to purgatory
163 self.ctx.purgatory.add_announcement(
164 event.clone(),
165 announcement.identifier.clone(),
166 event.pubkey,
167 repo_path,
168 relays,
169 );
170
171 tracing::info!(
172 identifier = %announcement.identifier,
173 event_id = %event.id,
174 "Added announcement to purgatory"
175 );
176
177 Ok(())
178 }
179
75 /// Create a bare git repository if it doesn't exist 180 /// Create a bare git repository if it doesn't exist
76 /// Path format: <git_data_path>/<npub>/<identifier>.git 181 /// Path format: <git_data_path>/<npub>/<identifier>.git
77 pub fn ensure_bare_repository( 182 pub fn ensure_bare_repository(
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs
index f94f004..4bfb513 100644
--- a/src/nostr/policy/state.rs
+++ b/src/nostr/policy/state.rs
@@ -10,7 +10,7 @@ use nostr_relay_builder::prelude::Event;
10 10
11use super::PolicyContext; 11use super::PolicyContext;
12use crate::git; 12use crate::git;
13use crate::git::authorization::fetch_repository_data; 13use crate::git::authorization::fetch_repository_data_with_purgatory;
14use crate::nostr::events::{validate_state, RepositoryAnnouncement, RepositoryState}; 14use crate::nostr::events::{validate_state, RepositoryAnnouncement, RepositoryState};
15 15
16/// Result of state policy evaluation 16/// Result of state policy evaluation
@@ -76,7 +76,13 @@ impl StatePolicy {
76 } 76 }
77 77
78 // Get all repositories and state events from db with identifier 78 // Get all repositories and state events from db with identifier
79 let db_repo_data = fetch_repository_data(&self.ctx.database, &state.identifier).await?; 79 // Include purgatory announcements for authorization
80 let db_repo_data = fetch_repository_data_with_purgatory(
81 &self.ctx.database,
82 &self.ctx.purgatory,
83 &state.identifier,
84 )
85 .await?;
80 86
81 // CRITICAL: Check if author is authorized via maintainer set 87 // CRITICAL: Check if author is authorized via maintainer set
82 // State events MUST be rejected if author is not in maintainer set of any accepted announcement 88 // State events MUST be rejected if author is not in maintainer set of any accepted announcement
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}
diff --git a/src/sync/mod.rs b/src/sync/mod.rs
index 1ee1872..872df66 100644
--- a/src/sync/mod.rs
+++ b/src/sync/mod.rs
@@ -1719,8 +1719,50 @@ impl SyncManager {
1719 // For sync-triggered events that go to purgatory, trigger immediate sync 1719 // For sync-triggered events that go to purgatory, trigger immediate sync
1720 // (instead of the default 3-minute delay for user-submitted events) 1720 // (instead of the default 3-minute delay for user-submitted events)
1721 if result == ProcessResult::Purgatory { 1721 if result == ProcessResult::Purgatory {
1722 // Announcements (kind 30617) - re-process rejected state events
1723 // When an announcement goes to purgatory, state events that were
1724 // previously rejected ("no announcement exists") can now be authorized
1725 // via fetch_repository_data_with_purgatory.
1726 if event.kind == Kind::GitRepoAnnouncement {
1727 use crate::nostr::events::RepositoryAnnouncement;
1728
1729 if let Ok(announcement) = RepositoryAnnouncement::from_event((*event).clone()) {
1730 // Re-process rejected state events for this announcement
1731 let (removed, hot_events) = rejected_events_index.invalidate_and_get(
1732 &event.pubkey,
1733 &announcement.identifier,
1734 Some(rejected_index::EventType::State),
1735 );
1736
1737 if removed > 0 {
1738 tracing::info!(
1739 pubkey = %event.pubkey,
1740 identifier = %announcement.identifier,
1741 removed_from_cold_index = removed,
1742 hot_cache_events = hot_events.len(),
1743 "Invalidated rejected state events (announcement now in purgatory)"
1744 );
1745 }
1746
1747 // Re-process state events from hot cache immediately
1748 if !hot_events.is_empty() {
1749 let _stats = Self::reprocess_events_from_hot_cache(
1750 hot_events,
1751 "state event (announcement in purgatory)",
1752 &event.pubkey,
1753 &announcement.identifier,
1754 &relay_url_clone,
1755 &database,
1756 &write_policy,
1757 &local_relay,
1758 &rejected_events_index,
1759 )
1760 .await;
1761 }
1762 }
1763 }
1722 // State events (kind 30618) - extract identifier and trigger immediate sync 1764 // State events (kind 30618) - extract identifier and trigger immediate sync
1723 if event.kind.as_u16() == 30618 { 1765 else if event.kind.as_u16() == 30618 {
1724 if let Some(identifier) = event.tags.iter().find_map(|tag| { 1766 if let Some(identifier) = event.tags.iter().find_map(|tag| {
1725 let tag_vec = tag.clone().to_vec(); 1767 let tag_vec = tag.clone().to_vec();
1726 if tag_vec.len() >= 2 && tag_vec[0] == "d" { 1768 if tag_vec.len() >= 2 && tag_vec[0] == "d" {
@@ -1754,7 +1796,9 @@ impl SyncManager {
1754 1796
1755 // Track pagination state for this subscription (REQ+EOSE) 1797 // Track pagination state for this subscription (REQ+EOSE)
1756 // and received event IDs for negentropy batches 1798 // and received event IDs for negentropy batches
1757 if result == ProcessResult::Saved || result == ProcessResult::Duplicate { 1799 // Include Purgatory results so announcements in purgatory still trigger
1800 // per-repo sync (state events, PR events) from the source relay.
1801 if result == ProcessResult::Saved || result == ProcessResult::Duplicate || result == ProcessResult::Purgatory {
1758 let mut pending = pending_sync_index.write().await; 1802 let mut pending = pending_sync_index.write().await;
1759 if let Some(batches) = pending.get_mut(&relay_url_clone) { 1803 if let Some(batches) = pending.get_mut(&relay_url_clone) {
1760 for batch in batches.iter_mut() { 1804 for batch in batches.iter_mut() {
@@ -2506,6 +2550,26 @@ impl SyncManager {
2506 "{} added to purgatory (waiting for git data)", 2550 "{} added to purgatory (waiting for git data)",
2507 context 2551 context
2508 ); 2552 );
2553 // Trigger immediate sync for re-processed events that go to purgatory
2554 // (same as sync-triggered events in the main event loop)
2555 if event.kind.as_u16() == 30618 {
2556 // State event - extract identifier from 'd' tag
2557 if let Some(id) = event.tags.iter().find_map(|tag| {
2558 let tag_vec = tag.clone().to_vec();
2559 if tag_vec.len() >= 2 && tag_vec[0] == "d" {
2560 Some(tag_vec[1].clone())
2561 } else {
2562 None
2563 }
2564 }) {
2565 write_policy.purgatory().enqueue_sync_immediate(&id);
2566 }
2567 } else if event.kind.as_u16() == 1617 || event.kind.as_u16() == 1618 {
2568 // PR event - extract identifier from 'a' tag
2569 if let Some(id) = crate::git::sync::extract_identifier_from_pr_event(&event) {
2570 write_policy.purgatory().enqueue_sync_immediate(&id);
2571 }
2572 }
2509 } 2573 }
2510 ProcessResult::Rejected => { 2574 ProcessResult::Rejected => {
2511 stats.rejected += 1; 2575 stats.rejected += 1;