Skip to main content

coven_sync/
sync_status.rs

1//! The sync state a status screen shows (§19.1, api.md E5), computed in one
2//! read from what coven knows and never stored: whether a location is
3//! recorded and its credentials held, how the last pass ended, whether the
4//! running pass moved a slot, the files moving, each upload in the queue,
5//! whether the send queue holds a write, and the pending records. Each fact
6//! names one candidate state or none, and when several hold the one of
7//! highest rank shows, as `state` of the Lean model in
8//! `spec/proofs/io/CovenIO/SyncState.lean` computes it; the differential
9//! test checks this against that model's runner.
10
11use crate::{LastPass, LocalFailure, PendingReason, PendingRecord, PendingSubject, StatusFailure};
12use coven_database::{DbError, RecordedLocation, RetrySync};
13use coven_storage::{CloudProvider, StorageFailure};
14use std::time::SystemTime;
15
16/// What a status screen shows (E5, §19.1): the sync state and when sync
17/// last finished, as `sync_status()` reads it and the handle's live query
18/// follows it.
19#[derive(Clone, Debug, PartialEq)]
20pub struct SyncStatus {
21    /// The sync state.
22    pub state: SyncState,
23    /// When a pass last reached the end of the sequence, by the store's
24    /// clock; kept across every state and never earlier than before.
25    pub last_synced: Option<SystemTime>,
26}
27
28/// The sync state (§19.1). With several candidates, the first of `Removed`,
29/// `NeedsReset`, `UpdateNeeded`, `SignedOut`, `NoAccess`, `FolderMissing`,
30/// `ClockWrong`, `StorageFull`, `OfflineWithUnsentChanges`, `Offline`,
31/// `Failed`, `Syncing`, `WaitingToSync`, `Synced` shows; `NotSynced` comes
32/// before them all.
33#[derive(Clone, Debug, PartialEq)]
34pub enum SyncState {
35    /// No storage location is recorded on this device.
36    NotSynced,
37    /// A running pass has read or claimed a slot, or a file is being sent
38    /// or downloaded.
39    Syncing,
40    /// Nothing moves, and the last pass was held back, at a slot that can't
41    /// apply yet or by a provider's cooldown, or a file waits in the upload
42    /// queue, or a write in the send queue: what the pass waits for, else
43    /// what the first such file waits for, else what the write waits for.
44    WaitingToSync(SyncWait),
45    /// The last pass reached the end of the sequence, every file this device
46    /// uploads is stored, the send queue is empty, and nothing below holds:
47    /// nothing waits.
48    Synced,
49    /// The last pass got no provider response.
50    Offline(SyncCause),
51    /// Offline, and the send queue holds a write or the upload queue a file.
52    OfflineWithUnsentChanges(SyncCause),
53    /// A location is recorded, but this device holds no credentials for it
54    /// (no cause), or storage refused them.
55    SignedOut {
56        /// The recorded location's provider.
57        provider: CloudProvider,
58        /// What refused the credentials; none when this device holds none.
59        cause: Option<SyncCause>,
60    },
61    /// Signed in, but the account isn't allowed.
62    NoAccess(SyncCause),
63    /// The provider's quota is used up.
64    StorageFull(SyncCause),
65    /// The storage folder or bucket is gone.
66    FolderMissing(SyncCause),
67    /// The device's clock is too far from the provider's.
68    ClockWrong(SyncCause),
69    /// A newer app or coven is needed.
70    UpdateNeeded(SyncCause),
71    /// This device or its member was removed, or a joined store's admission
72    /// was refused (`NotAdmitted`, §13.2).
73    Removed(SyncCause),
74    /// Another copy uses this device's id, or its member keys were replaced.
75    NeedsReset(SyncCause),
76    /// Any other failure, or a file that can't be uploaded or read, whose
77    /// pending record is the cause.
78    Failed(SyncCause),
79}
80
81/// What waiting to sync waits for, as `WaitingToSync` names it: what holds
82/// every transfer, the pass's and each file's alike, first; then, for a
83/// file waiting in the upload queue, what holds that file (§19.1).
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum SyncWait {
86    /// Sync isn't running (`start_sync`, `stop_sync`).
87    NotSyncing,
88    /// The provider asked every request to wait until then (§6.7).
89    CoolingDown {
90        /// When the cooldown ends, by the store's clock.
91        until: SystemTime,
92    },
93    /// A slot can't apply yet; nothing goes out or comes in until it does
94    /// (§5.4).
95    Blocked,
96    /// The app paused uploads, and the file's reference trails
97    /// (`set_uploads_paused`, §17.4).
98    Paused,
99    /// The network is metered, or may be, since the platform stopped
100    /// answering after opening (E5); the app moves files only on unmetered
101    /// networks (`set_unmetered_only`, §17.9), and the file's reference
102    /// trails.
103    Metered,
104    /// Its last attempt failed, and it retries then, or at the person's
105    /// retry (§6.7); `coven_uploads.pending` says why.
106    Retrying {
107        /// When its backoff ends, by the store's clock.
108        at: SystemTime,
109    },
110    /// Its turn hasn't come: a file's, a write's, or the next pass's once a
111    /// cooldown that held the last one is over.
112    Queued,
113}
114
115/// What names a state: the last pass, or the first pending record, in the
116/// order they arose, that names it, with the cause `coven_pending` keeps.
117#[derive(Clone, Debug, PartialEq)]
118pub enum SyncCause {
119    /// How the last pass ended.
120    Pass(LastPass),
121    /// A pending record.
122    Pending(PendingRecord),
123}
124
125/// The states that name a cause, as the model's `State.named` lists them.
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127enum Named {
128    SignedOut,
129    NoAccess,
130    StorageFull,
131    FolderMissing,
132    ClockWrong,
133    UpdateNeeded,
134    Removed,
135    NeedsReset,
136    Failed,
137}
138
139impl Named {
140    /// Its place among the states, as `SyncState::rank` orders them.
141    fn rank(self) -> u8 {
142        match self {
143            Self::Failed => 3,
144            Self::StorageFull => 6,
145            Self::ClockWrong => 7,
146            Self::FolderMissing => 8,
147            Self::NoAccess => 9,
148            Self::SignedOut => 10,
149            Self::UpdateNeeded => 11,
150            Self::NeedsReset => 12,
151            Self::Removed => 13,
152        }
153    }
154
155    /// The state, named by `cause`, at a location of `provider`.
156    fn state(self, cause: SyncCause, provider: CloudProvider) -> SyncState {
157        match self {
158            Self::SignedOut => SyncState::SignedOut { provider, cause: Some(cause) },
159            Self::NoAccess => SyncState::NoAccess(cause),
160            Self::StorageFull => SyncState::StorageFull(cause),
161            Self::FolderMissing => SyncState::FolderMissing(cause),
162            Self::ClockWrong => SyncState::ClockWrong(cause),
163            Self::UpdateNeeded => SyncState::UpdateNeeded(cause),
164            Self::Removed => SyncState::Removed(cause),
165            Self::NeedsReset => SyncState::NeedsReset(cause),
166            Self::Failed => SyncState::Failed(cause),
167        }
168    }
169}
170
171impl SyncState {
172    /// Which state shows when several hold: the highest. The device's own
173    /// standing first, then signing in, then what storage said of the
174    /// account or location, then reachability, then the catch-all failure,
175    /// then activity: moving, then waiting.
176    fn rank(&self) -> u8 {
177        match self {
178            Self::Synced => 0,
179            Self::WaitingToSync(_) => 1,
180            Self::Syncing => 2,
181            Self::Failed(_) => Named::Failed.rank(),
182            Self::Offline(_) => 4,
183            Self::OfflineWithUnsentChanges(_) => 5,
184            Self::StorageFull(_) => Named::StorageFull.rank(),
185            Self::ClockWrong(_) => Named::ClockWrong.rank(),
186            Self::FolderMissing(_) => Named::FolderMissing.rank(),
187            Self::NoAccess(_) => Named::NoAccess.rank(),
188            Self::SignedOut { .. } => Named::SignedOut.rank(),
189            Self::UpdateNeeded(_) => Named::UpdateNeeded.rank(),
190            Self::NeedsReset(_) => Named::NeedsReset.rank(),
191            Self::Removed(_) => Named::Removed.rank(),
192            Self::NotSynced => 14,
193        }
194    }
195}
196
197/// The state a provider failure names: what storage said of this account or
198/// location that a person must see, or coven's own bug (the model's
199/// `Failure.named`).
200#[deny(clippy::wildcard_enum_match_arm)]
201fn storage_names(failure: StorageFailure) -> Option<Named> {
202    use StorageFailure::*;
203    match failure {
204        Authentication => Some(Named::SignedOut),
205        PermissionDenied | AccessNotGranted { .. } => Some(Named::NoAccess),
206        QuotaExceeded => Some(Named::StorageFull),
207        ContainerNotFound => Some(Named::FolderMissing),
208        ClockSkew => Some(Named::ClockWrong),
209        // Its work retries, as a local bug's does, and shows failed meanwhile.
210        Bug => Some(Named::Failed),
211        ProviderUnavailable
212        | AccessNotRegistered { .. }
213        | DropboxAppFolderNotShareable
214        | CatchUpRequired
215        | Network
216        | Corrupted
217        | NotFound
218        | AlreadyExists
219        | Conflict
220        | RegionMismatch
221        | StoreExists
222        | OwnLocationRequired
223        | RateLimited
224        | InvalidConfiguration
225        | Refused
226        | Protocol
227        | InvalidPath
228        | InvalidRange
229        | NotStoreOwner
230        | InviteeIsStoreOwner
231        | SharingRefused(_)
232        | AccountIdUnavailable
233        | InvitationMismatch
234        | SessionMismatch
235        | SessionExpired
236        | SessionIdentityUnknown
237        | InvalidPart
238        | SingleRequestTooLarge { .. }
239        | CopyTooLarge { .. }
240        | Encoding
241        | File
242        | MemberKeysMissing => None,
243    }
244}
245
246impl PendingReason {
247    /// The state a pending record names, by its reason (the model's
248    /// `Reason.state`). A wait names none, nor does a verdict on an admin
249    /// operation or on another member's writes, which the app shows with
250    /// that operation. A provider failure that clears by itself names none
251    /// either: it retries on its own, and the pass says whether storage is
252    /// reachable. A file that can't be uploaded or read names failed: it
253    /// holds back what waits for it and is never skipped, so it shows until
254    /// the file uploads or the person acts. Any other failure names the
255    /// state its cause does, or failed. A cleanup names what its cleanup
256    /// waits on, as it retries that.
257    #[deny(clippy::wildcard_enum_match_arm)]
258    fn names(&self) -> Option<Named> {
259        match self {
260            Self::Storage { failure, .. } => storage_names(*failure).or_else(|| {
261                (crate::pending::storage_clears(*failure) != crate::pending::Clears::ByItself)
262                    .then_some(Named::Failed)
263            }),
264            Self::UpdateRequired(_) => Some(Named::UpdateNeeded),
265            Self::Removed | Self::NotAdmitted => Some(Named::Removed),
266            Self::MemberKeysReplaced { .. } => Some(Named::NeedsReset),
267            Self::Refused(_) | Self::FileUnavailable(_) | Self::Original { .. } => {
268                Some(Named::Failed)
269            }
270            Self::Failed(LocalFailure::Shutdown) => None,
271            Self::Failed(
272                LocalFailure::Database
273                | LocalFailure::DiskFull
274                | LocalFailure::DiskDenied
275                | LocalFailure::DiskOther
276                | LocalFailure::KeyCustody
277                | LocalFailure::Crypto
278                | LocalFailure::Operation
279                | LocalFailure::Bug,
280            ) => Some(Named::Failed),
281            Self::Cleanup { cleanup, .. } => cleanup.names(),
282            Self::Missing { .. }
283            | Self::Waits(_)
284            | Self::KeyUnavailable { .. }
285            | Self::NoStorage
286            | Self::WritesRefused { .. }
287            | Self::ProviderPending { .. }
288            | Self::AccountInUse { .. }
289            | Self::AccessRemains { .. }
290            | Self::Rejected(_)
291            | Self::Paused => None,
292        }
293    }
294}
295
296impl StatusFailure {
297    /// The state a pass that failed as a whole names (the model's
298    /// `PassFailure.state` but for offline); none for a provider's cooldown.
299    #[deny(clippy::wildcard_enum_match_arm)]
300    fn names(&self) -> Option<Named> {
301        match self {
302            Self::UpdateRequired(_) => Some(Named::UpdateNeeded),
303            Self::Removed | Self::NotAdmitted => Some(Named::Removed),
304            Self::DeviceIdInUse(_) => Some(Named::NeedsReset),
305            Self::Storage(failure) => storage_names(*failure)
306                .or_else(|| (*failure != StorageFailure::RateLimited).then_some(Named::Failed)),
307            Self::Operation(record) => Some(record.reason.names().unwrap_or(Named::Failed)),
308            Self::Other => Some(Named::Failed),
309        }
310    }
311}
312
313impl LastPass {
314    /// What this pass says, with no slot moving (the model's `Facts.ended`):
315    /// a pass held back by a wait, at a slot or by a provider's cooldown,
316    /// waits to sync, for what `held` says holds every transfer, else its
317    /// turn; offline is offline with unsent changes while a write or a file
318    /// waits to go out.
319    fn ended(&self, unsent: bool, held: Option<SyncWait>, provider: CloudProvider) -> SyncState {
320        let cause = || SyncCause::Pass(self.clone());
321        let waiting = || SyncState::WaitingToSync(held.unwrap_or(SyncWait::Queued));
322        match self {
323            Self::Synced => SyncState::Synced,
324            Self::Offline { .. } if unsent => SyncState::OfflineWithUnsentChanges(cause()),
325            Self::Offline { .. } => SyncState::Offline(cause()),
326            Self::Blocked { reason, .. } => {
327                reason.names().map_or_else(waiting, |named| named.state(cause(), provider))
328            }
329            Self::Failed { failure, .. } => {
330                failure.names().map_or_else(waiting, |named| named.state(cause(), provider))
331            }
332        }
333    }
334}
335
336/// Where one upload in this device's queue stands (§17.9).
337#[derive(Clone, Copy, Debug, PartialEq, Eq)]
338pub(crate) enum Progress {
339    /// An attempt is sending it now.
340    Moving,
341    /// Storage holds its bytes; it waits for the rest of its move.
342    Stored,
343    /// Queued, and no attempt is sending it.
344    Queued,
345}
346
347/// One upload in this device's queue.
348#[derive(Clone, Debug, PartialEq)]
349pub(crate) struct Upload {
350    pub(crate) progress: Progress,
351    /// A bound reference's, which the pause doesn't hold (§17.4).
352    pub(crate) bound: bool,
353    /// Its last attempt failed, and it retries then (§6.7).
354    pub(crate) retry_at: Option<SystemTime>,
355    /// Its own pending record, if it has one.
356    pub(crate) record: Option<PendingRecord>,
357}
358
359/// What the state is computed from, as the model's `Facts` names it; the
360/// time of the last finished sync is read beside it.
361#[derive(Clone, Debug, PartialEq)]
362pub(crate) struct Facts {
363    pub(crate) location: RecordedLocation<CloudProvider>,
364    /// How the last pass ended; none until a pass ends here, which counts
365    /// as synced, since setup, joining and restoring read the store to its
366    /// end.
367    pub(crate) last: Option<LastPass>,
368    /// The running pass has read a slot it hadn't, or claimed one.
369    pub(crate) moving: bool,
370    /// A file is being downloaded.
371    pub(crate) downloading: bool,
372    /// The uploads a file waits for that the state can show, in queue
373    /// order: the first one sending, the first queued one with no record,
374    /// and every one with a record. The rest change no candidate: a later
375    /// one of a rank never shows over the first, and a stored one names
376    /// none.
377    pub(crate) uploads: Vec<Upload>,
378    /// The upload queue holds a file, whether `uploads` lists it or not.
379    pub(crate) queued: bool,
380    /// The send queue holds a write.
381    pub(crate) unsent: bool,
382    /// The pending records, in the order they arose, but the connection's,
383    /// which is the last pass's failure kept for its retry.
384    pub(crate) records: Vec<PendingRecord>,
385    /// Whether a started connection will plan, and the cooldown holding
386    /// every request.
387    pub(crate) sync: RetrySync,
388    /// The app paused trailing uploads.
389    pub(crate) paused: bool,
390    /// The app moves files only on unmetered networks.
391    pub(crate) unmetered_only: bool,
392    /// The store's watch says the device's network is metered.
393    pub(crate) metered: bool,
394}
395
396/// A state that may show, its cause made only for the one that does.
397enum Candidate<'a> {
398    State(SyncState),
399    Named(Named, &'a PendingRecord),
400}
401
402impl Candidate<'_> {
403    fn rank(&self) -> u8 {
404        match self {
405            Self::State(state) => state.rank(),
406            Self::Named(named, _) => named.rank(),
407        }
408    }
409
410    fn state(self, provider: CloudProvider) -> SyncState {
411        match self {
412            Self::State(state) => state,
413            Self::Named(named, record) => named.state(SyncCause::Pending(record.clone()), provider),
414        }
415    }
416}
417
418impl Facts {
419    /// A write or an upload waits to go out.
420    fn unsent_work(&self) -> bool {
421        self.unsent || self.queued
422    }
423
424    /// What holds every transfer, the pass's, each upload's and a queued
425    /// write's alike: sync stopped, a provider's cooldown, or a slot that
426    /// can't apply yet (the model's `Facts.heldAll`).
427    fn held_all(&self) -> Option<SyncWait> {
428        match self.sync {
429            RetrySync::Stopped => Some(SyncWait::NotSyncing),
430            RetrySync::Started { cooling_until: Some(until) } => {
431                Some(SyncWait::CoolingDown { until })
432            }
433            RetrySync::Started { cooling_until: None } => {
434                matches!(self.last, Some(LastPass::Blocked { .. })).then_some(SyncWait::Blocked)
435            }
436        }
437    }
438
439    /// What one queued upload waits for: what holds every transfer first,
440    /// then what holds this one (the model's `Facts.uploadWait`).
441    fn upload_wait(&self, upload: &Upload) -> SyncWait {
442        if let Some(held) = self.held_all() {
443            held
444        } else if self.paused && !upload.bound {
445            SyncWait::Paused
446        } else if self.unmetered_only && self.metered && crate::files::held_on_metered(upload.bound)
447        {
448            SyncWait::Metered
449        } else if let Some(at) = upload.retry_at {
450            SyncWait::Retrying { at }
451        } else {
452            SyncWait::Queued
453        }
454    }
455
456    /// What an upload names: syncing while it moves; once queued, what its
457    /// record names, else waiting to sync; nothing once stored.
458    fn upload<'a>(&self, upload: &'a Upload) -> Option<Candidate<'a>> {
459        match upload.progress {
460            Progress::Moving => Some(Candidate::State(SyncState::Syncing)),
461            Progress::Stored => None,
462            Progress::Queued => Some(
463                match upload
464                    .record
465                    .as_ref()
466                    .and_then(|record| Some((record.reason.names()?, record)))
467                {
468                    Some((named, record)) => Candidate::Named(named, record),
469                    None => Candidate::State(SyncState::WaitingToSync(self.upload_wait(upload))),
470                },
471            ),
472        }
473    }
474
475    /// The state shown: not synced with no location recorded, else the
476    /// highest-ranked candidate. Among candidates of one state the first
477    /// shows, so the cause is the last pass, or else the first record, in
478    /// the order they arose, that names the state, and waiting to sync names
479    /// the held-back pass's wait, else the first waiting upload's, else a
480    /// queued write's. Signed out with no credentials comes first of all,
481    /// with no cause.
482    pub(crate) fn state(&self) -> SyncState {
483        let (provider, credentials) = match self.location {
484            RecordedLocation::None => return SyncState::NotSynced,
485            RecordedLocation::SignedOut(provider) => (provider, false),
486            RecordedLocation::Connected(provider) => (provider, true),
487        };
488        let signed_out = (!credentials)
489            .then_some(Candidate::State(SyncState::SignedOut { provider, cause: None }));
490        let looping = Candidate::State(if self.moving {
491            SyncState::Syncing
492        } else {
493            match &self.last {
494                None => SyncState::Synced,
495                Some(last) => last.ended(self.unsent_work(), self.held_all(), provider),
496            }
497        });
498        let records = self
499            .records
500            .iter()
501            .filter_map(|record| Some(Candidate::Named(record.reason.names()?, record)));
502        let downloading = self.downloading.then_some(Candidate::State(SyncState::Syncing));
503        let uploads = self.uploads.iter().filter_map(|upload| self.upload(upload));
504        // A write goes out with the next pass, so it waits for what holds
505        // every pass, else its turn (the model's `Facts.writes`).
506        let writes = self.unsent.then(|| {
507            Candidate::State(SyncState::WaitingToSync(self.held_all().unwrap_or(SyncWait::Queued)))
508        });
509        signed_out
510            .into_iter()
511            .chain([looping])
512            .chain(records)
513            .chain(downloading)
514            .chain(uploads)
515            .chain(writes)
516            .reduce(|shown, next| if next.rank() > shown.rank() { next } else { shown })
517            .expect("the loop's candidate is always one")
518            .state(provider)
519    }
520
521    /// The facts as the database read them, and when sync last finished.
522    fn read(
523        facts: coven_database::SyncFacts<PendingRecord>,
524    ) -> Result<(Self, Option<SystemTime>), DbError> {
525        let row = facts.row;
526        let location = match row.location {
527            RecordedLocation::None => RecordedLocation::None,
528            RecordedLocation::SignedOut(name) => {
529                RecordedLocation::SignedOut(named_provider(&name)?)
530            }
531            RecordedLocation::Connected(name) => {
532                RecordedLocation::Connected(named_provider(&name)?)
533            }
534        };
535        let last = row
536            .last_pass
537            .map(|bytes| serde_json::from_slice(&bytes).map_err(DbError::StateEncoding))
538            .transpose()?;
539        let uploads = facts
540            .uploads
541            .into_iter()
542            .map(|upload| Upload {
543                progress: if upload.sending {
544                    Progress::Moving
545                } else if upload.stored {
546                    Progress::Stored
547                } else {
548                    Progress::Queued
549                },
550                bound: upload.bound,
551                retry_at: upload.retry_at,
552                record: upload.record,
553            })
554            .collect();
555        let facts = Self {
556            location,
557            last,
558            moving: row.moving,
559            downloading: facts.downloading,
560            uploads,
561            queued: facts.queued,
562            unsent: facts.unsent,
563            records: facts
564                .records
565                .into_iter()
566                .filter(|record| record.subject != PendingSubject::Connection)
567                .collect(),
568            sync: facts.retry.sync,
569            paused: facts.paused,
570            unmetered_only: facts.unmetered_only,
571            metered: facts.metered,
572        };
573        Ok((facts, row.last_synced))
574    }
575}
576
577impl SyncStatus {
578    /// The sync state and when sync last finished, from what one committed
579    /// snapshot of coven's rows holds (§19.1): what `Database::sync_facts`
580    /// reads once and `Database::subscribe_sync_facts` follows.
581    pub fn computed(facts: coven_database::SyncFacts<PendingRecord>) -> Result<Self, DbError> {
582        let (facts, last_synced) = Facts::read(facts)?;
583        Ok(Self { state: facts.state(), last_synced })
584    }
585
586    /// A closed store's sync status (api.md E1, E5): the state opening shows
587    /// before sync starts, from the facts its database holds, with what
588    /// opening writes over them: `location`, read from the keychain as
589    /// opening reads it; no pass moving, whatever a process that ended
590    /// mid-pass left; and sync not running. The facts read with nothing sent
591    /// or downloaded, as nothing moves while the store is closed.
592    pub fn closed(
593        facts: coven_database::SyncFacts<PendingRecord>,
594        location: RecordedLocation<CloudProvider>,
595    ) -> Result<Self, DbError> {
596        let (facts, last_synced) = Facts::read(facts)?;
597        let facts = Facts { location, moving: false, sync: RetrySync::Stopped, ..facts };
598        Ok(Self { state: facts.state(), last_synced })
599    }
600}
601
602/// The sync state and when sync last finished, computed from what coven
603/// knows in one committed snapshot (§19.1).
604pub async fn sync_status(database: &coven_database::Database) -> Result<SyncStatus, DbError> {
605    database.sync_facts(SyncStatus::computed).await
606}
607
608/// The name `coven_sync_status.provider` gives a provider: its own.
609fn provider_name(provider: CloudProvider) -> String {
610    match provider {
611        CloudProvider::S3 => "S3",
612        CloudProvider::Dropbox => "Dropbox",
613        CloudProvider::OneDrive => "OneDrive",
614        CloudProvider::CloudKit => "CloudKit",
615    }
616    .into()
617}
618
619/// The provider `coven_sync_status.provider` names.
620fn named_provider(name: &str) -> Result<CloudProvider, DbError> {
621    match name {
622        "S3" => Ok(CloudProvider::S3),
623        "Dropbox" => Ok(CloudProvider::Dropbox),
624        "OneDrive" => Ok(CloudProvider::OneDrive),
625        "CloudKit" => Ok(CloudProvider::CloudKit),
626        _ => Err(DbError::DamagedDatabase),
627    }
628}
629
630/// Write, as the store opens, whether a storage location is recorded here
631/// and its credentials held, and that no pass moves; `finished`, for a store
632/// joining or restoring read to its end, is when (E5). A failure fails the
633/// store's handle with its cause.
634pub async fn record_opened(
635    database: &coven_database::Database,
636    location: RecordedLocation<CloudProvider>,
637    finished: Option<SystemTime>,
638) -> Result<(), DbError> {
639    database.record_opened(location.map(provider_name), finished).await
640}
641
642/// Write whether a storage location is recorded here and its credentials
643/// held, as setup, a change of credentials or releasing a connection finds
644/// it; `finished`, for setup, is when it read the store to its end. A
645/// failure fails the store's handle with its cause.
646pub async fn record_location(
647    database: &coven_database::Database,
648    location: RecordedLocation<CloudProvider>,
649    finished: Option<SystemTime>,
650) -> Result<(), DbError> {
651    database.record_location(location.map(provider_name), finished).await
652}
653
654#[cfg(test)]
655#[path = "sync_status_tests.rs"]
656mod tests;