Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica
Skip to main content

eidetica/sync/
ops.rs

1//! Core sync operations for the sync system.
2
3use std::future::Future;
4use std::time::Duration;
5
6use tokio::sync::oneshot;
7use tracing::{debug, info, warn};
8
9use super::{
10    Address, DatabaseTicket, PeerId, Sync, SyncError,
11    background::SyncCommand,
12    peer_manager::PeerManager,
13    peer_types,
14    protocol::{self, SyncRequest, SyncRequestAuth, SyncResponse, SyncTreeRequest},
15    user_sync_manager::UserSyncManager,
16};
17use crate::{
18    Database, Entry, Result,
19    auth::Permission,
20    auth::crypto::{PrivateKey, PublicKey},
21    crdt::Doc,
22    entry::ID,
23    store::DocStore,
24};
25
26use super::utils::collect_ancestors_to_send;
27
28impl Sync {
29    // === Core Sync Methods ===
30
31    /// Synchronize a specific tree with a peer using bidirectional sync.
32    ///
33    /// This is the main synchronization method that implements tip exchange
34    /// and bidirectional entry transfer to keep trees in sync between peers.
35    /// It performs both pull (fetch missing entries) and push (send our entries).
36    ///
37    /// # Arguments
38    /// * `peer_pubkey` - The public key of the peer to sync with
39    /// * `tree_id` - The ID of the tree to synchronize
40    ///
41    /// # Returns
42    /// A Result indicating success or failure of the sync operation.
43    pub async fn sync_tree_with_peer(&self, peer_pubkey: &PublicKey, tree_id: &ID) -> Result<()> {
44        self.sync_tree_with_peer_as(peer_pubkey, tree_id, None)
45            .await
46    }
47
48    /// Synchronize a tree with a peer, signing the request with `signing_key`.
49    ///
50    /// The peer authorizes the pull against the key that signed it, so this must
51    /// be a key with read access on `tree_id`. `None` uses this instance's device
52    /// key, which is right when the device itself holds the access (directly or
53    /// through a delegated tree). A database that instead granted a *user* key —
54    /// the usual outcome of bootstrapping with one — needs that key here.
55    pub async fn sync_tree_with_peer_as(
56        &self,
57        peer_pubkey: &PublicKey,
58        tree_id: &ID,
59        signing_key: Option<&PrivateKey>,
60    ) -> Result<()> {
61        // Get peer information and address
62        let peer_info = self
63            .get_peer_info(peer_pubkey)
64            .await?
65            .ok_or_else(|| SyncError::PeerNotFound(peer_pubkey.to_string()))?;
66
67        let address = peer_info
68            .addresses
69            .first()
70            .ok_or_else(|| SyncError::Network("No addresses found for peer".to_string()))?;
71
72        // Get our current tips for this tree (empty if tree doesn't exist)
73        let backend = self.backend()?;
74        let our_tips = backend
75            .snapshot(tree_id)
76            .await
77            .map_err(|e| SyncError::BackendError(format!("Failed to get local tips: {e}")))?;
78
79        // Get our device public key for automatic peer tracking
80        let our_device_pubkey = self.get_device_pubkey().ok();
81
82        // Send unified sync request, signed so the peer can authorize the pull
83        let instance = self.instance()?;
84        let auth = SyncRequestAuth::sign(
85            signing_key.unwrap_or(instance.signing_key()?),
86            peer_pubkey,
87            tree_id,
88            &our_tips,
89            instance.clock().now_millis(),
90        );
91        let request = SyncRequest::SyncTree(SyncTreeRequest {
92            tree_id: tree_id.clone(),
93            our_tips,
94            peer_pubkey: our_device_pubkey,
95            requesting_key: None,
96            requesting_key_name: None,
97            requested_permission: None,
98            metadata: None,
99            auth: Some(auth),
100        });
101
102        // Send request via background sync command
103        let (tx, rx) = oneshot::channel();
104        self.background_tx
105            .get()
106            .ok_or(SyncError::NoTransportEnabled)?
107            .send(SyncCommand::SendRequest {
108                address: address.clone(),
109                request: Box::new(request),
110                response: tx,
111            })
112            .await
113            .map_err(|e| SyncError::CommandSendError(e.to_string()))?;
114
115        let response = rx
116            .await
117            .map_err(|e| SyncError::Network(format!("Response channel error: {e}")))?
118            .map_err(|e| SyncError::Network(format!("Request failed: {e}")))?;
119
120        match response {
121            SyncResponse::Bootstrap(bootstrap_response) => {
122                self.handle_bootstrap_response(bootstrap_response).await?;
123            }
124            SyncResponse::Incremental(incremental_response) => {
125                self.handle_incremental_response(incremental_response, address)
126                    .await?;
127            }
128            SyncResponse::Error(msg) => {
129                return Err(SyncError::SyncProtocolError(format!("Sync error: {msg}")).into());
130            }
131            _ => {
132                return Err(SyncError::UnexpectedResponse {
133                    expected: "Bootstrap or Incremental",
134                    actual: format!("{response:?}"),
135                }
136                .into());
137            }
138        }
139
140        // Track tree/peer relationship for sync_on_commit to work
141        // This allows on_local_write() to find this peer when queueing entries
142        self.add_tree_sync(peer_pubkey, tree_id).await?;
143
144        Ok(())
145    }
146
147    /// Handle bootstrap response by storing root and all entries
148    pub(super) async fn handle_bootstrap_response(
149        &self,
150        response: protocol::BootstrapResponse,
151    ) -> Result<()> {
152        tracing::info!(tree_id = %response.tree_id, "Processing bootstrap response");
153
154        // Integrity check: the root entry's content must hash to the declared
155        // tree_id. Rejects peers serving substituted content. A mismatch here
156        // also covers the cross-algorithm bootstrap case (e.g. a SHA-256 tree
157        // advertised to a BLAKE3-default node) — those are unsupported until
158        // the backend gains multi-CID-per-entry storage, so failing loudly is
159        // better than silently re-keying the DAG under the wrong algorithm.
160        let derived = response.root_entry.id();
161        if derived != response.tree_id {
162            return Err(SyncError::InvalidEntry(format!(
163                "root entry content hashes to {} but bootstrap response declares tree_id {}",
164                derived, response.tree_id
165            ))
166            .into());
167        }
168
169        // Combine root entry with all other entries into a single batch
170        let mut all_entries = Vec::with_capacity(1 + response.all_entries.len());
171        all_entries.push(response.root_entry);
172        all_entries.extend(response.all_entries);
173
174        // Store all entries and fire callbacks once
175        self.store_received_entries(&response.tree_id, all_entries)
176            .await?;
177
178        tracing::info!(tree_id = %response.tree_id, "Bootstrap completed successfully");
179        Ok(())
180    }
181
182    /// Handle incremental response by storing missing entries and sending back what server is missing
183    pub(super) async fn handle_incremental_response(
184        &self,
185        response: protocol::IncrementalResponse,
186        peer_address: &peer_types::Address,
187    ) -> Result<()> {
188        tracing::debug!(tree_id = %response.tree_id, "Processing incremental response");
189
190        // Step 1: Store missing entries
191        self.store_received_entries(&response.tree_id, response.missing_entries)
192            .await?;
193
194        // Step 2: Check if server is missing entries from us
195        let backend = self.backend()?;
196        let our_snapshot = backend.snapshot(&response.tree_id).await?;
197        let their_tips = &response.their_tips;
198
199        // Find tips they don't have
200        let missing_tip_ids: Vec<_> = our_snapshot
201            .tips()
202            .iter()
203            .filter(|tip_id| !their_tips.contains(tip_id))
204            .cloned()
205            .collect();
206
207        if !missing_tip_ids.is_empty() {
208            tracing::debug!(
209                tree_id = %response.tree_id,
210                missing_tips = missing_tip_ids.len(),
211                "Server is missing some of our entries, sending them back"
212            );
213
214            // Collect entries server is missing
215            let engine = self
216                .backend()?
217                .local_engine()
218                .expect("sync requires local backend");
219            let entries_for_server =
220                collect_ancestors_to_send(engine.as_ref(), &missing_tip_ids, their_tips).await?;
221
222            if !entries_for_server.is_empty() {
223                // Send these entries back to server
224                self.send_missing_entries_to_peer(
225                    peer_address,
226                    &response.tree_id,
227                    entries_for_server,
228                )
229                .await?;
230            }
231        }
232
233        tracing::debug!(tree_id = %response.tree_id, "Incremental sync completed");
234        Ok(())
235    }
236
237    /// Send entries that the server is missing back to complete bidirectional sync
238    async fn send_missing_entries_to_peer(
239        &self,
240        peer_address: &peer_types::Address,
241        tree_id: &ID,
242        entries: Vec<Entry>,
243    ) -> Result<()> {
244        if entries.is_empty() {
245            return Ok(());
246        }
247
248        tracing::debug!(
249            tree_id = %tree_id,
250            entry_count = entries.len(),
251            "Sending missing entries back to peer for bidirectional sync"
252        );
253
254        let request = protocol::SyncRequest::SendEntries(entries);
255
256        // Send via command channel
257        let (tx, rx) = tokio::sync::oneshot::channel();
258        self.background_tx
259            .get()
260            .ok_or(SyncError::NoTransportEnabled)?
261            .send(SyncCommand::SendRequest {
262                address: peer_address.clone(),
263                request: Box::new(request),
264                response: tx,
265            })
266            .await
267            .map_err(|e| SyncError::CommandSendError(e.to_string()))?;
268
269        // Wait for acknowledgment
270        let response = rx
271            .await
272            .map_err(|e| SyncError::Network(format!("Response channel error: {e}")))?
273            .map_err(|e| SyncError::Network(format!("Request failed: {e}")))?;
274
275        match response {
276            protocol::SyncResponse::Ack | protocol::SyncResponse::Count(_) => {
277                tracing::debug!(tree_id = %tree_id, "Server acknowledged receipt of missing entries");
278                Ok(())
279            }
280            protocol::SyncResponse::Error(e) => {
281                Err(SyncError::Network(format!("Server error receiving entries: {e}")).into())
282            }
283            _ => Err(SyncError::UnexpectedResponse {
284                expected: "Ack or Count",
285                actual: format!("{response:?}"),
286            }
287            .into()),
288        }
289    }
290
291    /// Validate and store received entries from a peer, firing remote write callbacks.
292    pub(super) async fn store_received_entries(
293        &self,
294        tree_id: &ID,
295        entries: Vec<Entry>,
296    ) -> Result<()> {
297        // These entries arrive without per-entry declared IDs — they were batched
298        // under a single tree_id by the sender. Content is stored under whatever
299        // ID our local `entry.id()` derives, so substitution attacks on individual
300        // entries would fail DAG connectivity checks via parent pointers rather
301        // than a per-entry hash check here. Root-level integrity is verified by
302        // the bootstrap handler against the declared tree_id.
303        //
304        // TODO: Add signature verification and parent-existence / DAG-connectivity
305        // checks before marking entries as verified.
306
307        // Store entries and fire callbacks via Instance::put_remote_entries.
308        // Stored Unverified: these arrived from a peer and have not been
309        // verified by this node.
310        let instance = self.instance()?;
311        instance
312            .put_remote_entries(tree_id, entries)
313            .await
314            .map_err(|e| SyncError::BackendError(format!("Failed to store entries: {e}")))?;
315
316        Ok(())
317    }
318
319    /// Send a batch of entries to a sync peer (async version).
320    ///
321    /// # Arguments
322    /// * `entries` - The entries to send
323    /// * `address` - The address of the peer to send to
324    ///
325    /// # Returns
326    /// A Result indicating whether the entries were successfully acknowledged.
327    pub async fn send_entries(
328        &self,
329        entries: impl AsRef<[Entry]>,
330        address: &Address,
331    ) -> Result<()> {
332        let entries_vec = entries.as_ref().to_vec();
333        let request = SyncRequest::SendEntries(entries_vec);
334        let response = self.send_request(&request, address).await?;
335
336        match response {
337            SyncResponse::Ack | SyncResponse::Count(_) => Ok(()),
338            SyncResponse::Error(msg) => Err(SyncError::SyncProtocolError(format!(
339                "Peer {} returned error: {}",
340                address.address, msg
341            ))
342            .into()),
343            _ => Err(SyncError::UnexpectedResponse {
344                expected: "Ack or Count",
345                actual: format!("{response:?}"),
346            }
347            .into()),
348        }
349    }
350
351    /// Send specific entries to a peer via the background sync engine.
352    ///
353    /// This method queues entries for direct transmission without duplicate filtering.
354    /// The caller is responsible for determining which entries should be sent.
355    ///
356    /// # Duplicate Prevention Architecture
357    ///
358    /// Eidetica uses **smart duplicate prevention** in the background sync engine:
359    /// - **Database sync** (`SyncWithPeer` command): Uses tip comparison for semantic filtering
360    /// - **Direct send** (this method): Trusts caller to provide appropriate entries
361    ///
362    /// For automatic duplicate prevention, use tree-based sync relationships instead
363    /// of calling this method directly.
364    ///
365    /// # Arguments
366    /// * `peer_id` - The peer ID to send to
367    /// * `entries` - The specific entries to send (no filtering applied)
368    ///
369    /// # Returns
370    /// A Result indicating whether the command was successfully queued for background processing.
371    pub async fn send_entries_to_peer(&self, peer_id: &PeerId, entries: Vec<Entry>) -> Result<()> {
372        self.background_tx
373            .get()
374            .ok_or(SyncError::NoTransportEnabled)?
375            .send(SyncCommand::SendEntries {
376                peer: peer_id.clone(),
377                entries,
378            })
379            .await
380            .map_err(|e| SyncError::CommandSendError(e.to_string()))?;
381        Ok(())
382    }
383
384    /// Queue an entry for sync to a peer (non-blocking, for use in callbacks).
385    ///
386    /// This method is designed for use in write callbacks where async operations
387    /// are not possible. It uses try_send to avoid blocking, and logs errors
388    /// rather than failing the callback.
389    ///
390    /// # Arguments
391    /// * `peer_pubkey` - The public key of the peer to sync with
392    /// * `entry_id` - The ID of the entry to queue
393    /// * `tree_id` - The tree ID where the entry belongs
394    ///
395    /// # Returns
396    /// Ok(()) if the entry was successfully queued.
397    /// Only returns Err if transport is not enabled.
398    pub fn queue_entry_for_sync(
399        &self,
400        peer_id: &PeerId,
401        entry_id: &ID,
402        tree_id: &ID,
403    ) -> Result<()> {
404        // Ensure background sync is running
405        if self.background_tx.get().is_none() {
406            return Err(SyncError::NoTransportEnabled.into());
407        }
408
409        // Add to queue - BackgroundSync will process and send
410        self.queue
411            .enqueue(peer_id, entry_id.clone(), tree_id.clone());
412
413        Ok(())
414    }
415
416    /// Handle local write events for automatic sync.
417    ///
418    /// This method is called by the Instance write callback system when entries
419    /// are committed locally. It looks up the combined sync settings for the database
420    /// and queues the entry for sync with all configured peers if sync is enabled.
421    ///
422    /// This is the core method that implements automatic sync-on-commit behavior.
423    ///
424    /// # Arguments
425    /// * `event` - The write event containing the newly committed entries
426    /// * `database` - The database where the entries were committed
427    ///
428    /// # Returns
429    /// Ok(()) on success, or an error if settings lookup fails
430    pub(crate) async fn on_local_write(
431        &self,
432        event: &crate::instance::WriteEvent,
433        database: &Database,
434    ) -> Result<()> {
435        // Early return if background sync not running
436        if self.background_tx.get().is_none() {
437            return Ok(());
438        }
439
440        // Look up combined settings for this database
441        let tx = self.sync_tree.new_transaction().await?;
442        let user_mgr = UserSyncManager::new(&tx);
443        let peer_mgr = PeerManager::new(&tx);
444
445        let combined_settings = match user_mgr.get_combined_settings(database.root_id()).await? {
446            Some(settings) => settings,
447            None => {
448                // No settings configured for this database - no sync needed
449                debug!(database_id = %database.root_id(), "No sync settings for database, skipping");
450                return Ok(());
451            }
452        };
453
454        // Check if sync is enabled and sync_on_commit is true
455        if !combined_settings.sync_enabled || !combined_settings.sync_on_commit {
456            debug!(
457                database_id = %database.root_id(),
458                sync_enabled = combined_settings.sync_enabled,
459                sync_on_commit = combined_settings.sync_on_commit,
460                "Sync not enabled for database"
461            );
462            return Ok(());
463        }
464
465        // Get list of peers for this database
466        let peers = peer_mgr.get_tree_peers(database.root_id()).await?;
467
468        if peers.is_empty() {
469            debug!(database_id = %database.root_id(), "No peers configured for database");
470            return Ok(());
471        }
472
473        // Queue each entry for sync with each peer. The event no longer
474        // carries entry payloads — expand the cursor advance back into a
475        // concrete set of IDs by walking the DAG diff. Cost is bounded
476        // by the cursor delta, not the full tree.
477        let tree_id = database.root_id();
478
479        let new_ids = database
480            .ids_added(event.previous_tips(), event.post_tips())
481            .await?;
482
483        for entry_id in &new_ids {
484            debug!(
485                database_id = %tree_id,
486                entry_id = %entry_id,
487                peer_count = peers.len(),
488                "Queueing entry for automatic sync"
489            );
490
491            for peer_id in &peers {
492                self.queue_entry_for_sync(peer_id, entry_id, tree_id)?;
493            }
494        }
495
496        Ok(())
497    }
498
499    /// Initialize combined settings for all users.
500    ///
501    /// This is called during Sync initialization. For new sync trees (just created),
502    /// it scans the _users database to register all existing users. For existing
503    /// sync trees (loaded), it updates combined settings for already-tracked users.
504    pub(super) async fn initialize_user_settings(&self) -> Result<()> {
505        use crate::store::Table;
506        use crate::user::types::UserInfo;
507
508        // Check if sync tree is freshly created (no users tracked yet)
509        let user_tracking = self
510            .sync_tree
511            .get_store_viewer::<DocStore>(super::user_sync_manager::USER_TRACKING_SUBTREE)
512            .await?;
513        let all_tracked = user_tracking.get_all().await?;
514
515        if all_tracked.keys().count() == 0 {
516            // New sync tree - register all users from _users database
517            let instance = self.instance.upgrade().ok_or(SyncError::InstanceDropped)?;
518            let users_db = instance.users_db().await?;
519            let users_table = users_db
520                .get_store_viewer::<Table<UserInfo>>("users")
521                .await?;
522            let all_users = users_table.search(|_| true).await?;
523
524            for (user_uuid, user_info) in all_users {
525                self.sync_user(&user_uuid, &user_info.user_database_id)
526                    .await?;
527            }
528        } else {
529            // Existing sync tree - update settings for tracked users if changed
530            let tx = self.sync_tree.new_transaction().await?;
531            let user_mgr = UserSyncManager::new(&tx);
532
533            for user_uuid in all_tracked.keys() {
534                if let Some((prefs_db_id, _tips)) =
535                    user_mgr.get_tracked_user_state(user_uuid).await?
536                {
537                    self.sync_user(user_uuid, &prefs_db_id).await?;
538                }
539            }
540        }
541
542        Ok(())
543    }
544
545    /// Send a sync request to a peer and get a response (async version).
546    ///
547    /// # Arguments
548    /// * `request` - The sync request to send
549    /// * `address` - The address of the peer
550    ///
551    /// # Returns
552    /// The sync response from the peer.
553    pub(super) async fn send_request(
554        &self,
555        request: &SyncRequest,
556        address: &Address,
557    ) -> Result<SyncResponse> {
558        let (tx, rx) = oneshot::channel();
559
560        self.background_tx
561            .get()
562            .ok_or(SyncError::NoTransportEnabled)?
563            .send(SyncCommand::SendRequest {
564                address: address.clone(),
565                request: Box::new(request.clone()),
566                response: tx,
567            })
568            .await
569            .map_err(|e| SyncError::CommandSendError(e.to_string()))?;
570
571        rx.await
572            .map_err(|e| SyncError::Network(format!("Response channel error: {e}")))?
573    }
574
575    /// Discover available trees from a peer (simplified API).
576    ///
577    /// This method connects to a peer and retrieves the list of trees they're willing to sync.
578    /// This is useful for discovering what can be synced before setting up sync relationships.
579    ///
580    /// # Arguments
581    /// * `address` - The transport address of the peer.
582    ///
583    /// # Returns
584    /// A vector of TreeInfo describing available trees, or an error.
585    pub async fn discover_peer_trees(&self, address: &Address) -> Result<Vec<protocol::TreeInfo>> {
586        // Connect and get handshake info
587        let _peer_pubkey = self.connect_to_peer(address).await?;
588
589        // The handshake already contains the tree list, but we need to get it again
590        // since connect_to_peer doesn't return it. For now, return empty list
591        // TODO: Enhance this to actually return the tree list from handshake
592
593        tracing::warn!(
594            "discover_peer_trees not fully implemented - handshake contains tree info but API needs enhancement"
595        );
596        Ok(vec![])
597    }
598
599    /// Sync with a peer at a given address.
600    ///
601    /// This is a blocking convenience method that:
602    /// 1. Connects to discover the peer's public key
603    /// 2. Registers the peer and performs immediate sync
604    /// 3. Returns after sync completes
605    ///
606    /// For new code, prefer using [`register_sync_peer()`](Self::register_sync_peer)
607    /// directly, which registers intent and lets background sync handle it.
608    ///
609    /// # Arguments
610    /// * `address` - The transport address of the peer.
611    /// * `tree_id` - Optional tree ID to sync (None = discover available trees)
612    ///
613    /// # Returns
614    /// Result indicating success or failure.
615    pub async fn sync_with_peer(&self, address: &Address, tree_id: Option<&ID>) -> Result<()> {
616        self.sync_with_peer_as(address, tree_id, None).await
617    }
618
619    /// Sync with a peer, signing requests with `signing_key`.
620    ///
621    /// See [`sync_tree_with_peer_as`](Self::sync_tree_with_peer_as) for which
622    /// key to pass.
623    pub async fn sync_with_peer_as(
624        &self,
625        address: &Address,
626        tree_id: Option<&ID>,
627        signing_key: Option<&PrivateKey>,
628    ) -> Result<()> {
629        // Connect to peer if not already connected
630        let peer_pubkey = self.connect_to_peer(address).await?;
631
632        // Store the address for this peer (needed for sync_tree_with_peer)
633        self.add_peer_address(&peer_pubkey, address.clone()).await?;
634
635        if let Some(tree_id) = tree_id {
636            // Sync specific tree
637            self.sync_tree_with_peer_as(&peer_pubkey, tree_id, signing_key)
638                .await?;
639        } else {
640            // TODO: Sync all available trees
641            tracing::warn!(
642                "Syncing all trees not yet implemented - need to enhance discover_peer_trees first"
643            );
644        }
645
646        Ok(())
647    }
648
649    /// Sync with a peer using a [`DatabaseTicket`].
650    ///
651    /// Attempts [`sync_with_peer`](Self::sync_with_peer) for every address
652    /// hint in the ticket concurrently. Each address may point to a different
653    /// peer, so connections are independent. Succeeds if at least one address
654    /// syncs successfully; returns the last error if all fail.
655    ///
656    /// # Arguments
657    /// * `ticket` - A ticket containing the database ID and address hints.
658    ///
659    /// # Errors
660    /// Returns [`SyncError::InvalidAddress`] if the ticket has no address hints.
661    /// Returns the last sync error if no address succeeded.
662    pub async fn sync_with_ticket(&self, ticket: &DatabaseTicket) -> Result<()> {
663        let database_id = ticket.database_id().clone();
664        self.try_addresses_concurrently(ticket.addresses(), |sync, addr| {
665            let db_id = database_id.clone();
666            async move { sync.sync_with_peer(&addr, Some(&db_id)).await }
667        })
668        .await
669    }
670
671    /// Sync a specific tree with a peer, with optional authentication for bootstrap.
672    ///
673    /// This is a lower-level method that allows specifying authentication parameters
674    /// for bootstrap scenarios where access needs to be requested.
675    ///
676    /// # Arguments
677    /// * `peer_pubkey` - The public key of the peer to sync with
678    /// * `tree_id` - The ID of the tree to sync
679    /// * `requesting_key` - Optional private key to sign with and request access for
680    /// * `requesting_key_name` - Optional name/ID of the requesting key
681    /// * `requested_permission` - Optional permission level being requested
682    ///
683    /// # Returns
684    /// A Result indicating success or failure.
685    pub async fn sync_tree_with_peer_auth(
686        &self,
687        peer_pubkey: &PublicKey,
688        tree_id: &ID,
689        requesting_key: Option<&PrivateKey>,
690        requesting_key_name: Option<&str>,
691        requested_permission: Option<Permission>,
692        metadata: Option<Doc>,
693    ) -> Result<()> {
694        // Get peer information and address
695        let peer_info = self
696            .get_peer_info(peer_pubkey)
697            .await?
698            .ok_or_else(|| SyncError::PeerNotFound(peer_pubkey.to_string()))?;
699
700        let address = peer_info
701            .addresses
702            .first()
703            .ok_or_else(|| SyncError::Network("No addresses found for peer".to_string()))?;
704
705        // Get our current tips for this tree (empty if tree doesn't exist)
706        let backend = self.backend()?;
707        let our_tips = backend
708            .snapshot(tree_id)
709            .await
710            .map_err(|e| SyncError::BackendError(format!("Failed to get local tips: {e}")))?;
711
712        // Get our device public key for automatic peer tracking
713        let our_device_pubkey = self.get_device_pubkey().ok();
714
715        // Send unified sync request with auth parameters. The signature proves
716        // we hold our device key; a bootstrap that asks for a *different* key
717        // cannot be proven and stays on the manual approval path.
718        let instance = self.instance()?;
719        let signing_key = requesting_key.unwrap_or(instance.signing_key()?);
720        let auth = SyncRequestAuth::sign(
721            signing_key,
722            peer_pubkey,
723            tree_id,
724            &our_tips,
725            instance.clock().now_millis(),
726        );
727        let request = SyncRequest::SyncTree(SyncTreeRequest {
728            tree_id: tree_id.clone(),
729            our_tips,
730            peer_pubkey: our_device_pubkey,
731            requesting_key: requesting_key.map(|k| k.public_key()),
732            requesting_key_name: requesting_key_name.map(|k| k.to_string()),
733            requested_permission,
734            metadata,
735            auth: Some(auth),
736        });
737
738        // Send request via background sync command
739        let (tx, rx) = oneshot::channel();
740        self.background_tx
741            .get()
742            .ok_or(SyncError::NoTransportEnabled)?
743            .send(SyncCommand::SendRequest {
744                address: address.clone(),
745                request: Box::new(request),
746                response: tx,
747            })
748            .await
749            .map_err(|_| {
750                SyncError::CommandSendError("Background sync command channel closed".to_string())
751            })?;
752
753        // Wait for response
754        let response = rx
755            .await
756            .map_err(|_| {
757                SyncError::CommandSendError("Background sync response channel closed".to_string())
758            })?
759            .map_err(|e| SyncError::Network(format!("Sync request failed: {e}")))?;
760
761        // Handle the response (same logic as existing sync_tree_with_peer)
762        match response {
763            SyncResponse::Bootstrap(bootstrap_response) => {
764                info!(peer = %peer_pubkey, tree = %tree_id, entry_count = bootstrap_response.all_entries.len() + 1, "Received bootstrap response");
765
766                // Store root + all entries as a single batch with callback dispatch
767                let mut all_entries = Vec::with_capacity(1 + bootstrap_response.all_entries.len());
768                all_entries.push(bootstrap_response.root_entry);
769                all_entries.extend(bootstrap_response.all_entries);
770
771                // Bootstrap entries come from a peer; stored Unverified.
772                let instance = self.instance()?;
773                instance.put_remote_entries(tree_id, all_entries).await?;
774
775                info!(peer = %peer_pubkey, tree = %tree_id, "Bootstrap sync completed successfully");
776            }
777            SyncResponse::Incremental(incremental_response) => {
778                info!(peer = %peer_pubkey, tree = %tree_id, missing_count = incremental_response.missing_entries.len(), "Received incremental sync response");
779
780                // Use the enhanced handler that supports bidirectional sync
781                self.handle_incremental_response(incremental_response, address)
782                    .await?;
783
784                debug!(peer = %peer_pubkey, tree = %tree_id, "Incremental sync completed");
785            }
786            SyncResponse::BootstrapPending {
787                request_id,
788                message,
789            } => {
790                info!(peer = %peer_pubkey, tree = %tree_id, request_id = %request_id, "Bootstrap request pending manual approval");
791                return Err(SyncError::BootstrapPending {
792                    request_id,
793                    message,
794                }
795                .into());
796            }
797            SyncResponse::Error(err) => {
798                return Err(SyncError::Network(format!("Peer returned error: {err}")).into());
799            }
800            _ => {
801                return Err(SyncError::SyncProtocolError(
802                    "Unexpected response type for sync tree request".to_string(),
803                )
804                .into());
805            }
806        }
807
808        // Track tree/peer relationship for sync_on_commit to work
809        // This allows on_local_write() to find this peer when queueing entries
810        self.add_tree_sync(peer_pubkey, tree_id).await?;
811
812        Ok(())
813    }
814
815    // === Flush Operations ===
816
817    /// Process all queued entries and retry any failed sends.
818    ///
819    /// This method:
820    /// 1. Retries all entries in the retry queue (ignoring backoff timers)
821    /// 2. Processes all entries in the sync queue (batched by peer)
822    ///
823    /// When this method returns, all pending sync work has been attempted.
824    /// This is useful to eensuree that all pending pushes have completed.
825    ///
826    /// # Returns
827    /// `Ok(())` if all operations completed successfully, or an error
828    /// if the background sync engine is not running or sends failed.
829    pub async fn flush(&self) -> Result<()> {
830        let (tx, rx) = oneshot::channel();
831
832        self.background_tx
833            .get()
834            .ok_or(SyncError::NoTransportEnabled)?
835            .send(SyncCommand::Flush { response: tx })
836            .await
837            .map_err(|e| SyncError::CommandSendError(e.to_string()))?;
838
839        rx.await
840            .map_err(|e| SyncError::Network(format!("Response channel error: {e}")))?
841    }
842
843    /// Timeout applied to each address attempt in
844    /// [`try_addresses_concurrently`](Self::try_addresses_concurrently).
845    const ADDRESS_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(30);
846
847    /// Try an operation against multiple addresses concurrently, returning on
848    /// the first success.
849    ///
850    /// Spawns one detached task per address via [`tokio::spawn`]. Returns as
851    /// soon as any task succeeds. Remaining tasks are **not** cancelled — they
852    /// continue running in the background so that additional peer connections
853    /// can be established and registered for future syncs. Each task is subject
854    /// to [`ADDRESS_ATTEMPT_TIMEOUT`](Self::ADDRESS_ATTEMPT_TIMEOUT).
855    ///
856    /// If all tasks fail the last error is returned. If `addresses` is empty
857    /// an [`SyncError::InvalidAddress`] error is returned.
858    pub(super) async fn try_addresses_concurrently<F, Fut>(
859        &self,
860        addresses: &[Address],
861        f: F,
862    ) -> Result<()>
863    where
864        F: Fn(Sync, Address) -> Fut,
865        Fut: Future<Output = Result<()>> + Send + 'static,
866    {
867        if addresses.is_empty() {
868            return Err(SyncError::InvalidAddress("Ticket has no address hints".into()).into());
869        }
870
871        let (tx, mut rx) = tokio::sync::mpsc::channel(addresses.len());
872
873        for addr in addresses {
874            let tx = tx.clone();
875            let fut = f(self.clone(), addr.clone());
876            let addr_info = addr.clone();
877            // Detached spawn: the task keeps running even after we return.
878            tokio::spawn(async move {
879                let result = tokio::time::timeout(Self::ADDRESS_ATTEMPT_TIMEOUT, fut).await;
880                let result = match result {
881                    Ok(inner) => inner,
882                    Err(_) => {
883                        warn!(
884                            address = ?addr_info,
885                            "Address attempt timed out after {:?}",
886                            Self::ADDRESS_ATTEMPT_TIMEOUT,
887                        );
888                        Err(SyncError::Network(format!(
889                            "Address attempt timed out after {:?}",
890                            Self::ADDRESS_ATTEMPT_TIMEOUT,
891                        ))
892                        .into())
893                    }
894                };
895                match &result {
896                    Ok(()) => debug!(address = ?addr_info, "Address attempt succeeded"),
897                    Err(e) => debug!(address = ?addr_info, error = %e, "Address attempt failed"),
898                }
899                // Ignore send errors — the receiver is dropped on early success,
900                // but the task still completes its work (peer registration, etc.).
901                let _ = tx.send(result).await;
902            });
903        }
904        // Drop our sender so the channel closes when all tasks finish.
905        drop(tx);
906
907        let mut last_err = None;
908        while let Some(result) = rx.recv().await {
909            match result {
910                Ok(()) => return Ok(()),
911                Err(e) => last_err = Some(e),
912            }
913        }
914
915        Err(last_err.expect("at least one task was spawned"))
916    }
917}