1use std::num::NonZeroUsize;
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use crate::handle::CovenHandle;
9use crate::store_sync::ConfigProvider;
10use crate::{Migration, MigrationError};
11use coven_database::store::StoreReads;
12use coven_database::{CovenMigrationPolicy, Database, DbError, OpenError};
13use coven_foundation::clock::{ClockRef, SystemClock};
14use coven_foundation::config::{Config, HomeStorage};
15use coven_foundation::store_dir::{LocalBlobStoreError, PathTokenError};
16use coven_foundation::store_dir::{StoreDir, StoreOpenGuard};
17use coven_keys::custody::KeyCustody;
18use coven_keys::identity_custody::IdentityCustody;
19use coven_keys::keys::StoreKeys;
20use coven_protocol::blob::BlobTransitionObserver;
21use coven_protocol::synced_schema::SyncedTable;
22
23pub type CovenResult<T> = Result<T, CovenError>;
24
25#[derive(Debug, thiserror::Error)]
26pub enum CovenError {
27 #[error("host callback failed: {0}")]
29 Host(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
30 #[error("database error: {0}")]
31 Database(#[source] Box<DbError>),
32 #[error("migration error: {0}")]
33 Migration(MigrationError),
34 #[error("Coven schema migration error: {0}")]
35 CovenMigration(coven_database::CovenMigrationError),
36 #[error("sqlite error: {0}")]
37 Sqlite(#[from] rusqlite::Error),
38 #[error("file error: {0}")]
39 File(#[from] coven_foundation::atomic_file::FileError),
40 #[error("local blob {} has {actual_size} bytes, expected {expected_size}", path.display())]
41 LocalBlobSizeMismatch {
42 path: PathBuf,
43 expected_size: u64,
44 actual_size: u64,
45 },
46 #[error("unsafe blob path: {0}")]
47 UnsafeBlobPath(#[from] PathTokenError),
48 #[error("row routing key: {0}")]
49 RoutingEncryption(#[from] coven_keys::keys::RoutingEncryptionError),
50 #[error("store database path has no parent: {}", path.display())]
51 StorePathHasNoParent { path: PathBuf },
52 #[error("the write SQL closure panicked")]
53 WriteClosurePanicked,
54 #[error(
55 "write failed: {write}; failed to remove installed local blobs during rollback: {rollback}"
56 )]
57 WriteRollbackFailed {
58 #[source]
59 write: Box<CovenError>,
60 rollback: coven_database::BlobFileFailures,
61 },
62 #[error("write failed: {operation}; failed to remove unpublished local blobs: {cleanup}")]
63 BlobCleanupFailed {
64 #[source]
65 operation: Box<CovenError>,
66 cleanup: coven_database::BlobFileFailures,
67 },
68 #[error("synced_tables must be set before opening a coven store")]
69 MissingSyncedTables,
70 #[error("migrations must be set before opening a coven store")]
71 MissingMigrations,
72 #[error("coven_migration_policy must be set before opening a coven store for writing")]
73 MissingCovenMigrationPolicy,
74 #[error("candidate resolution failed: {0}")]
75 CandidateResolution(Box<coven_replication::sync::SyncError>),
76 #[error("blob declaration failed: {0}")]
77 BlobDeclaration(#[from] coven_database::BlobDeclError),
78 #[error("blob_tombstone_grace must be a positive duration")]
79 InvalidBlobTombstoneGrace,
80 #[error("browsable cloud storage cannot be used with scoped table {table:?}")]
81 BrowsableStorageWithScopedTable { table: String },
82 #[error("blob {namespace}/{id} is still referenced by a row after the write")]
83 BlobStillReferenced { namespace: String, id: String },
84 #[error("blob {namespace}/{id} is already referenced by a row")]
85 BlobAlreadyReferenced { namespace: String, id: String },
86 #[error("blob {namespace}/{id} is owned by an unpublished write")]
87 BlobOwnedByPendingWrite { namespace: String, id: String },
88 #[error("store is already open: {}", store_dir.display())]
89 AlreadyOpen { store_dir: PathBuf },
90 #[error("I/O error: {0}")]
91 Io(#[from] std::io::Error),
92 #[cfg(test)]
93 #[error("test failure: {0}")]
94 TestFailure(&'static str),
95}
96
97impl From<DbError> for CovenError {
98 fn from(error: DbError) -> Self {
99 Self::Database(Box::new(error))
100 }
101}
102
103impl From<OpenError> for CovenError {
104 fn from(value: OpenError) -> Self {
105 match value {
106 OpenError::CovenMigration(e) => CovenError::CovenMigration(e),
107 OpenError::Migration(e) => CovenError::Migration(e),
108 OpenError::Db(e) => CovenError::from(e),
109 }
110 }
111}
112
113impl From<LocalBlobStoreError> for CovenError {
114 fn from(value: LocalBlobStoreError) -> Self {
115 match value {
116 LocalBlobStoreError::Path(error) => CovenError::UnsafeBlobPath(error),
117 LocalBlobStoreError::File(error) => CovenError::File(error),
118 LocalBlobStoreError::SizeMismatch {
119 path,
120 expected_size,
121 actual_size,
122 } => CovenError::LocalBlobSizeMismatch {
123 path,
124 expected_size,
125 actual_size,
126 },
127 }
128 }
129}
130
131#[derive(Clone)]
132pub struct CovenConfig(ConfigProvider);
133
134impl CovenConfig {
135 fn current(&self) -> Config {
136 (self.0)()
137 }
138
139 fn provider(&self) -> ConfigProvider {
140 self.0.clone()
141 }
142}
143
144impl From<Config> for CovenConfig {
145 fn from(value: Config) -> Self {
146 let config = value;
147 Self(Arc::new(move || config.clone()))
148 }
149}
150
151impl<F> From<F> for CovenConfig
152where
153 F: Fn() -> Config + Send + Sync + 'static,
154{
155 fn from(value: F) -> Self {
156 Self(Arc::new(value))
157 }
158}
159
160pub struct Coven;
161
162impl Coven {
163 pub fn builder(store_dir: StoreDir, config: impl Into<CovenConfig>) -> CovenBuilder {
164 let config = config.into();
165 CovenBuilder {
166 store_dir,
167 config,
168 synced_tables: None,
169 migrations: None,
170 coven_migration_policy: None,
171 blob_tombstone_grace: coven_protocol::blob::BLOB_TOMBSTONE_GRACE,
172 max_concurrent_uploads: NonZeroUsize::MIN,
173 max_concurrent_downloads: NonZeroUsize::MIN,
174 clock: Arc::new(SystemClock),
175 key_custody: KeyCustody::Keyring,
176 identity_custody: IdentityCustody::Keyring,
177 oauth_clients: coven_storage::oauth::OAuthClients::empty(),
178 cloudkit_ops: None,
179 observer: None,
180 }
181 }
182
183 pub fn forget_keyring_master_key(store_id: &str) -> Result<(), coven_keys::keys::KeyError> {
191 StoreKeys::bind(store_id.to_string()).delete_encryption_key()
192 }
193}
194
195pub struct CovenBuilder {
196 store_dir: StoreDir,
197 config: CovenConfig,
198 synced_tables: Option<Vec<SyncedTable>>,
199 migrations: Option<Vec<Migration>>,
200 coven_migration_policy: Option<CovenMigrationPolicy>,
201 blob_tombstone_grace: chrono::Duration,
202 max_concurrent_uploads: NonZeroUsize,
203 max_concurrent_downloads: NonZeroUsize,
204 clock: ClockRef,
205 key_custody: KeyCustody,
206 identity_custody: IdentityCustody,
207 oauth_clients: coven_storage::oauth::OAuthClients,
208 cloudkit_ops: Option<Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>>,
209 observer: Option<Arc<dyn BlobTransitionObserver>>,
210}
211
212impl From<coven_foundation::store_dir::StoreOpenGuardError> for CovenError {
213 fn from(error: coven_foundation::store_dir::StoreOpenGuardError) -> Self {
214 match error {
215 coven_foundation::store_dir::StoreOpenGuardError::AlreadyOpen { store_dir } => {
216 CovenError::AlreadyOpen { store_dir }
217 }
218 coven_foundation::store_dir::StoreOpenGuardError::NoParent { path } => {
219 CovenError::StorePathHasNoParent { path }
220 }
221 coven_foundation::store_dir::StoreOpenGuardError::File(error) => {
222 CovenError::File(error)
223 }
224 }
225 }
226}
227
228impl CovenBuilder {
229 pub fn synced_tables(mut self, tables: Vec<SyncedTable>) -> Self {
230 self.synced_tables = Some(tables);
231 self
232 }
233
234 pub fn blob_tombstone_grace(mut self, grace: chrono::Duration) -> Self {
240 self.blob_tombstone_grace = grace;
241 self
242 }
243
244 pub fn max_concurrent_uploads(mut self, n: NonZeroUsize) -> Self {
248 self.max_concurrent_uploads = n;
249 self
250 }
251
252 pub fn max_concurrent_downloads(mut self, n: NonZeroUsize) -> Self {
256 self.max_concurrent_downloads = n;
257 self
258 }
259
260 pub fn migrations(mut self, migrations: Vec<Migration>) -> Self {
264 self.migrations = Some(migrations);
265 self
266 }
267
268 pub fn coven_migration_policy(mut self, policy: CovenMigrationPolicy) -> Self {
271 self.coven_migration_policy = Some(policy);
272 self
273 }
274
275 pub fn clock(mut self, clock: ClockRef) -> Self {
276 self.clock = clock;
277 self
278 }
279
280 pub fn key_custody(mut self, custody: KeyCustody) -> Self {
286 self.key_custody = custody;
287 self
288 }
289
290 pub fn identity_custody(mut self, custody: IdentityCustody) -> Self {
298 self.identity_custody = custody;
299 self
300 }
301
302 pub fn oauth_clients(mut self, clients: coven_storage::oauth::OAuthClients) -> Self {
304 self.oauth_clients = clients;
305 self
306 }
307
308 pub fn apply_cloudkit_ops(
309 mut self,
310 ops: Option<Arc<dyn coven_storage::cloud::cloudkit::CloudKitOps>>,
311 ) -> Self {
312 self.cloudkit_ops = ops;
313 self
314 }
315
316 pub fn observer(mut self, observer: Arc<dyn BlobTransitionObserver>) -> Self {
317 self.observer = Some(observer);
318 self
319 }
320
321 pub fn open(self) -> CovenResult<CovenHandle> {
332 let config = self.config.current();
333 let tables = validated_synced_tables(&config, self.synced_tables)?;
334 let migrations = self.migrations.ok_or(CovenError::MissingMigrations)?;
335 let coven_migration_policy = self
336 .coven_migration_policy
337 .ok_or(CovenError::MissingCovenMigrationPolicy)?;
338 if self.blob_tombstone_grace <= chrono::Duration::zero() {
339 return Err(CovenError::InvalidBlobTombstoneGrace);
340 }
341 let store_dir = self.store_dir;
342 let db_path = store_dir.db_path();
343 let provider = self.config.provider();
344 let transfer_limits = coven_protocol::blob::TransferLimits {
345 uploads: self.max_concurrent_uploads,
346 downloads: self.max_concurrent_downloads,
347 };
348 let open_guard = Arc::new(StoreOpenGuard::acquire(&store_dir)?);
349 store_dir.remove_orphaned_write_temps(self.clock.now().into())?;
350 let db = Database::open(
351 &db_path,
352 tables.clone(),
353 self.blob_tombstone_grace,
354 transfer_limits,
355 config.device_id.clone(),
356 self.clock.clone(),
357 coven_migration_policy,
358 &migrations,
359 )?;
360 let read_db = StoreReads::open(&db_path)?;
363 let (key_service, key_custody, identity_custody) =
364 resolve_custody(&config, &store_dir, self.key_custody, self.identity_custody);
365 Ok(CovenHandle::new(
366 db,
367 read_db,
368 store_dir,
369 provider,
370 key_service,
371 key_custody,
372 identity_custody,
373 self.oauth_clients,
374 self.clock,
375 self.cloudkit_ops,
376 self.observer,
377 open_guard,
378 coven_storage::BlobChunking::DEFAULT,
379 ))
380 }
381
382 pub fn open_read_only(self) -> CovenResult<crate::read_handle::CovenReadHandle> {
401 let config = self.config.current();
402 let tables = validated_synced_tables(&config, self.synced_tables)?;
403 let migrations = self.migrations.ok_or(CovenError::MissingMigrations)?;
404 let store_dir = self.store_dir;
405 let db_path = store_dir.db_path();
406 let provider = self.config.provider();
407 let db = Database::open_read_only(
411 &db_path,
412 tables,
413 self.blob_tombstone_grace,
414 coven_protocol::blob::TransferLimits {
415 uploads: self.max_concurrent_uploads,
416 downloads: self.max_concurrent_downloads,
417 },
418 config.device_id.clone(),
419 self.clock.clone(),
420 &migrations,
421 )?;
422 let (key_service, key_custody, identity_custody) =
423 resolve_custody(&config, &store_dir, self.key_custody, self.identity_custody);
424 let reads = StoreReads::open(&db_path)?;
425 Ok(crate::read_handle::CovenReadHandle::new(
426 db,
427 reads,
428 store_dir,
429 provider,
430 key_service,
431 key_custody,
432 identity_custody,
433 self.oauth_clients,
434 self.clock,
435 self.cloudkit_ops,
436 coven_storage::BlobChunking::DEFAULT,
437 ))
438 }
439}
440
441fn validated_synced_tables(
445 config: &Config,
446 tables: Option<Vec<SyncedTable>>,
447) -> CovenResult<Vec<SyncedTable>> {
448 let tables = tables.ok_or(CovenError::MissingSyncedTables)?;
449 validate_storage_scope(config, &tables)?;
450 Ok(tables)
451}
452
453fn resolve_custody(
457 config: &Config,
458 store_dir: &StoreDir,
459 key_custody: KeyCustody,
460 identity_custody: IdentityCustody,
461) -> (
462 StoreKeys,
463 Arc<dyn coven_keys::keys::MasterKeyCustody>,
464 Arc<dyn coven_keys::keys::DeviceIdentityCustody>,
465) {
466 let key_service = StoreKeys::bind(config.store_id.clone());
467 let master = key_custody.resolve(&key_service, store_dir);
468 let identity = identity_custody.resolve(&key_service, store_dir);
469 (key_service, master, identity)
470}
471
472fn validate_storage_scope(config: &Config, tables: &[SyncedTable]) -> CovenResult<()> {
473 if config.cloud_home.storage == HomeStorage::Browsable {
474 if let Some(table) = tables
475 .iter()
476 .find(|table| table.audience_column().is_some())
477 {
478 return Err(CovenError::BrowsableStorageWithScopedTable {
479 table: table.name().to_string(),
480 });
481 }
482 }
483 Ok(())
484}
485
486#[cfg(test)]
487#[path = "coven_tests.rs"]
488mod tests;
489
490#[cfg(test)]
491mod error_size_tests {
492 #[test]
493 fn coven_error_fits_below_clippys_large_result_threshold() {
494 let size = std::mem::size_of::<super::CovenError>();
495 assert!(size <= 128, "CovenError occupies {size} bytes");
496 }
497}