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;