upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-18 19:41:29 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-18 19:41:29 +0000
commite22021f0b248ebcf3bd09210d59b2cdb4701032f (patch)
tree3dd1a3a75a8b7424749c0b0505a3d1ab61ac7895
parenta804164468d3beafb243ece12555b4d1692a075d (diff)
fix: simplify purgatory sync - fix SelfSubscriber sync_level upgrade and negentropy fallback
Three targeted fixes for purgatory announcement sync: 1. SelfSubscriber sync_level upgrade: After or_insert_with in process_batch, always set entry.sync_level = SyncLevel::Full so that when a promoted announcement is broadcast via notify_event and SelfSubscriber receives it, an existing StateOnly entry gets upgraded to Full and PR event subscriptions are triggered immediately (not delayed up to 24h). 2. Negentropy fallback filter split: In handle_eose, when falling back from negentropy to REQ+EOSE, split batch_repos by SyncLevel and call build_sync_level_aware_filters instead of build_layer2_and_layer3_filters. Prevents StateOnly (purgatory) repos from getting Layer 2 #a/#A/#q filters prematurely, which caused nostr-sdk client deduplication to permanently drop PR events after orphan rejection. 3. Recompute sync filters after announcement batch EOSE: Add recompute_new_sync_filters_for_relay calls at all three batch-completion paths in handle_eose for generic filter (announcement) batches. This triggers state-only subscriptions for any purgatory repos registered during that batch, fixing the 24h delay before state event sync starts. 4. User-submitted purgatory announcements: Add repo_sync_index field to PolicyContext with setter/getter, wire in main.rs after SyncManager creation, and register in AcceptPurgatory handler so user-submitted announcements get StateOnly sync started immediately. 5. Update archive tests: test_archive_without_state_events_does_not_sync_git updated to reflect that StateOnly subscription now proactively fetches state events from source relays. test_archive_read_only_creates_bare_repo un-ignored as it now works end-to-end.
-rw-r--r--src/main.rs7
-rw-r--r--src/nostr/builder.rs54
-rw-r--r--src/nostr/policy/mod.rs19
-rw-r--r--src/sync/mod.rs66
-rw-r--r--src/sync/self_subscriber.rs4
-rw-r--r--tests/archive_read_only.rs63
6 files changed, 187 insertions, 26 deletions
diff --git a/src/main.rs b/src/main.rs
index ab6ede7..ebe05a3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -132,6 +132,13 @@ async fn main() -> Result<()> {
132 // Get a reference to the rejected events index for shutdown persistence 132 // Get a reference to the rejected events index for shutdown persistence
133 let shutdown_rejected_index = sync_manager.rejected_events_index(); 133 let shutdown_rejected_index = sync_manager.rejected_events_index();
134 134
135 // Wire repo_sync_index into write policy so user-submitted purgatory announcements
136 // get registered for state event sync immediately (Fix 3).
137 let repo_sync_index = sync_manager.repo_sync_index();
138 relay_with_db
139 .write_policy
140 .set_repo_sync_index(repo_sync_index);
141
135 tokio::spawn(async move { 142 tokio::spawn(async move {
136 sync_manager.run().await; 143 sync_manager.run().await;
137 }); 144 });
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs
index aff12a6..8d1e461 100644
--- a/src/nostr/builder.rs
+++ b/src/nostr/builder.rs
@@ -17,6 +17,7 @@ use crate::nostr::policy::{
17 AnnouncementPolicy, AnnouncementResult, PolicyContext, PrEventPolicy, ReferenceResult, 17 AnnouncementPolicy, AnnouncementResult, PolicyContext, PrEventPolicy, ReferenceResult,
18 RelatedEventPolicy, StatePolicy, StateResult, 18 RelatedEventPolicy, StatePolicy, StateResult,
19}; 19};
20use crate::sync::{RepoSyncIndex, RepoSyncNeeds, SyncLevel};
20 21
21/// Type alias for the shared database used by the relay 22/// Type alias for the shared database used by the relay
22pub type SharedDatabase = Arc<dyn NostrDatabase>; 23pub type SharedDatabase = Arc<dyn NostrDatabase>;
@@ -98,6 +99,14 @@ impl Nip34WritePolicy {
98 self.ctx.set_local_relay(relay); 99 self.ctx.set_local_relay(relay);
99 } 100 }
100 101
102 /// Set the repo sync index so that user-submitted purgatory announcements can
103 /// be registered for state event sync immediately.
104 ///
105 /// This must be called after SyncManager is created.
106 pub fn set_repo_sync_index(&self, index: RepoSyncIndex) {
107 self.ctx.set_repo_sync_index(index);
108 }
109
101 /// Handle repository announcement event 110 /// Handle repository announcement event
102 async fn handle_announcement(&self, event: &Event) -> WritePolicyResult { 111 async fn handle_announcement(&self, event: &Event) -> WritePolicyResult {
103 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); 112 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
@@ -146,6 +155,51 @@ impl Nip34WritePolicy {
146 "Accepted announcement to purgatory: {} (waiting for git data)", 155 "Accepted announcement to purgatory: {} (waiting for git data)",
147 event_id_str 156 event_id_str
148 ); 157 );
158
159 // Register repo in repo_sync_index with StateOnly level so that
160 // state event sync starts promptly via the next batch EOSE recompute.
161 // This handles user-submitted purgatory announcements - the SelfSubscriber
162 // only sees DB events, so it won't pick these up automatically.
163 if let Some(repo_sync_index) = self.ctx.get_repo_sync_index() {
164 if let Ok(announcement) =
165 RepositoryAnnouncement::from_event(event.clone())
166 {
167 use std::collections::HashSet;
168 let repo_id = format!(
169 "30617:{}:{}",
170 event.pubkey,
171 announcement.identifier
172 );
173
174 // Extract relay URLs from the announcement event tags
175 let relays: HashSet<String> = event
176 .tags
177 .iter()
178 .flat_map(|tag| {
179 let tag_vec = tag.as_slice();
180 if !tag_vec.is_empty() && tag_vec[0] == "relays" {
181 tag_vec[1..].iter().map(|s| s.to_string()).collect::<Vec<_>>()
182 } else {
183 vec![]
184 }
185 })
186 .collect();
187
188 let mut index = repo_sync_index.write().await;
189 index.entry(repo_id.clone()).or_insert_with(|| RepoSyncNeeds {
190 relays,
191 root_events: HashSet::new(),
192 sync_level: SyncLevel::StateOnly,
193 });
194 drop(index);
195
196 tracing::debug!(
197 repo_id = %repo_id,
198 "Registered purgatory announcement in repo_sync_index as StateOnly"
199 );
200 }
201 }
202
149 WritePolicyResult::Reject { 203 WritePolicyResult::Reject {
150 status: true, // Client sees OK 204 status: true, // Client sees OK
151 message: "purgatory: won't be served until git data arrives".into(), 205 message: "purgatory: won't be served until git data arrives".into(),
diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs
index 1566b6c..c958586 100644
--- a/src/nostr/policy/mod.rs
+++ b/src/nostr/policy/mod.rs
@@ -20,6 +20,7 @@ pub use crate::git::sync::AlignmentResult;
20 20
21use super::SharedDatabase; 21use super::SharedDatabase;
22use crate::purgatory::Purgatory; 22use crate::purgatory::Purgatory;
23use crate::sync::RepoSyncIndex;
23use nostr_relay_builder::LocalRelay; 24use nostr_relay_builder::LocalRelay;
24use std::sync::Arc; 25use std::sync::Arc;
25 26
@@ -34,6 +35,8 @@ pub struct PolicyContext {
34 pub local_relay: Arc<std::sync::RwLock<Option<LocalRelay>>>, 35 pub local_relay: Arc<std::sync::RwLock<Option<LocalRelay>>>,
35 /// Configuration reference for policy settings (includes blacklists) 36 /// Configuration reference for policy settings (includes blacklists)
36 pub config: crate::config::Config, 37 pub config: crate::config::Config,
38 /// Repo sync index for registering purgatory announcements (set after SyncManager creation)
39 pub repo_sync_index: Arc<std::sync::RwLock<Option<RepoSyncIndex>>>,
37} 40}
38 41
39impl PolicyContext { 42impl PolicyContext {
@@ -51,6 +54,7 @@ impl PolicyContext {
51 purgatory, 54 purgatory,
52 local_relay: Arc::new(std::sync::RwLock::new(None)), 55 local_relay: Arc::new(std::sync::RwLock::new(None)),
53 config, 56 config,
57 repo_sync_index: Arc::new(std::sync::RwLock::new(None)),
54 } 58 }
55 } 59 }
56 60
@@ -68,4 +72,19 @@ impl PolicyContext {
68 let guard = self.local_relay.read().unwrap(); 72 let guard = self.local_relay.read().unwrap();
69 guard.clone() 73 guard.clone()
70 } 74 }
75
76 /// Set the repo sync index after SyncManager has been created.
77 ///
78 /// This allows purgatory announcements submitted by users to be registered
79 /// in the sync index so state event sync starts promptly.
80 pub fn set_repo_sync_index(&self, index: RepoSyncIndex) {
81 let mut guard = self.repo_sync_index.write().unwrap();
82 *guard = Some(index);
83 }
84
85 /// Get a clone of the repo sync index if it has been set.
86 pub fn get_repo_sync_index(&self) -> Option<RepoSyncIndex> {
87 let guard = self.repo_sync_index.read().unwrap();
88 guard.clone()
89 }
71} 90}
diff --git a/src/sync/mod.rs b/src/sync/mod.rs
index 519017b..916e2b0 100644
--- a/src/sync/mod.rs
+++ b/src/sync/mod.rs
@@ -700,6 +700,14 @@ impl SyncManager {
700 self.rejected_events_index.save_to_disk(path) 700 self.rejected_events_index.save_to_disk(path)
701 } 701 }
702 702
703 /// Get a clone of the repo sync index Arc.
704 ///
705 /// This allows the write policy to register user-submitted purgatory announcements
706 /// in the sync index so that state event sync starts promptly.
707 pub fn repo_sync_index(&self) -> RepoSyncIndex {
708 self.repo_sync_index.clone()
709 }
710
703 /// Handle EOSE (End Of Stored Events) for a subscription 711 /// Handle EOSE (End Of Stored Events) for a subscription
704 /// 712 ///
705 /// This method: 713 /// This method:
@@ -951,9 +959,29 @@ impl SyncManager {
951 959
952 // Create REQ+EOSE subscriptions using original semantic filters 960 // Create REQ+EOSE subscriptions using original semantic filters
953 // This queries by kind/author/tags instead of by ID, which may 961 // This queries by kind/author/tags instead of by ID, which may
954 // succeed even when ID-based queries fail 962 // succeed even when ID-based queries fail.
955 let fallback_filters = filters::build_layer2_and_layer3_filters( 963 // Split batch_repos by SyncLevel to avoid sending Layer 2 filters
956 &batch_repos, 964 // (#a/#A/#q) for StateOnly (purgatory) repos - those PRs would be
965 // rejected as orphan and then silently dropped by nostr-sdk deduplication.
966 let (full_repos, state_only_repos) = {
967 let repo_index = self.repo_sync_index.read().await;
968 let mut full = HashSet::new();
969 let mut state_only = HashSet::new();
970 for repo_ref in &batch_repos {
971 match repo_index.get(repo_ref).map(|n| n.sync_level) {
972 Some(SyncLevel::StateOnly) => {
973 state_only.insert(repo_ref.clone());
974 }
975 _ => {
976 full.insert(repo_ref.clone());
977 }
978 }
979 }
980 (full, state_only)
981 };
982 let fallback_filters = filters::build_sync_level_aware_filters(
983 &full_repos,
984 &state_only_repos,
957 &batch_root_events, 985 &batch_root_events,
958 None, 986 None,
959 ); 987 );
@@ -1033,12 +1061,24 @@ impl SyncManager {
1033 { 1061 {
1034 let mut completed_batch = batches.remove(idx); 1062 let mut completed_batch = batches.remove(idx);
1035 completed_batch.failed = true; // Mark as failed 1063 completed_batch.failed = true; // Mark as failed
1064 let is_generic =
1065 completed_batch.items.repos.is_empty()
1066 && completed_batch.items.root_events.is_empty();
1036 if batches.is_empty() { 1067 if batches.is_empty() {
1037 pending.remove(&relay_url_for_fallback); 1068 pending.remove(&relay_url_for_fallback);
1038 } 1069 }
1039 drop(pending); 1070 drop(pending);
1040 self.confirm_batch(&relay_url_for_fallback, completed_batch) 1071 self.confirm_batch(&relay_url_for_fallback, completed_batch)
1041 .await; 1072 .await;
1073 // For generic filter (announcement) batches, recompute filters
1074 // so any purgatory repos registered during this batch get
1075 // state-only subscriptions triggered.
1076 if is_generic {
1077 self.recompute_new_sync_filters_for_relay(
1078 &relay_url_for_fallback,
1079 )
1080 .await;
1081 }
1042 } 1082 }
1043 } 1083 }
1044 return; 1084 return;
@@ -1132,12 +1172,24 @@ impl SyncManager {
1132 if let Some(batches) = pending.get_mut(&relay_url_for_retry) { 1172 if let Some(batches) = pending.get_mut(&relay_url_for_retry) {
1133 if let Some(idx) = batches.iter().position(|b| b.batch_id == batch_id) { 1173 if let Some(idx) = batches.iter().position(|b| b.batch_id == batch_id) {
1134 let completed_batch = batches.remove(idx); 1174 let completed_batch = batches.remove(idx);
1175 let is_generic =
1176 completed_batch.items.repos.is_empty()
1177 && completed_batch.items.root_events.is_empty();
1135 if batches.is_empty() { 1178 if batches.is_empty() {
1136 pending.remove(&relay_url_for_retry); 1179 pending.remove(&relay_url_for_retry);
1137 } 1180 }
1138 drop(pending); 1181 drop(pending);
1139 self.confirm_batch(&relay_url_for_retry, completed_batch) 1182 self.confirm_batch(&relay_url_for_retry, completed_batch)
1140 .await; 1183 .await;
1184 // For generic filter (announcement) batches, recompute filters
1185 // so any purgatory repos registered during this batch get
1186 // state-only subscriptions triggered.
1187 if is_generic {
1188 self.recompute_new_sync_filters_for_relay(
1189 &relay_url_for_retry,
1190 )
1191 .await;
1192 }
1141 } 1193 }
1142 } 1194 }
1143 return; 1195 return;
@@ -1148,6 +1200,8 @@ impl SyncManager {
1148 1200
1149 // 3. Batch complete - extract and remove 1201 // 3. Batch complete - extract and remove
1150 let completed_batch = batches.remove(batch_idx); 1202 let completed_batch = batches.remove(batch_idx);
1203 let is_generic = completed_batch.items.repos.is_empty()
1204 && completed_batch.items.root_events.is_empty();
1151 1205
1152 // Clean up empty relay entry 1206 // Clean up empty relay entry
1153 if batches.is_empty() { 1207 if batches.is_empty() {
@@ -1159,6 +1213,12 @@ impl SyncManager {
1159 1213
1160 // 4. Confirm the batch (moves items to RelayState) 1214 // 4. Confirm the batch (moves items to RelayState)
1161 self.confirm_batch(relay_url, completed_batch).await; 1215 self.confirm_batch(relay_url, completed_batch).await;
1216
1217 // 5. For generic filter (announcement) batches, recompute sync filters so any
1218 // purgatory repos registered during this batch get state-only subscriptions triggered.
1219 if is_generic {
1220 self.recompute_new_sync_filters_for_relay(relay_url).await;
1221 }
1162 } 1222 }
1163 1223
1164 /// Confirm a completed batch by moving items to RelayState 1224 /// Confirm a completed batch by moving items to RelayState
diff --git a/src/sync/self_subscriber.rs b/src/sync/self_subscriber.rs
index db16c62..70c3dbf 100644
--- a/src/sync/self_subscriber.rs
+++ b/src/sync/self_subscriber.rs
@@ -478,6 +478,10 @@ impl SelfSubscriber {
478 root_events: HashSet::new(), 478 root_events: HashSet::new(),
479 sync_level: SyncLevel::Full, 479 sync_level: SyncLevel::Full,
480 }); 480 });
481 // Upgrade sync_level to Full - this handles the case where the entry
482 // already exists as StateOnly (purgatory announcement) and is now being
483 // promoted (git data arrived and the event was broadcast via notify_event).
484 entry.sync_level = SyncLevel::Full;
481 entry.relays.extend(needs.relays); 485 entry.relays.extend(needs.relays);
482 entry.root_events.extend(needs.root_events); 486 entry.root_events.extend(needs.root_events);
483 487
diff --git a/tests/archive_read_only.rs b/tests/archive_read_only.rs
index e388ae5..069b3b7 100644
--- a/tests/archive_read_only.rs
+++ b/tests/archive_read_only.rs
@@ -55,7 +55,6 @@ use std::time::Duration;
55/// 5. Verify bare repository is created and git data is synced 55/// 5. Verify bare repository is created and git data is synced
56/// 6. Verify git pushes are rejected (read-only mode) 56/// 6. Verify git pushes are rejected (read-only mode)
57#[tokio::test] 57#[tokio::test]
58#[ignore] // Requires SyncLevel implementation (Phase 3) - purgatory announcements don't trigger per-repo sync yet
59async fn test_archive_read_only_creates_bare_repo() { 58async fn test_archive_read_only_creates_bare_repo() {
60 // 1. Start source relay 59 // 1. Start source relay
61 let source_relay = TestRelay::start().await; 60 let source_relay = TestRelay::start().await;
@@ -264,24 +263,24 @@ async fn test_archive_read_only_creates_bare_repo() {
264 source_relay.stop().await; 263 source_relay.stop().await;
265} 264}
266 265
267/// Test that archive mode without state events does NOT sync git data. 266/// Test that archive mode proactively syncs state events and git data
267/// when the source relay has state events available.
268/// 268///
269/// This verifies the security model: archive mode only syncs git data 269/// With StateOnly sync now implemented, purgatory announcements subscribe
270/// when there are state events to validate against. 270/// to state events from the relays listed in the announcement. This means
271/// the archive relay will:
272/// 1. Sync the announcement → purgatory → register as StateOnly in repo_sync_index
273/// 2. Subscribe to state events (kind 30618) on source relay
274/// 3. Receive the state event → purgatory sync triggered
275/// 4. Fetch git data from source relay's clone URL
271/// 276///
272/// With announcement purgatory, the flow is: 277/// This test verifies the full sync chain works end-to-end for archive mode.
273/// 1. Send announcement to source relay (goes to purgatory)
274/// 2. Send state event to source relay (goes to purgatory)
275/// 3. Push git data to source relay (promotes announcement and state event)
276/// 4. Start archive relay with sync from source
277/// 5. Archive relay syncs the promoted announcement
278/// 6. Verify git data is NOT synced (archive has no state event to authorize git fetch)
279#[tokio::test] 278#[tokio::test]
280async fn test_archive_without_state_events_does_not_sync_git() { 279async fn test_archive_syncs_state_events_and_git_data_via_state_only_subscription() {
281 // 1. Start source relay 280 // 1. Start source relay
282 let source_relay = TestRelay::start().await; 281 let source_relay = TestRelay::start().await;
283 let keys = Keys::generate(); 282 let keys = Keys::generate();
284 let identifier = "archive-no-state-repo"; 283 let identifier = "archive-state-only-sync-repo";
285 284
286 // Pre-allocate archive relay port 285 // Pre-allocate archive relay port
287 let archive_port = TestRelay::find_free_port(); 286 let archive_port = TestRelay::find_free_port();
@@ -295,6 +294,7 @@ async fn test_archive_without_state_events_does_not_sync_git() {
295 let npub = keys.public_key().to_bech32().expect("Failed to get npub"); 294 let npub = keys.public_key().to_bech32().expect("Failed to get npub");
296 295
297 // 3. Create and send announcement listing BOTH relays 296 // 3. Create and send announcement listing BOTH relays
297 // The archive relay will subscribe to state events on BOTH listed relays
298 let announcement = create_repo_announcement( 298 let announcement = create_repo_announcement(
299 &keys, 299 &keys,
300 &[&source_relay.domain(), &archive_domain], 300 &[&source_relay.domain(), &archive_domain],
@@ -337,6 +337,8 @@ async fn test_archive_without_state_events_does_not_sync_git() {
337 ) 337 )
338 .expect("Failed to create state event"); 338 .expect("Failed to create state event");
339 339
340 let state_event_id = state_event.id;
341
340 source_client 342 source_client
341 .send_event(&state_event) 343 .send_event(&state_event)
342 .await 344 .await
@@ -348,9 +350,12 @@ async fn test_archive_without_state_events_does_not_sync_git() {
348 push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier) 350 push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
349 .expect("Push to source should succeed"); 351 .expect("Push to source should succeed");
350 352
351 tokio::time::sleep(Duration::from_millis(500)).await; 353 // Wait for state event to be promoted on source relay
354 wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
355 .await
356 .expect("State event should be served on source relay after push");
352 357
353 // 6. Start archive relay (without state event - we don't send state event to archive) 358 // 6. Start archive relay - StateOnly subscription will proactively fetch state events
354 let archive_relay = TestRelay::start_with_archive_and_sync( 359 let archive_relay = TestRelay::start_with_archive_and_sync(
355 archive_port, 360 archive_port,
356 Some(source_relay.url().to_string()), 361 Some(source_relay.url().to_string()),
@@ -360,15 +365,28 @@ async fn test_archive_without_state_events_does_not_sync_git() {
360 ) 365 )
361 .await; 366 .await;
362 367
363 // Wait for sync 368 // Wait for sync connection
364 wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5)) 369 wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5))
365 .await 370 .await
366 .expect("Sync connection should establish"); 371 .expect("Sync connection should establish");
367 372
368 // Give time for sync to fetch announcement 373 // 7. Wait for state event to be served on archive relay
369 tokio::time::sleep(Duration::from_secs(3)).await; 374 // The StateOnly subscription fetches the state event from source relay,
375 // which then triggers purgatory sync and git data fetch.
376 let found = wait_for_event_served(
377 archive_relay.url(),
378 &state_event_id,
379 Duration::from_secs(30), // Allow time for sync + git fetch
380 )
381 .await;
382
383 assert!(
384 found.is_ok(),
385 "State event should be served on archive after StateOnly subscription fetches it: {:?}",
386 found.err()
387 );
370 388
371 // 7. Verify bare repository was created (announcement was synced and accepted to purgatory) 389 // 8. Verify bare repository was created
372 let repo_path = archive_relay 390 let repo_path = archive_relay
373 .git_data_path() 391 .git_data_path()
374 .join(format!("{}/{}.git", npub, identifier)); 392 .join(format!("{}/{}.git", npub, identifier));
@@ -378,8 +396,7 @@ async fn test_archive_without_state_events_does_not_sync_git() {
378 "Bare repository should be created for archive announcement" 396 "Bare repository should be created for archive announcement"
379 ); 397 );
380 398
381 // 8. Verify git data was NOT synced (no state events on archive to trigger git fetch) 399 // 9. Verify git data was synced via the state event chain
382 // Check that the commit does NOT exist in the archive relay's repo
383 let output = tokio::process::Command::new("git") 400 let output = tokio::process::Command::new("git")
384 .args(["cat-file", "-t", &commit_hash]) 401 .args(["cat-file", "-t", &commit_hash])
385 .current_dir(&repo_path) 402 .current_dir(&repo_path)
@@ -389,8 +406,8 @@ async fn test_archive_without_state_events_does_not_sync_git() {
389 let commit_exists = output.map(|o| o.status.success()).unwrap_or(false); 406 let commit_exists = output.map(|o| o.status.success()).unwrap_or(false);
390 407
391 assert!( 408 assert!(
392 !commit_exists, 409 commit_exists,
393 "Git data should NOT be synced without state events (security: validates against Nostr state)" 410 "Git data should be synced via StateOnly subscription → state event → git fetch chain"
394 ); 411 );
395 412
396 // Cleanup 413 // Cleanup