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