Skip to main content

coven/
handle.rs

1//! The data handle: one object a host constructs once that owns coven's
2//! pieces and exposes the whole data interface as methods.
3//!
4//! coven owns the store's data — SQL rows and blobs, on disk first, cloud
5//! optional. A host (a desktop/mobile app) talks to coven through this one
6//! handle and never assembles coven's internals by hand or hands them back to
7//! coven on every call. The handle delegates to retained owners for rows,
8//! blobs, sync, security, membership, joining, recovery, and Circles; the
9//! caller passes only descriptors (a [`BlobRef`], SQL, or a config).
10//!
11//! The stack runs on Tokio and is `Send + Sync` throughout.
12//!
13//! ## What it owns
14//!
15//! - **Rows** — SQL execution and row-and-blob writes.
16//! - **Blobs** — exact row-bound reads, cache policy, locality transitions, and
17//!   upload visibility.
18//! - **Sync** — connection lifecycle, status, and explicit synchronization.
19//! - **Security** — key custody, device identity, host secrets, and app-data
20//!   sealing.
21//! - **Membership, joining, recovery, and Circles** — their complete host
22//!   workflows, each behind its retained domain owner.
23
24use std::collections::HashMap;
25use std::path::PathBuf;
26use std::sync::Arc;
27
28use crate::device_pairing::StoreDevicePairing;
29use crate::store_blobs::StoreBlobAccess;
30use crate::store_blobs::StoreBlobs;
31use crate::store_circles::StoreCircles;
32use crate::store_cloud_storage::StoreCloudStorage;
33use crate::store_joining::StoreJoining;
34use crate::store_membership::StoreMembership;
35use crate::store_recovery::StoreRecovery;
36use crate::store_rows::StoreRows;
37use crate::store_security::StoreSecurity;
38use crate::store_sync::{ConfigProvider, StoreSync, SyncError};
39use coven_database::store::StoreReads;
40use coven_database::{Database, DbError, StoreDatabase};
41use coven_foundation::clock::ClockRef;
42use coven_foundation::store_dir::StoreDir;
43use coven_foundation::store_dir::StoreOpenGuard;
44use coven_keys::encryption::SealError;
45use coven_keys::keys::{
46    DeviceIdentityCustody, IdentityError, KeyError, MasterKeyCustody, MasterKeyError, StoreKeys,
47};
48use coven_protocol::blob::{BlobRef, BlobTransitionObserver, RowBlobRef};
49use coven_protocol::membership::MemberInfo;
50use coven_protocol::membership::MemberRole;
51use coven_protocol::objects::StorageError;
52use coven_replication::blob::transition::{MakeLocalError, MakeRemoteError};
53use coven_replication::blob::DrainOutcome;
54use coven_replication::sync::store::blob::{LocalStoreBlobAccess, StoreBlobCache};
55use coven_replication::sync::sync_loop::SyncLoopStatus;
56use coven_replication::sync::EagerCacheFillStatus;
57use coven_replication::sync::{BlobCacheError, BlobStream};
58#[cfg(any(test, feature = "test-utils"))]
59use coven_storage::cloud::ExactCloudHome;
60#[cfg(any(test, feature = "test-utils"))]
61use coven_storage::CloudCipher;
62use tokio::sync::watch;
63
64/// Why one blocked operation could not be handed back to the sync loop.
65///
66/// The three kinds keep their own refusal vocabularies — a write's, a Circle
67/// operation's, a reclaim operation's — because a host that shows the operation
68/// shows its error too, and flattening them would lose what it says.
69#[derive(Debug, thiserror::Error)]
70pub enum RetryBlockedOperationError {
71    #[error("retry blocked write: {0}")]
72    Write(#[from] crate::CovenError),
73    #[error("retry circle operation: {0}")]
74    Circle(#[from] crate::CircleError),
75    #[error("retry stuck reclaim operation: {0}")]
76    Reclaim(#[from] crate::SyncError),
77}
78
79/// The cipher a store's app-data sealing runs under, resolved from `custody`.
80///
81/// A store whose custody unlocks `None` has no key to seal under or open with,
82/// which is [`SealError::Locked`] — the same discipline the sync engine's cipher
83/// resolution keeps, where an opaque home with no established key refuses to
84/// start rather than inventing one.
85///
86/// Shared by [`CovenHandle`] and [`CovenReadHandle`](crate::CovenReadHandle) so
87/// both resolve the identical keyring the identical way; a payload one seals, the
88/// other opens.
89/// The handle over one coven store.
90///
91/// Open it once with [`Coven::builder`](crate::Coven::builder), then call methods. Cheap to
92/// [`clone`](Clone) — every field is shared (an `Arc`, a `Clone` handle, or a
93/// reference-counted lock), so a clone drives the same retained owners as the
94/// original.
95///
96/// # Using the handle
97///
98/// The host builds the handle once at startup and then only calls methods on it
99/// — it never assembles coven's internals by hand or hands them back to coven on
100/// every call. Rows go through the connection coven owns; blobs go through the
101/// handle's read/store methods; sync is optional.
102///
103/// ```no_run
104/// # use coven::{CovenHandle, RowBlobRef};
105/// # async fn use_store(handle: &CovenHandle, cover: &RowBlobRef)
106/// #     -> Result<(), Box<dyn std::error::Error>> {
107/// // Rows: run app SQL on the connection coven owns.
108/// let note_count: i64 = handle
109///     .read(|sql| {
110///         sql.query_row("SELECT count(*) FROM notes", [], |row| row.get(0))
111///             .map_err(coven::CovenError::from)
112///     })
113///     .await?;
114///
115/// // Blobs: read an exact row version. coven resolves locality — the user's own
116/// // file, its local store, the cache, or a cloud fetch — and returns plaintext.
117/// let bytes: Vec<u8> = handle.read_blob(cover).await?;
118///
119/// // Sync is optional. Connect a provider, then drive it; a store with no
120/// // cloud home never calls these and stays fully usable on-device.
121/// handle.connect_sync().await?;
122/// handle.sync_now();
123/// # let _ = note_count;
124/// # Ok(())
125/// # }
126/// ```
127#[derive(Clone)]
128pub struct CovenHandle {
129    rows: StoreRows,
130    blobs: StoreBlobs,
131    security: StoreSecurity,
132    sync: StoreSync,
133    membership: StoreMembership,
134    joining: StoreJoining,
135    pairing: StoreDevicePairing,
136    recovery: StoreRecovery,
137    circles: StoreCircles,
138}
139
140impl CovenHandle {
141    /// Build the handle over an already-open [`Database`] and the store's
142    /// directory. Does no I/O and opens no sync connection — a home-less store
143    /// is fully usable (rows + Local blobs). Call
144    /// [`connect_sync`](Self::connect_sync) when a cloud provider is connected.
145    ///
146    /// `config_provider` is read fresh on every call that needs the current
147    /// config (the cloud-home selection, the blob-path scheme), so the host can
148    /// reconnect a provider without rebuilding the handle. `observer` carries the
149    /// host's transition bookkeeping; pass `None` if it surfaces none.
150    #[allow(clippy::too_many_arguments)]
151    pub(crate) fn new(
152        db: Database,
153        read_database: StoreReads,
154        store_dir: StoreDir,
155        config_provider: ConfigProvider,
156        key_service: StoreKeys,
157        key_custody: Arc<dyn MasterKeyCustody>,
158        identity_custody: Arc<dyn DeviceIdentityCustody>,
159        oauth_clients: coven_storage::oauth::OAuthClients,
160        clock: ClockRef,
161        cloudkit_ops: Option<Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>>,
162        observer: Option<Arc<dyn BlobTransitionObserver>>,
163        open_guard: Arc<StoreOpenGuard>,
164        blob_chunking: coven_storage::BlobChunking,
165    ) -> Self {
166        let database = StoreDatabase::from_database(db);
167        let cloud_homes = coven_storage::cloud::CloudHomeFactory::new(oauth_clients);
168        let credentials = coven_keys::keys::CloudHomeCredentialsOwner::new(key_service.clone());
169        let security = StoreSecurity::new(
170            key_service,
171            key_custody.clone(),
172            identity_custody,
173            store_dir.clone(),
174        );
175        let cloud_storage = StoreCloudStorage::new(
176            security.clone(),
177            cloud_homes,
178            credentials,
179            clock.clone(),
180            cloudkit_ops,
181            blob_chunking,
182        );
183        let blob_cache = StoreBlobCache::new(database.clone(), store_dir.clone());
184        let local_blob_access =
185            LocalStoreBlobAccess::new(database.clone(), store_dir.clone(), blob_cache);
186        let blob_access = StoreBlobAccess::new(
187            database.clone(),
188            config_provider.clone(),
189            cloud_storage.clone(),
190            local_blob_access.clone(),
191        );
192        let sync = StoreSync::new(
193            config_provider.clone(),
194            security.clone(),
195            database.clone(),
196            #[cfg(test)]
197            store_dir.clone(),
198            clock.clone(),
199            observer,
200            open_guard,
201            cloud_storage,
202            blob_access.clone(),
203            Arc::new(coven_replication::sync::sync_loop::SystemSyncLoopRuntimeFactory),
204        );
205        let rows = StoreRows::new(
206            coven_database::StoreRowWrites::new(database.clone()),
207            read_database,
208            key_custody,
209            sync.clone(),
210        );
211        let blobs = StoreBlobs::new(database.clone(), blob_access, local_blob_access);
212        let membership = StoreMembership::new(sync.clone());
213        let joining = StoreJoining::new(database.clone(), membership.clone(), sync.clone());
214        let pairing = StoreDevicePairing::new(
215            config_provider,
216            store_dir.device_pairing_journal_path(),
217            clock,
218            joining.clone(),
219            sync.clone(),
220        );
221        let recovery = StoreRecovery::new(database.clone(), security.clone(), sync.clone());
222        let circles = StoreCircles::new(
223            database.clone(),
224            membership.clone(),
225            security.clone(),
226            sync.clone(),
227        );
228        Self {
229            rows,
230            blobs,
231            security,
232            sync,
233            membership,
234            joining,
235            pairing,
236            recovery,
237            circles,
238        }
239    }
240
241    pub async fn write<F, R>(&self, sql: F) -> crate::CovenResult<crate::WriteReceipt<R>>
242    where
243        F: for<'context, 'connection> FnOnce(
244                crate::SqlContext<'context, 'connection>,
245            ) -> crate::CovenResult<R>
246            + Send
247            + 'static,
248        R: Send + 'static,
249    {
250        self.rows.write(sql).await
251    }
252
253    /// Read one consistent snapshot when awaited. Attach `process` to compute
254    /// a result on separate workers after releasing the connection.
255    pub fn read<F, R>(&self, read: F) -> crate::Read<'_, F>
256    where
257        F: for<'connection> FnOnce(crate::SqlReadContext<'connection>) -> crate::CovenResult<R>
258            + Send
259            + 'static,
260        R: Send + 'static,
261    {
262        self.rows.read(read)
263    }
264
265    /// Create a query that returns its initial value and runs again when a
266    /// committed database change can affect it.
267    ///
268    /// The query uses the same [`crate::SqlReadContext`] as [`read`](Self::read).
269    /// Coven records the tables and columns SQLite reads, and narrows supported
270    /// single-table primary-key predicates to their bound values. Other
271    /// predicates retain safe table-and-column invalidation.
272    /// Attach [`process`](crate::LiveQuery::process) to move result processing
273    /// off the read connection. Only the final delivered value needs `Clone`
274    /// and `PartialEq`.
275    pub fn subscribe<F, R>(&self, query: F) -> crate::LiveQuery<R>
276    where
277        F: for<'connection> Fn(crate::SqlReadContext<'connection>) -> crate::CovenResult<R>
278            + Send
279            + Sync
280            + 'static,
281        R: Send + 'static,
282    {
283        self.rows.subscribe(query)
284    }
285
286    /// Create a tracked query whose absolute request can be replaced while the
287    /// subscription remains active.
288    /// Attach [`process`](crate::ReconfigurableLiveQuery::process) to process
289    /// each result together with the request that produced it.
290    pub fn subscribe_reconfigurable<Request, F, R>(
291        &self,
292        initial_request: Request,
293        query: F,
294    ) -> crate::ReconfigurableLiveQuery<Request, R>
295    where
296        Request: Clone + PartialEq + Send + Sync + 'static,
297        F: for<'connection> Fn(
298                &Request,
299                crate::SqlReadContext<'connection>,
300            ) -> crate::CovenResult<R>
301            + Send
302            + Sync
303            + 'static,
304        R: Send + 'static,
305    {
306        self.rows.subscribe_reconfigurable(initial_request, query)
307    }
308
309    pub async fn write_with_blobs<F, S, R>(
310        &self,
311        build: F,
312        sql: S,
313    ) -> crate::CovenResult<crate::WriteReceipt<R>>
314    where
315        F: FnOnce(&mut crate::WriteBatch) -> crate::CovenResult<()> + Send + 'static,
316        S: for<'context, 'connection> FnOnce(
317                crate::SqlContext<'context, 'connection>,
318            ) -> crate::CovenResult<R>
319            + Send
320            + 'static,
321        R: Send + 'static,
322    {
323        self.rows.write_with_blobs(build, sql).await
324    }
325
326    // =========================================================================
327    // Sync lifecycle
328    // =========================================================================
329
330    /// Subscribe to the sync loop's [`SyncLoopStatus`] stream. The channel is
331    /// owned by this handle, not the loop, so the receiver keeps working across a
332    /// reconnect and may be created before any provider is connected (it starts
333    /// receiving once a loop runs). Infallible for that reason — there is no loop
334    /// state to check.
335    ///
336    /// The receiver immediately contains the current value. Intermediate values
337    /// may be coalesced; `Synchronized.row_changes` is a refresh hint rather than a
338    /// complete change stream.
339    pub fn subscribe_sync_status(&self) -> tokio::sync::watch::Receiver<SyncLoopStatus> {
340        self.sync.subscribe_status()
341    }
342
343    /// Subscribe to the post-open CacheEager fill. Enrollment installs rows and
344    /// returns without artwork; the connected library then reports discovery,
345    /// bounded-cadence download progress, completion, cancellation, or failure.
346    pub fn subscribe_eager_cache_fill_status(
347        &self,
348    ) -> tokio::sync::watch::Receiver<EagerCacheFillStatus> {
349        self.sync.subscribe_eager_cache_status()
350    }
351
352    /// Stop post-open CacheEager downloads without stopping cloud sync.
353    pub fn cancel_eager_cache_fill(&self) {
354        self.sync.cancel_eager_cache_fill();
355    }
356
357    /// Writes that have shared rows and have not reached a published position.
358    pub async fn pending_writes(&self) -> Result<Vec<crate::PendingWrite>, crate::CovenError> {
359        self.rows
360            .pending_writes()
361            .await
362            .map_err(crate::CovenError::from)
363    }
364
365    /// Writes stopped by a semantic publication fault and awaiting an explicit
366    /// retry or discard decision.
367    pub async fn blocked_writes(&self) -> Result<Vec<crate::PendingWrite>, crate::CovenError> {
368        self.rows
369            .blocked_writes()
370            .await
371            .map_err(crate::CovenError::from)
372    }
373
374    /// Requeue one blocked write for full production validation. A connected
375    /// sync loop is woken after the durable transition.
376    pub async fn retry_blocked_write(
377        &self,
378        write_id: &crate::WriteId,
379    ) -> Result<Vec<crate::WriteId>, crate::CovenError> {
380        self.rows.retry_blocked_write(write_id).await
381    }
382
383    /// Hand one blocked operation back to the sync loop, whichever kind it is.
384    ///
385    /// The host renders [`SyncLoopStatus::Blocked`]'s operations as one list
386    /// with one button, so it retries them through one call; the id says which
387    /// path the retry takes. Each kind revalidates from scratch, so an
388    /// operation whose cause still stands simply blocks again.
389    pub async fn retry_blocked_operation(
390        &self,
391        operation: crate::BlockedOperationId,
392    ) -> Result<(), crate::RetryBlockedOperationError> {
393        match operation {
394            crate::BlockedOperationId::Write(write_id) => {
395                self.rows.retry_blocked_write(&write_id).await?;
396                Ok(())
397            }
398            crate::BlockedOperationId::CircleOperation(operation_id) => {
399                Ok(self.circles.retry(operation_id).await?)
400            }
401            crate::BlockedOperationId::Reclaim(operation_id) => {
402                Ok(self.sync.retry_stuck_reclaim(operation_id).await?)
403            }
404        }
405    }
406
407    /// Atomically discard a blocked write and reverse every later unpublished
408    /// shared write whose working-row state depends on it.
409    pub async fn discard_blocked_write(
410        &self,
411        write_id: &crate::WriteId,
412    ) -> Result<Vec<crate::WriteId>, crate::CovenError> {
413        self.rows.discard_blocked_write(write_id).await
414    }
415
416    /// Read the current durable status of one write.
417    pub async fn write_status(
418        &self,
419        write_id: &crate::WriteId,
420    ) -> Result<crate::WriteStatus, crate::CovenError> {
421        self.rows
422            .write_status(write_id)
423            .await
424            .map_err(crate::CovenError::from)
425    }
426
427    /// Subscribe to one write's current durable status. The initial value is
428    /// reconstructed from SQLite before the receiver is returned.
429    pub async fn subscribe_write_status(
430        &self,
431        write_id: &crate::WriteId,
432    ) -> Result<tokio::sync::watch::Receiver<crate::WriteStatus>, crate::CovenError> {
433        self.rows
434            .subscribe_write_status(write_id)
435            .await
436            .map_err(crate::CovenError::from)
437    }
438
439    /// Build the connected cloud storage, start its sync loop, and install the
440    /// connection. If the cloud home fails to build, no connection is installed.
441    ///
442    /// The at-rest cipher is resolved from the handle's custody per start: an
443    /// opaque home unlocks the master keyring (failing with
444    /// [`SyncError::MasterKeyNotEstablished`] if none is established), a
445    /// browsable one never consults custody. Reconnecting a provider replaces
446    /// the cloud home and loop while retaining the Store database and clock.
447    pub async fn connect_sync(&self) -> Result<(), SyncError> {
448        self.sync.connect().await
449    }
450
451    /// Build and probe the cloud home described by `config` without installing
452    /// it as this handle's sync connection. Hosts use this to validate proposed
453    /// provider settings before committing them to their config source.
454    pub async fn probe_cloud_home(&self, config: &crate::Config) -> Result<(), SyncError> {
455        self.sync.probe_cloud_home(config).await
456    }
457
458    /// Connect a new S3 cloud home and commit its credentials and any generated
459    /// opaque-home master key only after the replacement connection is ready.
460    pub async fn setup_s3_cloud_home(
461        &self,
462        cloud_home: crate::CloudHomeConfig,
463        access_key: String,
464        secret_key: String,
465    ) -> Result<crate::ConnectedCloudHome, crate::CloudHomeSetupError> {
466        self.sync.setup_s3(cloud_home, access_key, secret_key).await
467    }
468
469    /// Connect a new CloudKit cloud home and commit any generated opaque-home
470    /// master key only after the replacement connection is ready.
471    pub async fn setup_cloudkit_cloud_home(
472        &self,
473        cloud_home: crate::CloudHomeConfig,
474        cloudkit_ops: Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>,
475    ) -> Result<crate::ConnectedCloudHome, crate::CloudHomeSetupError> {
476        self.sync.setup_cloudkit(cloud_home, cloudkit_ops).await
477    }
478
479    /// Authorize and connect a new Google Drive, Dropbox, or OneDrive home.
480    /// Tokens remain proposed until the replacement connection is ready.
481    #[cfg(feature = "oauth-providers")]
482    pub async fn setup_oauth_cloud_home(
483        &self,
484        cloud_home: crate::CloudHomeConfig,
485        cancel: tokio::sync::watch::Receiver<bool>,
486    ) -> Result<crate::ConnectedCloudHome, crate::CloudHomeSetupError> {
487        self.sync.setup_oauth(cloud_home, cancel).await
488    }
489
490    /// Whether a home with this storage policy needs and can unlock its key.
491    pub fn cloud_home_key_state(
492        &self,
493        storage: crate::HomeStorage,
494    ) -> Result<crate::CloudHomeKeyState, KeyError> {
495        self.security.cloud_home_key_state(storage)
496    }
497
498    /// Import the master key for this returning opaque cloud home, verify it
499    /// against the signed Store root, and connect without retaining a rejected key.
500    pub async fn unlock_cloud_home(
501        &self,
502        serialized_master_key: &str,
503    ) -> Result<crate::ConnectedCloudHome, crate::CloudHomeUnlockError> {
504        self.sync.unlock(serialized_master_key).await
505    }
506
507    pub async fn connect_sync_with_cloudkit(
508        &self,
509        cloudkit_ops: Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>,
510    ) -> Result<(), SyncError> {
511        self.sync.connect_with_cloudkit(cloudkit_ops).await
512    }
513
514    /// Test-only: connect a started sync loop over an injected [`ExactCloudHome`]
515    /// instead of one built from [`crate::Config`], so a host's integration tests drive
516    /// the real make-Remote / make-Local / upload-drain and read paths over a mock
517    /// cloud with no live provider.
518    ///
519    /// The test counterpart of [`connect_sync`](Self::connect_sync): it builds
520    /// storage over `home`/`cipher`, prepares the configured home's master key,
521    /// starts the loop, and commits a newly generated key and the connection
522    /// together only after startup succeeds. The explicit cipher protects the
523    /// injected storage; the master key separately protects Store routing data.
524    ///
525    /// The read path needs no separate hook: `blob_storage`
526    /// serves reads from the connected loop's own `CloudSyncConnection`, which here
527    /// wraps the injected `home`, so [`read_blob`](Self::read_blob) /
528    /// [`pin`](Self::pin) resolve a Remote miss against the same test home the
529    /// drain writes to.
530    #[cfg(any(test, feature = "test-utils"))]
531    pub fn connect_sync_with_test_home(
532        &self,
533        home: Arc<dyn ExactCloudHome>,
534        cipher: CloudCipher,
535    ) -> impl std::future::Future<Output = Result<(), crate::CloudHomeSetupError>> + Send + '_ {
536        Box::pin(async move { self.sync.connect_with_test_home(home, cipher).await })
537    }
538
539    /// Test-only: atomically set up a proposed cloud home over an injected
540    /// provider while exercising the production key and connection transaction.
541    #[cfg(any(test, feature = "test-utils"))]
542    pub async fn setup_cloud_home_with_test_home(
543        &self,
544        cloud_home: crate::CloudHomeConfig,
545        home: Arc<dyn ExactCloudHome>,
546        credentials: Option<crate::CloudHomeCredentials>,
547    ) -> Result<crate::ConnectedCloudHome, crate::CloudHomeSetupError> {
548        self.sync
549            .setup_with_test_home(cloud_home, home, credentials)
550            .await
551    }
552
553    /// Test-only: unlock a returning opaque home over an injected provider
554    /// while exercising the production key-and-connection transaction.
555    #[cfg(any(test, feature = "test-utils"))]
556    pub async fn unlock_cloud_home_with_test_home(
557        &self,
558        serialized_master_key: &str,
559        home: Arc<dyn ExactCloudHome>,
560    ) -> Result<crate::ConnectedCloudHome, crate::CloudHomeUnlockError> {
561        self.sync
562            .unlock_with_test_home(serialized_master_key, home)
563            .await
564    }
565
566    /// Test-only: connect over an injected [`ExactCloudHome`] exactly as
567    /// [`connect_sync_with_test_home`](Self::connect_sync_with_test_home) does,
568    /// but start no background loop — the caller drives sync itself.
569    ///
570    /// The loop-started connect and an explicit
571    /// [`drain_uploads`](Self::drain_uploads) are two drainers of one queue. They
572    /// take turns rather than overlap, so whichever runs second drains only what
573    /// the first left — a host asserting on its own drain's count reads that as
574    /// "nothing was queued" and fails intermittently. Here no cycle exists to
575    /// share the queue with: the host's `drain_uploads` is the only drain, its
576    /// count is the whole truth, and [`is_syncing`](Self::is_syncing) stays
577    /// `false` for the connection's whole life.
578    ///
579    /// Everything a connected store can do is available — `make_remote`,
580    /// `make_local`, the drain, membership — because none of it needs the loop
581    /// thread. Circle *writes* are the exception: they are dispatched to that
582    /// thread, so they refuse with
583    /// [`CircleError::LoopNotRunning`](crate::CircleError::LoopNotRunning) here.
584    #[cfg(any(test, feature = "test-utils"))]
585    pub fn connect_sync_with_test_home_caller_driven(
586        &self,
587        home: Arc<dyn ExactCloudHome>,
588        cipher: CloudCipher,
589    ) -> impl std::future::Future<Output = Result<(), crate::CloudHomeSetupError>> + Send + '_ {
590        Box::pin(async move {
591            self.sync
592                .connect_with_test_home_caller_driven(home, cipher)
593                .await
594        })
595    }
596
597    /// Test-only: connect over an injected [`ExactCloudHome`] while resolving the
598    /// at-rest cipher from custody the way production
599    /// [`connect_sync`](Self::connect_sync) does, instead of taking an explicit
600    /// cipher like [`connect_sync_with_test_home`](Self::connect_sync_with_test_home).
601    ///
602    /// Where that method prepares a missing master key as part of its connection
603    /// transaction, this requires an existing key and drives the connection path
604    /// used by production, which unlocks the master keyring through the store's
605    /// custody exactly as `start_sync` would — so a
606    /// test can establish a key, connect over a mock home, and prove the traffic
607    /// is sealed under that key. An opaque home with no key established fails
608    /// [`SyncError::MasterKeyNotEstablished`] before the loop starts.
609    #[cfg(any(test, feature = "test-utils"))]
610    pub async fn connect_sync_with_test_home_custody(
611        &self,
612        home: Arc<dyn ExactCloudHome>,
613    ) -> Result<(), SyncError> {
614        self.sync.connect_with_test_home_custody(home).await
615    }
616
617    /// Start (or restart) the sync loop of the installed connection. A no-op
618    /// when no provider is connected — a home-less store has nothing to start.
619    /// Errors if the connected cloud home fails to build.
620    pub async fn start_sync(&self) -> Result<(), SyncError> {
621        self.sync.start().await
622    }
623
624    /// Stop the sync loop after the in-flight cycle while keeping the provider
625    /// connected so [`start_sync`](Self::start_sync) can resume it. A no-op when
626    /// no provider is connected.
627    ///
628    /// The material a running loop resolved from custody (the master keyring,
629    /// the device signing identity) is cached only inside that loop for as
630    /// long as it runs — nowhere else in the handle — and this is where it is
631    /// purged. A subsequent [`start_sync`](Self::start_sync)/
632    /// [`connect_sync`](Self::connect_sync) re-resolves fresh from whatever
633    /// custody now serves, so a host's lock flow that stops sync as part of
634    /// locking, then later reconnects, never resumes on stale material.
635    pub fn stop_sync(&self) {
636        self.sync.stop()
637    }
638
639    /// Disconnect the provider entirely: stop the loop and drop the connection.
640    /// The store becomes home-less until the next
641    /// [`connect_sync`](Self::connect_sync).
642    ///
643    /// Carries the same purge as [`stop_sync`](Self::stop_sync), so nothing about
644    /// the previous connection — including which custody it resolved material
645    /// from — survives into the next connect.
646    pub fn disconnect_sync(&self) {
647        self.sync.disconnect()
648    }
649
650    /// Disconnect the configured cloud home and remove its provider credentials.
651    /// If credential removal fails, the installed connection is preserved.
652    pub async fn disconnect_cloud_home(&self) -> Result<(), SyncError> {
653        self.sync.disconnect_cloud_home().await
654    }
655
656    /// Wake the sync loop to run a cycle now rather than at the next idle tick. A
657    /// no-op when no provider is connected.
658    pub fn sync_now(&self) {
659        self.sync.trigger()
660    }
661
662    /// Whether the sync loop is running. `false` for a home-less store.
663    pub fn is_syncing(&self) -> bool {
664        self.sync.is_syncing()
665    }
666
667    /// Whether a provider connection is installed. Distinct
668    /// from [`is_syncing`](Self::is_syncing), which additionally requires the loop
669    /// to be running: this is the predicate a host uses for "has a cloud home"
670    /// without the loop-ready condition.
671    pub fn is_connected(&self) -> bool {
672        self.sync.is_connected()
673    }
674
675    // =========================================================================
676    // Master-key lifecycle
677    // =========================================================================
678
679    /// Import a serialized master keyring a host already holds and establish it
680    /// under the handle's custody, replacing whatever custody already holds.
681    pub async fn import_master_key(&self, serialized: &str) -> Result<(), MasterKeyError> {
682        self.sync.import_master_key(serialized).await
683    }
684
685    /// Remove the master key from custody and disconnect any operation retaining
686    /// its unlocked value. If custody cannot remove the key, the connection is
687    /// preserved and the error is returned.
688    pub async fn forget_master_key(&self) -> Result<(), SyncError> {
689        self.sync.forget_master_key().await
690    }
691
692    // =========================================================================
693    // Identity lifecycle
694    // =========================================================================
695
696    /// Generate this store's signing identity and establish it under the
697    /// handle's identity custody. Errors with
698    /// [`IdentityError::AlreadyEstablished`] if custody already unlocks one —
699    /// coven never generates over an existing identity. This is the identity
700    /// counterpart of cloud-home setup's master-key transaction for a store a
701    /// host is creating fresh (not joining or restoring, which each establish
702    /// their own identity as part of what they do). Returns the established
703    /// public key, hex-encoded.
704    pub fn initialize_identity(&self) -> Result<String, IdentityError> {
705        self.security.initialize_identity()
706    }
707
708    // =========================================================================
709    // Host secrets
710    // =========================================================================
711
712    /// Set a host's own store-scoped secret — an API token, a service
713    /// credential — under the same platform keyring, and the same access
714    /// policy, as coven's own key material. `name` identifies the secret
715    /// within the store; coven owns the account rendering and the entry's
716    /// protection class. [`KeyError::InvalidSecretName`] if `name` collides
717    /// with one of coven's own reserved slot names, is empty, or contains
718    /// `:`.
719    /// The concurrent blob-transfer limits in force: how many uploads an
720    /// upload-drain pass runs at once and how many downloads a pin fetches at
721    /// once.
722    pub fn transfer_limits(&self) -> coven_protocol::blob::TransferLimits {
723        self.blobs.transfer_limits()
724    }
725
726    /// Replace the transfer limits while the store is open. Every later
727    /// upload-drain pass and pin call runs under the new limits; a pass
728    /// already running keeps the limit it admitted under. The builder's
729    /// `max_concurrent_uploads` / `max_concurrent_downloads` set the initial
730    /// values.
731    pub fn set_transfer_limits(&self, limits: coven_protocol::blob::TransferLimits) {
732        self.blobs.set_transfer_limits(limits)
733    }
734
735    pub fn set_host_secret(&self, name: &str, value: &str) -> Result<(), KeyError> {
736        self.security.set_host_secret(name, value)
737    }
738
739    /// Read a host secret set by [`set_host_secret`](Self::set_host_secret),
740    /// `None` if never set. A present-but-empty entry is corrupt, not
741    /// absent — the same discipline coven's own key reads apply.
742    pub fn host_secret(&self, name: &str) -> Result<Option<String>, KeyError> {
743        self.security.host_secret(name)
744    }
745
746    /// Remove a host secret. `Ok` whether or not one was set.
747    pub fn delete_host_secret(&self, name: &str) -> Result<(), KeyError> {
748        self.security.delete_host_secret(name)
749    }
750
751    // =========================================================================
752    // App-data sealing
753    // =========================================================================
754
755    /// Seal `plaintext` under the store's current master-key generation, for a
756    /// host to store in its own rows — a password entry's payload, an API token.
757    /// coven's at-rest encryption is cloud-side; the local database is plaintext
758    /// SQLite, so a host with a secret to keep in a row seals it here first.
759    ///
760    /// The output records the generation it was sealed under, so it stays
761    /// openable after any number of key rotations. `aad` binds the ciphertext to
762    /// its context — the owning row's primary key, say — and
763    /// [`open_app_data`](Self::open_app_data) with a different `aad` fails, so a
764    /// payload moved to another row does not silently open there.
765    ///
766    /// [`SealError::Locked`] if the store has no established master key, the same
767    /// gate [`connect_sync`](Self::connect_sync) applies before it seals cloud
768    /// traffic.
769    pub fn seal_app_data(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, SealError> {
770        self.security.seal_app_data(plaintext, aad)
771    }
772
773    /// Open a payload [`seal_app_data`](Self::seal_app_data) produced, under
774    /// whichever generation it names — a rotated keyring still opens everything
775    /// it sealed before rotating.
776    ///
777    /// [`SealError::Locked`] if the store is locked; a wrong `aad`, a tampered
778    /// payload, an unreadable version, or a generation this store's keyring lacks
779    /// each surface their own typed error.
780    pub fn open_app_data(&self, sealed: &[u8], aad: &[u8]) -> Result<Vec<u8>, SealError> {
781        self.security.open_app_data(sealed, aad)
782    }
783
784    // =========================================================================
785    // Blobs
786    // =========================================================================
787
788    /// Capture the exact current blob-bearing row version. Blob operations use
789    /// this row-bound value so a later row replacement cannot redirect a read.
790    pub async fn row_blob_ref(&self, table: &str, row_id: &str) -> Result<RowBlobRef, DbError> {
791        self.blobs.row_blob_ref(table, row_id).await
792    }
793
794    /// Read a blob's whole plaintext through coven's locality-aware read: served
795    /// from the user's file (Local user-provided), coven's local store (Local
796    /// host-provided), the pinned/evictable cache on a Remote hit, or fetched
797    /// from the cloud (into the cache) on a Remote miss. The host passes the
798    /// [`RowBlobRef`] captured from [`row_blob_ref`](Self::row_blob_ref); coven
799    /// holds the database, directory, and storage.
800    pub async fn read_blob(&self, blob: &RowBlobRef) -> Result<Vec<u8>, BlobCacheError> {
801        self.blobs.read(blob).await
802    }
803
804    /// Ensure the exact current row blob plaintext is durable on this device.
805    /// Remote blobs materialize into their locator-keyed cache path; Local and
806    /// pending-remote blobs exact-verify their authoritative local source.
807    pub async fn materialize_row_blob(&self, blob: &RowBlobRef) -> Result<(), BlobCacheError> {
808        self.blobs.materialize(blob).await
809    }
810
811    /// Open an exact row blob's plaintext for ranged reading, for streaming or
812    /// seeking without loading the whole file. The ranged sibling of
813    /// [`read_blob`](Self::read_blob), which stays the one-shot whole read.
814    ///
815    /// Opening resolves the blob's locality, proves the plaintext's size and
816    /// content hash against the row, and holds the open file; every
817    /// [`BlobStream::read_at`] then costs only the bytes it returns. Hold the
818    /// stream for as long as the host is reading that blob — a stream per opened
819    /// file, not per range — since re-opening re-proves the whole blob.
820    pub async fn open_blob_stream(&self, blob: &RowBlobRef) -> Result<BlobStream, BlobCacheError> {
821        self.blobs.open_stream(blob).await
822    }
823
824    /// Pin a Remote blob set for offline: coven fetches each into the protected
825    /// cache (`storage/pinned/`) — from the evictable cache if already there, else
826    /// the cloud — exempt from the size budget. Idempotent.
827    pub async fn pin(&self, blobs: &[RowBlobRef]) -> Result<(), BlobCacheError> {
828        self.blobs.pin(blobs).await
829    }
830
831    /// Unpin a Remote blob set: coven moves each from `storage/pinned/` to the
832    /// evictable `storage/cache/` (still readable, now droppable). No cloud read.
833    pub async fn unpin(&self, blobs: &[RowBlobRef]) -> Result<(), BlobCacheError> {
834        self.blobs.unpin(blobs).await
835    }
836
837    /// The cloud object key a blob's bytes live at, derived under the connected
838    /// home's path scheme (`Hashed` → `{namespace}/{ab}/{cd}/{id}`, `Plain` →
839    /// `{namespace}/{cloud_path}`).
840    ///
841    /// Read-only: coven owns this derivation and every operation that needs a key
842    /// derives its own (a delete resolves it from the stored ref), so nothing a
843    /// host calls takes one back. It exists so a host can *observe* the key coven
844    /// would use — asserting an upload landed where a read looks for it, or
845    /// naming an object in a diagnostic — without reimplementing the layout and
846    /// drifting from it.
847    ///
848    /// A `Plain` home whose `cloud_path` is absent, or does not name the blob it
849    /// carries, is a surfaced error — see `CloudSyncConnection::blob_key`.
850    pub fn blob_cloud_key(&self, blob: &BlobRef) -> Result<String, StorageError> {
851        self.sync.blob_cloud_key(blob)
852    }
853
854    /// Whether every blob in `blobs` is pinned for offline — present in coven's
855    /// kept cache folder (`storage/pinned/`). The host answers "is this release
856    /// kept offline" through this instead of stat-ing coven's cache layout itself.
857    /// An empty set is vacuously pinned. A blob not pinned (in the evictable cache
858    /// or absent) makes the whole set unpinned; an existence-check failure is
859    /// surfaced, never read as "not pinned".
860    pub async fn is_pinned(&self, blobs: &[RowBlobRef]) -> Result<bool, BlobCacheError> {
861        self.blobs.all_pinned(blobs).await
862    }
863
864    /// Whether each of `table`'s `row_ids` is pinned for offline, one answer per
865    /// id in the order given. `None` where an id names no live blob-bearing row.
866    ///
867    /// [`is_pinned`](Self::is_pinned) answers over blobs that together make up
868    /// one thing — every blob of a release, pinned and unpinned together. This
869    /// answers for many independent rows at once: a host drawing a "kept
870    /// offline" marker per row of a page resolves and answers the whole page in
871    /// one call, instead of a [`row_blob_ref`](Self::row_blob_ref) and an
872    /// `is_pinned` per row.
873    ///
874    /// A row whose blob has no committed cloud object — one still Local, or one
875    /// whose upload has not landed — has no kept copy to hold and reads as not
876    /// pinned. An existence-check failure is still surfaced, never read as "not
877    /// pinned".
878    pub async fn rows_pinned(
879        &self,
880        table: &str,
881        row_ids: Vec<String>,
882    ) -> Result<Vec<Option<bool>>, BlobCacheError> {
883        self.blobs.rows_pinned(table, row_ids).await
884    }
885
886    /// Remove one Remote blob's re-fetchable on-device cache copies from both
887    /// `storage/pinned/` and `storage/cache/`. This never touches the local store,
888    /// whose bytes may be the only usable copy owned by an unpublished write.
889    /// It does not delete the cloud blob or its carrying row; a later read can
890    /// fetch the bytes again.
891    pub async fn evict_blob(&self, blob: &RowBlobRef) -> Result<(), BlobCacheError> {
892        self.blobs.evict(blob).await
893    }
894
895    /// Make `(root_table, root_id)` Remote (Local → Remote): enqueue an upload per
896    /// user-provided blob from its external file and record the make_remote
897    /// intent, then return. The drain uploads each and flips the gate true on the
898    /// last; the gate flip re-emits the subtree and the cycle's inline push
899    /// uploads host-provided blobs. `pin` keeps the uploaded blobs in the cache as
900    /// pinned offline copies. Errors with [`MakeRemoteError::SyncNotReady`] when no
901    /// provider is connected.
902    ///
903    /// `refs` is the root's complete current blob set in the order the host wants
904    /// uploads admitted. coven validates the set atomically before enqueueing it.
905    /// `root_label` is what the host calls this root, snapshotted onto the queue
906    /// rows and the intent. The queue outlives the root row on purpose — a
907    /// cancelled or deleted root still has cloud objects to unwind — so an entry
908    /// that had to read the row to name itself could not be rendered at exactly
909    /// the moment a person most needs to see it.
910    pub async fn make_remote(
911        &self,
912        root_table: &str,
913        root_id: &str,
914        root_label: &str,
915        pin: bool,
916        refs: Vec<coven_protocol::blob::RowBlobRef>,
917    ) -> Result<(), MakeRemoteError> {
918        self.sync
919            .make_remote(root_table, root_id, root_label, pin, refs)
920            .await
921    }
922
923    pub async fn make_remote_batch(
924        &self,
925        root_table: &str,
926        roots: Vec<crate::MakeRemoteRoot>,
927        pin: bool,
928    ) -> Result<(), MakeRemoteError> {
929        self.sync.make_remote_batch(root_table, roots, pin).await
930    }
931
932    #[cfg(test)]
933    pub(crate) async fn make_remote_with_discovered_order_for_test(
934        &self,
935        root_table: &str,
936        root_id: &str,
937        root_label: &str,
938        pin: bool,
939    ) -> Result<(), MakeRemoteError> {
940        let refs = self
941            .blobs
942            .row_blob_refs_for_root(root_table, root_id)
943            .await?;
944        self.make_remote(root_table, root_id, root_label, pin, refs)
945            .await
946    }
947
948    #[cfg(test)]
949    pub(crate) async fn make_remote_batch_with_discovered_order_for_test(
950        &self,
951        root_table: &str,
952        roots: Vec<(String, String)>,
953        pin: bool,
954    ) -> Result<(), MakeRemoteError> {
955        let mut prepared = Vec::with_capacity(roots.len());
956        for (id, label) in roots {
957            let refs = self.blobs.row_blob_refs_for_root(root_table, &id).await?;
958            prepared.push(crate::MakeRemoteRoot { id, label, refs });
959        }
960        self.make_remote_batch(root_table, prepared, pin).await
961    }
962
963    /// Cancel an in-flight make_remote of `(root_table, root_id)`: clear its intent
964    /// and pending uploads and tombstone any blob already in the cloud. The gate
965    /// never flips, so the root stays Local. Errors with
966    /// [`MakeRemoteError::SyncNotReady`] when no provider is connected.
967    pub async fn cancel_make_remote(
968        &self,
969        root_table: &str,
970        root_id: &str,
971    ) -> Result<(), MakeRemoteError> {
972        self.sync.cancel_make_remote(root_table, root_id).await
973    }
974
975    /// Make `(root_table, root_id)` Local (Remote → Local): bring each blob back to
976    /// a local file durability-first — a user-provided blob to the path named in
977    /// `dest` (blob id → destination path), a host-provided blob to coven's local
978    /// store (no dest) — then flip the gate false, register the external refs, and
979    /// enqueue the cloud deletes in one atomic commit. `cancel` aborts before the
980    /// commit (the root stays Remote). Errors with [`MakeLocalError::SyncNotReady`]
981    /// when no provider is connected.
982    pub async fn make_local(
983        &self,
984        root_table: &str,
985        root_id: &str,
986        dest: &HashMap<String, PathBuf>,
987        cancel: &watch::Receiver<bool>,
988    ) -> Result<(), MakeLocalError> {
989        self.sync
990            .make_local(root_table, root_id, dest, cancel)
991            .await
992    }
993
994    /// Every upload the durable queue is holding, oldest first.
995    ///
996    /// An upload appears here the moment [`make_remote`](Self::make_remote)
997    /// enqueues it — before any transfer is attempted, and whether or not sync
998    /// is connected — and stays until its publication activates or its
999    /// cancellation clears it. The queue is a table in the store database, so
1000    /// this survives restarts: a host can render "waiting to upload" without
1001    /// having observed the transfer that will do it.
1002    ///
1003    /// This is a read; nothing here starts or advances a transfer. Compare
1004    /// [`drain_uploads`](Self::drain_uploads), which does the work.
1005    ///
1006    /// To ask whether a *root* still has a transition running, prefer
1007    /// [`make_remote_progress`](Self::make_remote_progress): the queue empties
1008    /// before the transition ends.
1009    pub async fn queued_uploads(&self) -> Result<Vec<crate::QueuedUpload>, crate::DbError> {
1010        self.blobs.queued_uploads().await
1011    }
1012
1013    /// Subscribe to the durable upload queue and make-remote intents as one
1014    /// committed snapshot. The first [`crate::CloudOutboxLiveQuery::next`] returns
1015    /// immediately; later calls wake from the same committed-change stream as
1016    /// row live queries.
1017    pub fn subscribe_cloud_outbox(&self) -> crate::CloudOutboxLiveQuery {
1018        self.blobs.subscribe_cloud_outbox()
1019    }
1020
1021    /// Read the same committed durable state the cloud-outbox subscription
1022    /// emits, without waiting for a change.
1023    pub async fn cloud_outbox_snapshot(
1024        &self,
1025    ) -> Result<crate::CloudOutboxSnapshot, crate::DbError> {
1026        self.blobs.cloud_outbox_snapshot().await
1027    }
1028
1029    /// The queued uploads belonging to one gated root.
1030    ///
1031    /// The filter runs in SQL, so asking about one root does not decode every
1032    /// other queued upload in the store. A host answers "is anything still
1033    /// waiting to upload for this row?" from whether this is empty — but see
1034    /// [`make_remote_progress`](Self::make_remote_progress) for whether the
1035    /// transition itself has finished, which outlasts its uploads.
1036    pub async fn queued_uploads_for_root(
1037        &self,
1038        root_table: &str,
1039        root_id: &str,
1040    ) -> Result<Vec<crate::QueuedUpload>, crate::DbError> {
1041        self.blobs
1042            .queued_uploads_for_root(root_table, root_id)
1043            .await
1044    }
1045
1046    /// Where the user's own file for a row's blob lives on disk, or `None`
1047    /// when the row has no external registration.
1048    ///
1049    /// This is the read that mirrors
1050    /// [`SqlContext::register_external_blob`](crate::SqlContext::register_external_blob):
1051    /// a host that needs the original file itself — to re-read its tags, to
1052    /// find an artifact it produced — asks here rather than reading coven's
1053    /// copy, because for a user-provided blob there is no copy.
1054    ///
1055    /// `None` means no registration, which is an ordinary answer: a row whose
1056    /// blobs coven copies, or one whose registration was cleared, has no user
1057    /// file to name. A registration that disagrees with the row it belongs to
1058    /// is an error, not a `None`.
1059    pub async fn external_blob(
1060        &self,
1061        table: &str,
1062        row_id: &str,
1063    ) -> Result<Option<crate::ExternalBlob>, crate::DbError> {
1064        self.blobs.external_blob(table, row_id).await
1065    }
1066
1067    /// Every cloud tombstone the durable queue is holding, oldest first.
1068    ///
1069    /// A tombstone is queued by
1070    /// [`SqlContext::enqueue_blob_delete`](crate::SqlContext::enqueue_blob_delete)
1071    /// and stays until a sync cycle carries the removal out, so this reports
1072    /// removals still owed to the cloud across restarts.
1073    pub async fn queued_deletes(&self) -> Result<Vec<crate::QueuedDelete>, crate::DbError> {
1074        self.blobs.queued_deletes().await
1075    }
1076
1077    /// How far the make-remote for one gated root has got, or `None` when that
1078    /// root has none running.
1079    ///
1080    /// This outlasts the root's queued uploads. Once the last upload lands its
1081    /// queue rows are consumed, but the transition is not finished until the
1082    /// Store write publishing it activates — so a root can have no queued
1083    /// uploads and still be mid-transition, reported here as
1084    /// [`MakeRemoteProgress::Publishing`](crate::MakeRemoteProgress).
1085    pub async fn make_remote_progress(
1086        &self,
1087        root_table: &str,
1088        root_id: &str,
1089    ) -> Result<Option<crate::MakeRemoteProgress>, crate::DbError> {
1090        self.blobs.make_remote_progress(root_table, root_id).await
1091    }
1092
1093    /// Drain pending blob uploads now: read each local file, seal it under its
1094    /// scope, write it to the cloud, and keep a `retain_pinned` entry's plaintext
1095    /// in the protected cache.
1096    ///
1097    /// The sync loop drains each cycle; this drives a drain directly off the
1098    /// connected home, against coven's own register clock and the handle's
1099    /// observer. Errors when no provider is connected (there is no cloud to write
1100    /// to).
1101    ///
1102    /// The [`DrainOutcome`] says what the pass found, not just how much it moved:
1103    /// an empty queue, a queue held entirely in retry backoff, and a paused one
1104    /// are each their own answer rather than a zero count.
1105    ///
1106    /// A host that connects with a running loop shares the queue with the
1107    /// cycle's drain. The two never run at once — the queue is drained under an
1108    /// exclusive turn, so one entry is never in two uploads — but they do divide
1109    /// the work: this call may wait for a cycle's drain and then find the
1110    /// entries it wanted already uploaded, and answer `QueueEmpty`. The outcome
1111    /// describes this pass, never the queue's whole history, so a host that
1112    /// needs the latter should watch
1113    /// [`subscribe_cloud_outbox`](Self::subscribe_cloud_outbox) rather than
1114    /// count one drain's return. `connect_sync_with_test_home_caller_driven`
1115    /// (test builds only) connects without a loop, so this call is the only
1116    /// drain and its count is the whole truth.
1117    pub async fn drain_uploads(&self) -> Result<DrainOutcome, SyncError> {
1118        self.sync.drain_uploads().await
1119    }
1120
1121    /// Retry every failed upload now, without waiting for its automatic retry
1122    /// delay. This clears the durable delay only after confirming that a cloud
1123    /// connection can run the drain, then attempts the queue immediately.
1124    ///
1125    /// Provider failures remain in the returned [`DrainOutcome`], with their
1126    /// updated attempt records available through
1127    /// [`subscribe_cloud_outbox`](Self::subscribe_cloud_outbox). Connection or
1128    /// database failures are returned as [`SyncError`].
1129    pub async fn retry_uploads_now(&self) -> Result<DrainOutcome, SyncError> {
1130        self.sync.retry_uploads_now().await
1131    }
1132
1133    pub async fn get_cache_budget(&self, namespace: &str) -> Result<Option<u64>, crate::DbError> {
1134        self.blobs.cache_budget(namespace).await
1135    }
1136
1137    pub async fn set_cache_budget(
1138        &self,
1139        namespace: &str,
1140        max_bytes: u64,
1141    ) -> Result<(), crate::DbError> {
1142        self.blobs.set_cache_budget(namespace, max_bytes).await
1143    }
1144
1145    /// Generate a restore code, seeded with the store's current membership-head
1146    /// floor read from the cloud. Requires a connected provider because minting
1147    /// a trustworthy floor is a network read, not a pure function of local
1148    /// config and keyring state — a restore code minted without one would carry
1149    /// no protection against a storage provider replaying an older, otherwise
1150    /// validly signed membership state to the device that redeems it.
1151    pub async fn generate_restore_code(&self) -> Result<String, SyncError> {
1152        self.recovery.generate_restore_code().await
1153    }
1154
1155    pub async fn get_members(&self) -> Result<Vec<MemberInfo>, SyncError> {
1156        self.membership.members().await
1157    }
1158
1159    pub async fn membership_conflict(
1160        &self,
1161    ) -> Result<Option<crate::MembershipConflictInfo>, SyncError> {
1162        self.membership.conflict().await
1163    }
1164
1165    pub async fn start_device_pairing(
1166        &self,
1167    ) -> Result<crate::DevicePairingHost, crate::StartDevicePairingError> {
1168        self.pairing.start().await
1169    }
1170
1171    pub async fn approve_device_pairing(
1172        &self,
1173        host: &crate::DevicePairingHost,
1174        request: &crate::DevicePairingRequest,
1175        role: MemberRole,
1176        policy: crate::DeviceJoinApprovalPolicy<'_>,
1177        access_administrator: Option<&dyn crate::DeviceProviderAccessAdministrator>,
1178        on_progress: &(dyn Fn(crate::AdmittingDeviceJoinProgress) + Send + Sync),
1179        cancel: tokio::sync::watch::Receiver<bool>,
1180    ) -> Result<crate::DeviceJoinDriveOutcome, crate::ApproveDevicePairingError> {
1181        self.pairing
1182            .approve(
1183                host,
1184                request,
1185                role,
1186                policy,
1187                access_administrator,
1188                on_progress,
1189                cancel,
1190            )
1191            .await
1192    }
1193
1194    pub async fn cancel_device_pairing(
1195        &self,
1196        host: &crate::DevicePairingHost,
1197    ) -> Result<(), crate::ApproveDevicePairingError> {
1198        self.pairing.cancel(host).await
1199    }
1200
1201    pub async fn begin_device_join(
1202        &self,
1203        member_pubkey: &str,
1204    ) -> Result<crate::DeviceJoinOffer, SyncError> {
1205        self.sync.begin_device_join(member_pubkey).await
1206    }
1207
1208    pub async fn abandon_device_join(
1209        &self,
1210        offer: crate::DeviceJoinOffer,
1211    ) -> Result<crate::DeviceJoinAbandonment, SyncError> {
1212        self.sync.abandon_device_join(offer).await
1213    }
1214
1215    pub async fn authorize_device_provider_access(
1216        &self,
1217        request: crate::DeviceProviderAccessRequest,
1218        access_administrator: Option<&dyn crate::DeviceProviderAccessAdministrator>,
1219    ) -> Result<crate::DeviceProviderAdmissionApproval, SyncError> {
1220        self.sync
1221            .authorize_device_provider_access(request, access_administrator)
1222            .await
1223    }
1224
1225    pub async fn accept_device_registration_request(
1226        &self,
1227        request: crate::DeviceRegistrationRequest,
1228    ) -> Result<crate::ProvisionalDeviceBootstrap, SyncError> {
1229        self.sync.accept_device_registration(request).await
1230    }
1231
1232    pub async fn publish_device_provider_challenge(
1233        &self,
1234        bootstrap: crate::ProvisionalDeviceBootstrap,
1235    ) -> Result<crate::ProviderReadyDeviceBootstrap, SyncError> {
1236        self.sync.publish_device_provider_challenge(bootstrap).await
1237    }
1238
1239    pub async fn complete_device_provider_admission(
1240        &self,
1241        readiness: crate::DeviceJoinReadiness,
1242    ) -> Result<crate::DeviceProviderAdmissionCompletion, SyncError> {
1243        self.sync
1244            .complete_device_provider_admission(readiness)
1245            .await
1246    }
1247
1248    pub async fn finalize_device_join(
1249        &self,
1250        completion: crate::DeviceProviderAdmissionCompletion,
1251    ) -> Result<crate::DeviceJoinActivation, SyncError> {
1252        self.sync.finalize_device_join(completion).await
1253    }
1254
1255    pub async fn device_join_status(
1256        &self,
1257        attempt_id: crate::DeviceJoinAttemptId,
1258        role: crate::DeviceJoinRole,
1259    ) -> Result<Option<crate::DeviceJoinStatus>, SyncError> {
1260        self.joining.status(attempt_id, role).await
1261    }
1262
1263    pub async fn resume_device_joins(&self) -> Result<Vec<crate::DeviceJoinAction>, SyncError> {
1264        self.joining.resumable_actions().await
1265    }
1266
1267    pub async fn remove_member(&self, public_key_hex: &str) -> Result<(), SyncError> {
1268        self.membership.remove(public_key_hex).await
1269    }
1270
1271    #[cfg(test)]
1272    pub(crate) async fn admit_member_for_test(
1273        &self,
1274        public_key_hex: &str,
1275        role: MemberRole,
1276    ) -> Result<coven_replication::sync::MemberAdmission, SyncError> {
1277        self.membership.admit(public_key_hex, None, role).await
1278    }
1279
1280    pub async fn resolve_membership_conflict(
1281        &self,
1282        choice: &crate::MembershipConflictChoice,
1283    ) -> Result<(), SyncError> {
1284        self.membership.resolve_conflict(choice).await
1285    }
1286
1287    /// Propose excluding one Store device and return the code that identifies
1288    /// the exact activated proposal.
1289    pub async fn propose_device_exclusion(
1290        &self,
1291        device_id: crate::StoreDeviceId,
1292    ) -> Result<String, SyncError> {
1293        self.membership.propose_device_exclusion(device_id).await
1294    }
1295
1296    /// Cancel the exact Store-device exclusion proposal carried by `proposal_code`.
1297    pub async fn cancel_device_exclusion(&self, proposal_code: &str) -> Result<(), SyncError> {
1298        self.membership.cancel_device_exclusion(proposal_code).await
1299    }
1300
1301    /// Finalize the exact Store-device exclusion proposal carried by `proposal_code`.
1302    pub async fn finalize_device_exclusion(&self, proposal_code: &str) -> Result<(), SyncError> {
1303        self.membership
1304            .finalize_device_exclusion(proposal_code)
1305            .await
1306    }
1307
1308    /// Begin transferring Store ownership to an active device and return the
1309    /// request code that device must accept.
1310    pub async fn begin_owner_promotion(
1311        &self,
1312        device_id: crate::StoreDeviceId,
1313    ) -> Result<String, SyncError> {
1314        self.membership.begin_owner_promotion(device_id).await
1315    }
1316
1317    /// Accept an Owner-promotion request and return the acceptance code the
1318    /// existing Owner must finalize.
1319    pub async fn accept_owner_promotion(&self, request_code: &str) -> Result<String, SyncError> {
1320        self.membership.accept_owner_promotion(request_code).await
1321    }
1322
1323    /// Finalize the Owner-promotion acceptance carried by `acceptance_code`.
1324    pub async fn finalize_owner_promotion(&self, acceptance_code: &str) -> Result<(), SyncError> {
1325        self.membership
1326            .finalize_owner_promotion(acceptance_code)
1327            .await
1328    }
1329
1330    /// The Circle application surface: create, lifecycle, inspection, and typed
1331    /// [`CircleError`](crate::CircleError). A borrowed namespace with no state of
1332    /// its own.
1333    pub fn circles(&self) -> crate::Circles<'_> {
1334        crate::Circles::new(&self.circles)
1335    }
1336
1337    // =========================================================================
1338    // Rows
1339    // =========================================================================
1340
1341    #[cfg(test)]
1342    pub(crate) async fn create_test_store(
1343        &self,
1344        store_id: &str,
1345        signer: coven_keys::keys::UserKeypair,
1346        home: std::sync::Arc<coven_storage::cloud::test_utils::InMemoryCloudHome>,
1347    ) -> Result<
1348        std::sync::Arc<coven_replication::sync::test_helpers::TestStore>,
1349        coven_replication::sync::test_helpers::TestError,
1350    > {
1351        self.sync.create_test_store(store_id, signer, home).await
1352    }
1353
1354    #[cfg(test)]
1355    pub(crate) async fn publish_test_store(
1356        &self,
1357        store: &coven_replication::sync::test_helpers::TestStore,
1358    ) -> Result<bool, coven_replication::sync::test_helpers::TestError> {
1359        self.sync.publish_test_store(store).await
1360    }
1361
1362    #[cfg(test)]
1363    pub(crate) async fn pull_test_store(
1364        &self,
1365        store: &coven_replication::sync::test_helpers::TestStore,
1366    ) -> (
1367        std::collections::BTreeMap<String, u64>,
1368        coven_replication::sync::store::StorePullResult,
1369    ) {
1370        self.sync
1371            .pull_test_store(store)
1372            .await
1373            .expect("pull exact test Store")
1374    }
1375
1376    #[cfg(test)]
1377    pub(crate) async fn store_write_partition_for_test(
1378        &self,
1379        write_id: &crate::WriteId,
1380    ) -> Result<Vec<u8>, coven_database::DbError> {
1381        self.rows.store_write_partition_for_test(write_id).await
1382    }
1383
1384    #[cfg(test)]
1385    pub(crate) async fn write_blob_lease_count_for_test(
1386        &self,
1387        write_id: &crate::WriteId,
1388    ) -> Result<i64, coven_database::DbError> {
1389        self.rows.write_blob_lease_count_for_test(write_id).await
1390    }
1391
1392    #[cfg(test)]
1393    pub(crate) async fn store_write_journal_counts_for_test(
1394        &self,
1395    ) -> Result<(i64, i64), coven_database::DbError> {
1396        self.rows.store_write_journal_counts_for_test().await
1397    }
1398
1399    /// Count cleanup obligations for one blob in integration tests.
1400    #[cfg(any(test, feature = "test-utils"))]
1401    pub async fn cleanup_intent_count_for_test(
1402        &self,
1403        namespace: &str,
1404        blob_id: &str,
1405    ) -> Result<i64, coven_database::DbError> {
1406        self.rows
1407            .cleanup_intent_count_for_test(namespace, blob_id)
1408            .await
1409    }
1410
1411    #[cfg(test)]
1412    pub(crate) async fn coven_table_exists_for_test(
1413        &self,
1414        table: coven_database::DatabaseTestTable,
1415    ) -> Result<bool, coven_database::DbError> {
1416        self.rows.coven_table_exists_for_test(table).await
1417    }
1418
1419    #[cfg(test)]
1420    pub(crate) async fn install_store_write_failure_trigger_for_test(
1421        &self,
1422    ) -> Result<(), coven_database::DbError> {
1423        self.rows
1424            .install_store_write_failure_trigger_for_test()
1425            .await
1426    }
1427
1428    #[cfg(test)]
1429    pub(crate) async fn remove_store_write_failure_trigger_for_test(
1430        &self,
1431    ) -> Result<(), coven_database::DbError> {
1432        self.rows
1433            .remove_store_write_failure_trigger_for_test()
1434            .await
1435    }
1436
1437    #[cfg(test)]
1438    pub(crate) async fn write_blob_facts_for_test(
1439        &self,
1440        write_id: crate::WriteId,
1441    ) -> Result<String, coven_database::DbError> {
1442        self.rows.write_blob_facts_for_test(write_id).await
1443    }
1444
1445    #[cfg(test)]
1446    pub(crate) async fn execute_sql_with_blob_staging_for_test(
1447        &self,
1448        blob_staging: Option<Box<dyn coven_database::AudienceBlobMoveStaging>>,
1449        sql: String,
1450    ) -> crate::CovenResult<crate::WriteReceipt<()>> {
1451        self.rows
1452            .execute_sql_with_blob_staging_for_test(blob_staging, sql)
1453            .await
1454    }
1455
1456    #[cfg(test)]
1457    pub(crate) async fn latest_materialized_commit_coordinate_for_test(
1458        &self,
1459    ) -> Result<(String, u64), coven_database::DbError> {
1460        self.sync
1461            .latest_materialized_commit_coordinate_for_test()
1462            .await
1463    }
1464
1465    #[cfg(test)]
1466    pub(crate) fn arm_pull_after_remote_commit_for_test(
1467        &self,
1468        device_id: String,
1469        sequence: u64,
1470    ) -> (
1471        std::sync::Arc<tokio::sync::Notify>,
1472        std::sync::Arc<tokio::sync::Notify>,
1473    ) {
1474        self.sync
1475            .arm_pull_after_remote_commit_for_test(device_id, sequence)
1476    }
1477
1478    #[cfg(test)]
1479    pub(crate) async fn prepare_test_join_snapshot(
1480        &self,
1481        store: &coven_replication::sync::test_helpers::TestStore,
1482        owner: &coven_keys::keys::UserKeypair,
1483        snapshot_path: std::path::PathBuf,
1484    ) -> Result<(), coven_replication::sync::test_helpers::TestError> {
1485        self.sync
1486            .prepare_test_join_snapshot(store, owner, snapshot_path)
1487            .await
1488    }
1489}
1490
1491#[cfg(test)]
1492#[path = "handle_tests.rs"]
1493mod tests;