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;