Skip to main content

hashtree_cli/
sync.rs

1//! Background sync service for auto-pulling trees from Nostr
2//!
3//! Subscribes to:
4//! 1. Own trees (all visibility levels) - highest priority
5//! 2. Followed users' public trees - lower priority
6//!
7//! Blob reads use configured Blossom HTTP servers.
8
9use anyhow::Result;
10use git_remote_htree::nostr_client::{hashtree_root_kinds, is_hashtree_root_kind, load_keys};
11use hashtree_core::{from_hex, to_hex, Cid};
12use nostr_sdk::prelude::*;
13use std::collections::{HashMap, HashSet, VecDeque};
14use std::path::PathBuf;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17use tokio::sync::RwLock;
18use tracing::{error, info, warn};
19
20use crate::fetch::{FetchConfig, Fetcher};
21use crate::storage::{HashtreeStore, PRIORITY_FOLLOWED, PRIORITY_OWN};
22
23/// Sync priority levels
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
25pub enum SyncPriority {
26    /// Explicitly pinned mutable refs - highest priority
27    Pinned = 0,
28    /// Explicitly tracked authors - mirrors all readable trees for that author
29    TrackedAuthor = 1,
30    /// Own trees - high priority
31    Own = 2,
32    /// Followed users' trees - lower priority
33    Followed = 3,
34}
35
36/// A tree to sync
37#[derive(Debug, Clone)]
38pub struct SyncTask {
39    /// Nostr key (npub.../treename)
40    pub key: String,
41    /// Content identifier
42    pub cid: Cid,
43    /// Priority level
44    pub priority: SyncPriority,
45    /// When this task was queued
46    pub queued_at: Instant,
47}
48
49/// Configuration for background sync
50#[derive(Debug, Clone)]
51pub struct SyncConfig {
52    /// Enable syncing own trees
53    pub sync_own: bool,
54    /// Enable syncing followed users' public trees
55    pub sync_followed: bool,
56    /// Nostr relays for subscriptions
57    pub relays: Vec<String>,
58    /// Max concurrent sync tasks
59    pub max_concurrent: usize,
60    /// Timeout for Blossom requests (ms)
61    pub blossom_timeout_ms: u64,
62}
63
64impl Default for SyncConfig {
65    fn default() -> Self {
66        Self {
67            sync_own: true,
68            sync_followed: true,
69            relays: hashtree_config::DEFAULT_RELAYS
70                .iter()
71                .map(|s| s.to_string())
72                .collect(),
73            max_concurrent: 3,
74            blossom_timeout_ms: 10000,
75        }
76    }
77}
78
79impl SyncConfig {
80    /// Create from hashtree_config (respects user's config.toml)
81    pub fn from_config(config: &hashtree_config::Config) -> Self {
82        Self {
83            sync_own: true,
84            sync_followed: true,
85            relays: config.nostr.relays.clone(),
86            max_concurrent: 3,
87            blossom_timeout_ms: 10000,
88        }
89    }
90}
91
92/// State for a subscribed tree
93#[allow(dead_code)]
94struct TreeSubscription {
95    key: String,
96    current_cid: Option<Cid>,
97    priority: SyncPriority,
98    last_synced: Option<Instant>,
99}
100
101fn build_exact_tree_filter(key: &str) -> Result<Filter> {
102    let (npub, tree_name) = key
103        .split_once('/')
104        .ok_or_else(|| anyhow::anyhow!("Invalid pinned ref key: {}", key))?;
105    let author = PublicKey::from_bech32(npub)
106        .map_err(|_| anyhow::anyhow!("Invalid npub in pinned ref key: {}", key))?;
107
108    Ok(Filter::new()
109        .kinds(hashtree_root_kinds())
110        .author(author)
111        .custom_tag(
112            SingleLetterTag::lowercase(Alphabet::D),
113            tree_name.to_string(),
114        )
115        .custom_tag(SingleLetterTag::lowercase(Alphabet::L), "hashtree"))
116}
117
118fn build_author_tree_filter(author: PublicKey) -> Filter {
119    Filter::new()
120        .kinds(hashtree_root_kinds())
121        .author(author)
122        .custom_tag(SingleLetterTag::lowercase(Alphabet::L), "hashtree")
123}
124
125fn load_author_signing_keys() -> HashMap<String, Keys> {
126    load_keys()
127        .into_iter()
128        .filter_map(|stored| {
129            let secret_hex = stored.secret_hex?;
130            let secret_bytes = hex::decode(&secret_hex).ok()?;
131            let secret = SecretKey::from_slice(&secret_bytes).ok()?;
132            Some((stored.pubkey_hex, Keys::new(secret)))
133        })
134        .collect()
135}
136
137fn cid_from_tree_event(event: &Event, author_keys: Option<&Keys>) -> Option<Cid> {
138    let mut hash_hex: Option<String> = None;
139    let mut key_hex: Option<String> = None;
140    let mut encrypted_key: Option<String> = None;
141    let mut self_encrypted_key: Option<String> = None;
142
143    for tag in event.tags.iter() {
144        let tag_vec = tag.as_slice();
145        if tag_vec.len() < 2 {
146            continue;
147        }
148
149        match tag_vec[0].as_str() {
150            "hash" => hash_hex = Some(tag_vec[1].clone()),
151            "key" => key_hex = Some(tag_vec[1].clone()),
152            "encryptedKey" => encrypted_key = Some(tag_vec[1].clone()),
153            "selfEncryptedKey" => self_encrypted_key = Some(tag_vec[1].clone()),
154            _ => {}
155        }
156    }
157
158    let hash = from_hex(&hash_hex?).ok()?;
159
160    if let Some(key_hex) = key_hex {
161        let bytes = hex::decode(&key_hex).ok()?;
162        if bytes.len() != 32 {
163            return None;
164        }
165        let mut key = [0u8; 32];
166        key.copy_from_slice(&bytes);
167        return Some(Cid {
168            hash,
169            key: Some(key),
170        });
171    }
172
173    if let Some(ciphertext) = self_encrypted_key {
174        let keys = author_keys?;
175        if keys.public_key() != event.pubkey {
176            return None;
177        }
178        let key_hex = nip44::decrypt(keys.secret_key(), &event.pubkey, &ciphertext).ok()?;
179        let bytes = hex::decode(&key_hex).ok()?;
180        if bytes.len() != 32 {
181            return None;
182        }
183        let mut key = [0u8; 32];
184        key.copy_from_slice(&bytes);
185        return Some(Cid {
186            hash,
187            key: Some(key),
188        });
189    }
190
191    if encrypted_key.is_some() {
192        return None;
193    }
194
195    Some(Cid { hash, key: None })
196}
197
198fn classify_sync_event(
199    key: &str,
200    author_hex: &str,
201    my_pubkey: &PublicKey,
202    pinned_refs: &HashSet<String>,
203    tracked_authors: &HashSet<String>,
204    followed_authors: &HashSet<String>,
205) -> Option<SyncPriority> {
206    if pinned_refs.contains(key) {
207        return Some(SyncPriority::Pinned);
208    }
209
210    if tracked_authors.contains(author_hex) {
211        return Some(SyncPriority::TrackedAuthor);
212    }
213
214    if author_hex == my_pubkey.to_hex() {
215        return Some(SyncPriority::Own);
216    }
217
218    if followed_authors.contains(author_hex) {
219        return Some(SyncPriority::Followed);
220    }
221
222    None
223}
224
225fn apply_synced_tree_update(store: &HashtreeStore, task: &SyncTask) -> Result<()> {
226    let (owner, name) = task
227        .key
228        .split_once('/')
229        .map(|(o, n)| (o.to_string(), Some(n)))
230        .unwrap_or((task.key.clone(), None));
231
232    let storage_priority = match task.priority {
233        SyncPriority::Pinned | SyncPriority::TrackedAuthor | SyncPriority::Own => PRIORITY_OWN,
234        SyncPriority::Followed => PRIORITY_FOLLOWED,
235    };
236
237    if matches!(
238        task.priority,
239        SyncPriority::Pinned | SyncPriority::TrackedAuthor
240    ) {
241        store.pin(&task.cid.hash)?;
242    }
243
244    store.index_tree(
245        &task.cid.hash,
246        &owner,
247        name,
248        storage_priority,
249        Some(&task.key),
250    )?;
251
252    store.evict_if_needed()?;
253    Ok(())
254}
255
256/// Background sync service
257pub struct BackgroundSync {
258    config: SyncConfig,
259    store: Arc<HashtreeStore>,
260    /// Nostr client for subscriptions
261    client: Client,
262    /// Our public key
263    my_pubkey: PublicKey,
264    /// Subscribed trees
265    subscriptions: Arc<RwLock<HashMap<String, TreeSubscription>>>,
266    /// Followed authors that are allowed to generate sync tasks
267    followed_authors: Arc<RwLock<HashSet<String>>>,
268    /// Currently pinned mutable refs that should keep following updates
269    pinned_refs: Arc<RwLock<HashSet<String>>>,
270    /// Authors whose readable trees should be mirrored continuously
271    tracked_authors: Arc<RwLock<HashSet<String>>>,
272    /// Exact pinned refs already subscribed at the relay layer
273    subscribed_pinned_refs: Arc<RwLock<HashSet<String>>>,
274    /// Tracked authors already subscribed at the relay layer
275    subscribed_tracked_authors: Arc<RwLock<HashSet<String>>>,
276    /// Local signing keys available for decrypting owner-private roots
277    author_signing_keys: Arc<RwLock<HashMap<String, Keys>>>,
278    /// Sync queue
279    queue: Arc<RwLock<VecDeque<SyncTask>>>,
280    /// Currently syncing hashes
281    syncing: Arc<RwLock<HashSet<String>>>,
282    /// Shutdown signal
283    shutdown_tx: tokio::sync::watch::Sender<bool>,
284    shutdown_rx: tokio::sync::watch::Receiver<bool>,
285    /// Fetcher for remote content
286    fetcher: Arc<Fetcher>,
287}
288
289impl BackgroundSync {
290    /// Create a new background sync service
291    pub async fn new(config: SyncConfig, store: Arc<HashtreeStore>, keys: Keys) -> Result<Self> {
292        let my_pubkey = keys.public_key();
293        let client = Client::new(keys);
294
295        // Add relays
296        for relay in &config.relays {
297            if let Err(e) = client.add_relay(relay).await {
298                warn!("Failed to add relay {}: {}", relay, e);
299            }
300        }
301
302        // Connect to relays
303        client.connect().await;
304
305        let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
306
307        // Create fetcher with config
308        // BlossomClient auto-loads servers from ~/.hashtree/config.toml
309        let fetch_config = FetchConfig {
310            blossom_timeout: Duration::from_millis(config.blossom_timeout_ms),
311        };
312        let fetcher = Arc::new(Fetcher::new(fetch_config));
313
314        Ok(Self {
315            config,
316            store,
317            client,
318            my_pubkey,
319            subscriptions: Arc::new(RwLock::new(HashMap::new())),
320            followed_authors: Arc::new(RwLock::new(HashSet::new())),
321            pinned_refs: Arc::new(RwLock::new(HashSet::new())),
322            tracked_authors: Arc::new(RwLock::new(HashSet::new())),
323            subscribed_pinned_refs: Arc::new(RwLock::new(HashSet::new())),
324            subscribed_tracked_authors: Arc::new(RwLock::new(HashSet::new())),
325            author_signing_keys: Arc::new(RwLock::new(load_author_signing_keys())),
326            queue: Arc::new(RwLock::new(VecDeque::new())),
327            syncing: Arc::new(RwLock::new(HashSet::new())),
328            shutdown_tx,
329            shutdown_rx,
330            fetcher,
331        })
332    }
333
334    /// Start the background sync service
335    pub async fn run(&self, contacts_file: PathBuf) -> Result<()> {
336        info!("Starting background sync service");
337
338        // Wait for relays to connect before subscribing
339        tokio::time::sleep(Duration::from_secs(3)).await;
340
341        self.refresh_author_signing_keys().await;
342        self.refresh_pinned_ref_subscriptions().await?;
343        self.refresh_tracked_author_subscriptions().await?;
344
345        // Subscribe to own trees
346        if self.config.sync_own {
347            self.subscribe_own_trees().await?;
348        }
349
350        // Subscribe to followed users' trees
351        if self.config.sync_followed {
352            self.subscribe_followed_trees(&contacts_file).await?;
353        }
354
355        // Start sync worker
356        let queue = self.queue.clone();
357        let syncing = self.syncing.clone();
358        let store = self.store.clone();
359        let fetcher = self.fetcher.clone();
360        let max_concurrent = self.config.max_concurrent;
361        let mut shutdown_rx = self.shutdown_rx.clone();
362
363        // Spawn sync worker task
364        tokio::spawn(async move {
365            let mut interval = tokio::time::interval(Duration::from_millis(500));
366
367            loop {
368                tokio::select! {
369                    _ = shutdown_rx.changed() => {
370                        if *shutdown_rx.borrow() {
371                            info!("Sync worker shutting down");
372                            break;
373                        }
374                    }
375                    _ = interval.tick() => {
376                        // Check if we can start more sync tasks
377                        let current_syncing = syncing.read().await.len();
378                        if current_syncing >= max_concurrent {
379                            continue;
380                        }
381
382                        // Get next task from queue
383                        let task = {
384                            let mut q = queue.write().await;
385                            q.pop_front()
386                        };
387
388                        if let Some(task) = task {
389                            let hash_hex = to_hex(&task.cid.hash);
390
391                            // Check if already syncing
392                            {
393                                let mut s = syncing.write().await;
394                                if s.contains(&hash_hex) {
395                                    continue;
396                                }
397                                s.insert(hash_hex.clone());
398                            }
399
400                            // Spawn sync task
401                            let syncing_clone = syncing.clone();
402                            let store_clone = store.clone();
403                            let fetcher_clone = fetcher.clone();
404
405                            tokio::spawn(async move {
406                                let result = fetcher_clone.fetch_cid_tree(
407                                    &store_clone,
408                                    &task.cid,
409                                ).await;
410
411                                match result {
412                                    Ok((chunks_fetched, bytes_fetched)) => {
413                                        if chunks_fetched > 0 {
414                                            info!(
415                                                "Synced tree {} ({} chunks, {} bytes)",
416                                                &hash_hex[..12],
417                                                chunks_fetched,
418                                                bytes_fetched
419                                            );
420                                        } else {
421                                            tracing::debug!(
422                                                "Tree {} already present locally; applying ref update",
423                                                &hash_hex[..12]
424                                            );
425                                        }
426
427                                        match store_clone.blob_exists(&task.cid.hash) {
428                                            Ok(true) => {}
429                                            Ok(false) => {
430                                                warn!(
431                                                    "Skipping ref update for {} because root {} is still missing locally",
432                                                    task.key,
433                                                    &hash_hex[..12]
434                                                );
435                                                syncing_clone.write().await.remove(&hash_hex);
436                                                return;
437                                            }
438                                            Err(err) => {
439                                                warn!(
440                                                    "Failed to verify synced root {} before indexing {}: {}",
441                                                    &hash_hex[..12],
442                                                    task.key,
443                                                    err
444                                                );
445                                                syncing_clone.write().await.remove(&hash_hex);
446                                                return;
447                                            }
448                                        }
449
450                                        if let Err(e) = apply_synced_tree_update(&store_clone, &task) {
451                                            warn!("Failed to apply synced tree {}: {}", &hash_hex[..12], e);
452                                        }
453                                    }
454                                    Err(e) => {
455                                        warn!("Failed to sync tree {}: {}", &hash_hex[..12], e);
456                                    }
457                                }
458
459                                // Remove from syncing set
460                                syncing_clone.write().await.remove(&hash_hex);
461                            });
462                        }
463                    }
464                }
465            }
466        });
467
468        // Handle Nostr notifications for tree updates
469        let mut notifications = self.client.notifications();
470        let subscriptions = self.subscriptions.clone();
471        let queue = self.queue.clone();
472        let mut pinned_refresh = tokio::time::interval(Duration::from_secs(5));
473        let mut shutdown_rx = self.shutdown_rx.clone();
474
475        loop {
476            tokio::select! {
477                _ = shutdown_rx.changed() => {
478                    if *shutdown_rx.borrow() {
479                        info!("Background sync shutting down");
480                        break;
481                    }
482                }
483                _ = pinned_refresh.tick() => {
484                    self.refresh_author_signing_keys().await;
485                    if let Err(err) = self.refresh_pinned_ref_subscriptions().await {
486                        warn!("Failed to refresh pinned ref subscriptions: {}", err);
487                    }
488                    if let Err(err) = self.refresh_tracked_author_subscriptions().await {
489                        warn!("Failed to refresh tracked author subscriptions: {}", err);
490                    }
491                }
492                notification = notifications.recv() => {
493                    match notification {
494                        Ok(RelayPoolNotification::Event { event, .. }) => {
495                            self.handle_tree_event(&event, &subscriptions, &queue).await;
496                        }
497                        Ok(_) => {}
498                        Err(e) => {
499                            error!("Notification error: {}", e);
500                            break;
501                        }
502                    }
503                }
504            }
505        }
506
507        Ok(())
508    }
509
510    async fn refresh_pinned_ref_subscriptions(&self) -> Result<()> {
511        let current_refs: HashSet<String> = self.store.list_pinned_refs()?.into_iter().collect();
512        {
513            let mut pinned_refs = self.pinned_refs.write().await;
514            *pinned_refs = current_refs.clone();
515        }
516
517        {
518            let mut subscriptions = self.subscriptions.write().await;
519            subscriptions.retain(|key, sub| {
520                sub.priority != SyncPriority::Pinned || current_refs.contains(key)
521            });
522        }
523
524        let new_refs: Vec<String> = {
525            let subscribed = self.subscribed_pinned_refs.read().await;
526            current_refs
527                .iter()
528                .filter(|key| !subscribed.contains(*key))
529                .cloned()
530                .collect()
531        };
532
533        for key in new_refs {
534            let filter = match build_exact_tree_filter(&key) {
535                Ok(filter) => filter,
536                Err(err) => {
537                    warn!("Ignoring invalid pinned ref {}: {}", key, err);
538                    continue;
539                }
540            };
541
542            match self.client.subscribe(filter, None).await {
543                Ok(_) => {
544                    info!("Subscribed to pinned ref {}", key);
545                    self.subscribed_pinned_refs.write().await.insert(key);
546                }
547                Err(err) => {
548                    warn!(
549                        "Failed to subscribe to pinned ref (will retry on refresh): {}",
550                        err
551                    );
552                }
553            }
554        }
555
556        Ok(())
557    }
558
559    async fn refresh_author_signing_keys(&self) {
560        let mut author_signing_keys = self.author_signing_keys.write().await;
561        *author_signing_keys = load_author_signing_keys();
562    }
563
564    async fn refresh_tracked_author_subscriptions(&self) -> Result<()> {
565        let tracked_npubs = self.store.list_tracked_authors()?;
566        let parsed_authors: Vec<(String, PublicKey, String)> = tracked_npubs
567            .into_iter()
568            .filter_map(|npub| match PublicKey::from_bech32(&npub) {
569                Ok(pubkey) => Some((npub, pubkey, pubkey.to_hex())),
570                Err(err) => {
571                    warn!("Ignoring invalid tracked author {}: {}", npub, err);
572                    None
573                }
574            })
575            .collect();
576        let current_authors: HashSet<String> = parsed_authors
577            .iter()
578            .map(|(_, _, author_hex)| author_hex.clone())
579            .collect();
580
581        {
582            let mut tracked_authors = self.tracked_authors.write().await;
583            *tracked_authors = current_authors.clone();
584        }
585
586        {
587            let mut subscriptions = self.subscriptions.write().await;
588            subscriptions.retain(|key, sub| {
589                if sub.priority != SyncPriority::TrackedAuthor {
590                    return true;
591                }
592
593                let Some((npub, _)) = key.split_once('/') else {
594                    return false;
595                };
596                let Ok(author) = PublicKey::from_bech32(npub) else {
597                    return false;
598                };
599                current_authors.contains(&author.to_hex())
600            });
601        }
602
603        let new_authors: Vec<(String, PublicKey)> = {
604            let subscribed = self.subscribed_tracked_authors.read().await;
605            parsed_authors
606                .iter()
607                .filter(|(_, _, author_hex)| !subscribed.contains(author_hex))
608                .map(|(_, pubkey, author_hex)| (author_hex.clone(), *pubkey))
609                .collect()
610        };
611
612        for (author_hex, author) in new_authors {
613            match self
614                .client
615                .subscribe(build_author_tree_filter(author), None)
616                .await
617            {
618                Ok(_) => {
619                    info!(
620                        "Subscribed to tracked author {}",
621                        author.to_bech32().unwrap_or(author_hex.clone())
622                    );
623                    self.subscribed_tracked_authors
624                        .write()
625                        .await
626                        .insert(author_hex);
627                }
628                Err(err) => {
629                    warn!(
630                        "Failed to subscribe to tracked author (will retry on refresh): {}",
631                        err
632                    );
633                }
634            }
635        }
636
637        Ok(())
638    }
639
640    /// Subscribe to own trees from our pubkey.
641    async fn subscribe_own_trees(&self) -> Result<()> {
642        let filter = build_author_tree_filter(self.my_pubkey);
643
644        match self.client.subscribe(filter, None).await {
645            Ok(_) => {
646                info!(
647                    "Subscribed to own trees for {}",
648                    self.my_pubkey.to_bech32().unwrap_or_default()
649                );
650            }
651            Err(e) => {
652                warn!(
653                    "Failed to subscribe to own trees (will retry on reconnect): {}",
654                    e
655                );
656            }
657        }
658
659        Ok(())
660    }
661
662    /// Subscribe to followed users' trees
663    async fn subscribe_followed_trees(&self, contacts_file: &PathBuf) -> Result<()> {
664        // Load contacts from file
665        let contacts: Vec<String> = if contacts_file.exists() {
666            let data = std::fs::read_to_string(contacts_file)?;
667            serde_json::from_str(&data).unwrap_or_default()
668        } else {
669            Vec::new()
670        };
671
672        if contacts.is_empty() {
673            self.followed_authors.write().await.clear();
674            info!("No contacts to subscribe to");
675            return Ok(());
676        }
677
678        {
679            let mut followed_authors = self.followed_authors.write().await;
680            *followed_authors = contacts.iter().cloned().collect();
681        }
682
683        // Convert hex pubkeys to PublicKey
684        let pubkeys: Vec<PublicKey> = contacts
685            .iter()
686            .filter_map(|hex| PublicKey::from_hex(hex).ok())
687            .collect();
688
689        if pubkeys.is_empty() {
690            return Ok(());
691        }
692
693        // Subscribe to all followed users' hashtree events
694        let filter = Filter::new()
695            .kinds(hashtree_root_kinds())
696            .authors(pubkeys.clone())
697            .custom_tag(SingleLetterTag::lowercase(Alphabet::L), "hashtree");
698
699        match self.client.subscribe(filter, None).await {
700            Ok(_) => {
701                info!("Subscribed to {} followed users' trees", pubkeys.len());
702            }
703            Err(e) => {
704                warn!(
705                    "Failed to subscribe to followed trees (will retry on reconnect): {}",
706                    e
707                );
708            }
709        }
710
711        Ok(())
712    }
713
714    /// Handle incoming tree event
715    async fn handle_tree_event(
716        &self,
717        event: &Event,
718        subscriptions: &Arc<RwLock<HashMap<String, TreeSubscription>>>,
719        queue: &Arc<RwLock<VecDeque<SyncTask>>>,
720    ) {
721        // Check if it's a hashtree event
722        let has_hashtree_tag = event.tags.iter().any(|tag| {
723            let v = tag.as_slice();
724            v.len() >= 2 && v[0] == "l" && v[1] == "hashtree"
725        });
726
727        if !has_hashtree_tag || !is_hashtree_root_kind(event.kind) {
728            return;
729        }
730
731        // Extract d-tag (tree name)
732        let d_tag = event.tags.iter().find_map(|tag| {
733            if let Some(TagStandard::Identifier(id)) = tag.as_standardized() {
734                Some(id.clone())
735            } else {
736                None
737            }
738        });
739
740        let tree_name = match d_tag {
741            Some(name) => name,
742            None => return,
743        };
744
745        // Build key
746        let npub = event
747            .pubkey
748            .to_bech32()
749            .unwrap_or_else(|_| event.pubkey.to_hex());
750        let key = format!("{}/{}", npub, tree_name);
751
752        let author_hex = event.pubkey.to_hex();
753        let pinned_refs = self.pinned_refs.read().await.clone();
754        let tracked_authors = self.tracked_authors.read().await.clone();
755        let followed_authors = self.followed_authors.read().await.clone();
756
757        // Determine priority and ignore stale events from refs we no longer care about.
758        let Some(priority) = classify_sync_event(
759            &key,
760            &author_hex,
761            &self.my_pubkey,
762            &pinned_refs,
763            &tracked_authors,
764            &followed_authors,
765        ) else {
766            return;
767        };
768
769        let author_keys = self
770            .author_signing_keys
771            .read()
772            .await
773            .get(&author_hex)
774            .cloned();
775        let Some(cid) = cid_from_tree_event(event, author_keys.as_ref()) else {
776            return;
777        };
778
779        // Check if we need to sync
780        let should_sync = {
781            let mut subs = subscriptions.write().await;
782            let sub = subs.entry(key.clone()).or_insert(TreeSubscription {
783                key: key.clone(),
784                current_cid: None,
785                priority,
786                last_synced: None,
787            });
788
789            // Check if CID changed
790            let changed = sub.current_cid.as_ref().map(|c| c.hash) != Some(cid.hash);
791            if changed {
792                sub.current_cid = Some(cid.clone());
793                true
794            } else {
795                false
796            }
797        };
798
799        if should_sync {
800            info!(
801                "New tree update: {} -> {}",
802                key,
803                to_hex(&cid.hash)[..12].to_string()
804            );
805
806            // Add to sync queue
807            let task = SyncTask {
808                key,
809                cid,
810                priority,
811                queued_at: Instant::now(),
812            };
813
814            let mut q = queue.write().await;
815
816            // Insert based on priority (own trees first)
817            let insert_pos = q
818                .iter()
819                .position(|t| t.priority > task.priority)
820                .unwrap_or(q.len());
821            q.insert(insert_pos, task);
822        }
823    }
824
825    /// Signal shutdown
826    pub fn shutdown(&self) {
827        let _ = self.shutdown_tx.send(true);
828    }
829
830    /// Queue a manual sync for a specific tree
831    pub async fn queue_sync(&self, key: &str, cid: Cid, priority: SyncPriority) {
832        let task = SyncTask {
833            key: key.to_string(),
834            cid,
835            priority,
836            queued_at: Instant::now(),
837        };
838
839        let mut q = self.queue.write().await;
840        let insert_pos = q
841            .iter()
842            .position(|t| t.priority > task.priority)
843            .unwrap_or(q.len());
844        q.insert(insert_pos, task);
845    }
846
847    /// Get current sync status
848    pub async fn status(&self) -> SyncStatus {
849        let subscriptions = self.subscriptions.read().await;
850        let queue = self.queue.read().await;
851        let syncing = self.syncing.read().await;
852
853        SyncStatus {
854            subscribed_trees: subscriptions.len(),
855            queued_tasks: queue.len(),
856            active_syncs: syncing.len(),
857        }
858    }
859}
860
861/// Overall sync status
862#[derive(Debug, Clone)]
863pub struct SyncStatus {
864    pub subscribed_trees: usize,
865    pub queued_tasks: usize,
866    pub active_syncs: usize,
867}
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872    use git_remote_htree::nostr_client::KIND_HASHTREE_ROOT;
873    use nostr_sdk::Keys;
874    use std::fs;
875    use tempfile::TempDir;
876
877    fn upload_repo_root(
878        store: &HashtreeStore,
879        base: &std::path::Path,
880        name: &str,
881        body: &str,
882    ) -> Cid {
883        let dir = base.join(name);
884        fs::create_dir_all(&dir).expect("create repo dir");
885        fs::write(dir.join("README.md"), body).expect("write repo file");
886        let cid = store
887            .upload_dir_with_options(&dir, true)
888            .expect("upload repo directory");
889        let cid = Cid::parse(&cid).expect("parse repo cid");
890        store.unpin(&cid.hash).expect("clear upload auto-pin");
891        cid
892    }
893
894    #[test]
895    fn classify_sync_event_ignores_removed_pinned_refs() {
896        let keys = Keys::generate();
897        let author = Keys::generate().public_key();
898        let key = format!("{}/repo", author.to_bech32().expect("author npub"));
899
900        let priority = classify_sync_event(
901            &key,
902            &author.to_hex(),
903            &keys.public_key(),
904            &HashSet::new(),
905            &HashSet::new(),
906            &HashSet::new(),
907        );
908
909        assert_eq!(priority, None);
910    }
911
912    #[test]
913    fn classify_sync_event_prioritizes_tracked_authors() {
914        let keys = Keys::generate();
915        let author = Keys::generate().public_key();
916        let key = format!("{}/repo", author.to_bech32().expect("author npub"));
917
918        let mut tracked_authors = HashSet::new();
919        tracked_authors.insert(author.to_hex());
920
921        let priority = classify_sync_event(
922            &key,
923            &author.to_hex(),
924            &keys.public_key(),
925            &HashSet::new(),
926            &tracked_authors,
927            &HashSet::new(),
928        );
929
930        assert_eq!(priority, Some(SyncPriority::TrackedAuthor));
931    }
932
933    #[test]
934    fn tracked_author_private_event_uses_matching_local_key() {
935        let author = Keys::generate();
936        let root_hash = [0x11; 32];
937        let root_key = [0x22; 32];
938        let ciphertext = nip44::encrypt(
939            author.secret_key(),
940            &author.public_key(),
941            hex::encode(root_key),
942            nip44::Version::V2,
943        )
944        .expect("encrypt private root key");
945        let event = EventBuilder::new(Kind::Custom(KIND_HASHTREE_ROOT), "")
946            .tags(vec![
947                Tag::identifier("backup".to_string()),
948                Tag::custom(
949                    TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::L)),
950                    vec!["hashtree"],
951                ),
952                Tag::custom(TagKind::Custom("hash".into()), vec![hex::encode(root_hash)]),
953                Tag::custom(TagKind::Custom("selfEncryptedKey".into()), vec![ciphertext]),
954            ])
955            .sign_with_keys(&author)
956            .expect("sign private root event");
957
958        let cid = cid_from_tree_event(&event, Some(&author)).expect("decrypt tracked private cid");
959
960        assert_eq!(cid.hash, root_hash);
961        assert_eq!(cid.key, Some(root_key));
962    }
963
964    #[test]
965    fn pinned_sync_update_replaces_old_root_pin() {
966        let temp_dir = TempDir::new().expect("temp dir");
967        let store = HashtreeStore::new(temp_dir.path().join("store")).expect("store");
968        let first_cid = upload_repo_root(&store, temp_dir.path(), "repo-v1", "version one\n");
969        let second_cid = upload_repo_root(&store, temp_dir.path(), "repo-v2", "version two\n");
970        let repo_key = format!(
971            "{}/repo",
972            Keys::generate()
973                .public_key()
974                .to_bech32()
975                .expect("repo owner npub")
976        );
977
978        let first_task = SyncTask {
979            key: repo_key.clone(),
980            cid: first_cid.clone(),
981            priority: SyncPriority::Pinned,
982            queued_at: Instant::now(),
983        };
984        apply_synced_tree_update(&store, &first_task).expect("apply first sync update");
985
986        assert!(store.is_pinned(&first_cid.hash).expect("first root pinned"));
987        assert_eq!(
988            store.get_tree_ref(&repo_key).expect("first tree ref"),
989            Some(first_cid.hash)
990        );
991
992        let second_task = SyncTask {
993            key: repo_key.clone(),
994            cid: second_cid.clone(),
995            priority: SyncPriority::Pinned,
996            queued_at: Instant::now(),
997        };
998        apply_synced_tree_update(&store, &second_task).expect("apply second sync update");
999
1000        assert!(
1001            !store
1002                .is_pinned(&first_cid.hash)
1003                .expect("first root pin status"),
1004            "updating a pinned ref should unpin the superseded root"
1005        );
1006        assert!(store
1007            .is_pinned(&second_cid.hash)
1008            .expect("second root pinned"));
1009        assert_eq!(
1010            store.get_tree_ref(&repo_key).expect("updated tree ref"),
1011            Some(second_cid.hash)
1012        );
1013        assert!(
1014            store
1015                .get_tree_meta(&first_cid.hash)
1016                .expect("first meta lookup")
1017                .is_none(),
1018            "superseded pinned root should be unindexed after update"
1019        );
1020    }
1021
1022    #[test]
1023    fn tracked_author_sync_update_replaces_old_root_pin() {
1024        let temp_dir = TempDir::new().expect("temp dir");
1025        let store = HashtreeStore::new(temp_dir.path().join("store")).expect("store");
1026        let first_cid = upload_repo_root(&store, temp_dir.path(), "repo-v1", "version one\n");
1027        let second_cid = upload_repo_root(&store, temp_dir.path(), "repo-v2", "version two\n");
1028        let repo_key = format!(
1029            "{}/repo",
1030            Keys::generate()
1031                .public_key()
1032                .to_bech32()
1033                .expect("repo owner npub")
1034        );
1035
1036        let first_task = SyncTask {
1037            key: repo_key.clone(),
1038            cid: first_cid.clone(),
1039            priority: SyncPriority::TrackedAuthor,
1040            queued_at: Instant::now(),
1041        };
1042        apply_synced_tree_update(&store, &first_task).expect("apply first tracked sync update");
1043
1044        assert!(store.is_pinned(&first_cid.hash).expect("first root pinned"));
1045        assert_eq!(
1046            store.get_tree_ref(&repo_key).expect("first tree ref"),
1047            Some(first_cid.hash)
1048        );
1049
1050        let second_task = SyncTask {
1051            key: repo_key.clone(),
1052            cid: second_cid.clone(),
1053            priority: SyncPriority::TrackedAuthor,
1054            queued_at: Instant::now(),
1055        };
1056        apply_synced_tree_update(&store, &second_task).expect("apply second tracked sync update");
1057
1058        assert!(
1059            !store
1060                .is_pinned(&first_cid.hash)
1061                .expect("first root pin status"),
1062            "updating a tracked author ref should unpin the superseded root"
1063        );
1064        assert!(store
1065            .is_pinned(&second_cid.hash)
1066            .expect("second root pinned"));
1067        assert_eq!(
1068            store.get_tree_ref(&repo_key).expect("updated tree ref"),
1069            Some(second_cid.hash)
1070        );
1071    }
1072}