The folder connector was pointed at a Nextcloud VFS tree and got three things wrong, the first of which loses work. **A dehydrated sidecar read as absent.** `a.drsc` does not exist when the client has dehydrated it — only `a.drsc.nextcloud` does — so `get` missed, `.ok()` swallowed the `NotFound`, and the sidecar writer took that for "there is no sidecar yet" and wrote a fresh document over the existing one. Every edit another device had put there went with it. That function's own doc comment calls this the exact loss the format's unknown-key preservation exists to prevent. **A stub was catalogued as a 1-byte image**, and ARCH §9.0 measured this machine at 121,785 placeholders against 10,267 real files — so a folder library on a synced tree was ~92% broken rows. **Identity changed on hydration**, so downloading a photograph looked like a delete and an add, orphaning its thumbnail and its face rows. Entries now carry the photograph's own name and a `materialised` flag; `get` on a stub returns the new `RemoteError::NotMaterialised`, which is distinct from `NotFound` precisely because the sidecar writer must treat them differently — it fetches the sidecar and merges, or leaves the entry queued. Hydration is a **borrow**. `BorrowPool` records what was on disk before it asked, so `release_all` dehydrates only what a pass brought and leaves what the user already had. Reference counted: the thumbnail pass and the face pass meet on the same RAW, and without counting the first to finish dehydrates the file the second is reading. A borrow against a plain folder or a server does nothing, so a pass written for VFS runs everywhere. Releasing means asking the client to dehydrate and never deleting: a deletion inside a synced tree propagates to the server and removes the photograph from every device. Not a second backend — the capability is per *connection*, not per type, since the same folder hydrates only while the client runs. The convention arrives through a detector the registry supplies, so `dr-sync-folder` still knows nothing about any client's protocol. ARCH §9.0a records this as an amendment: finding 3 rejected hydration because it costs 100× a range read, and that comparison assumed a connector was available. A folder library has none.
109 lines
4.6 KiB
Rust
109 lines
4.6 KiB
Rust
// TRACES: FR-NC-12 | FR-NC-13
|
|
//! The one place the interface names a backend.
|
|
//!
|
|
//! `dr-sync` defines [`RemoteBackend`] and a capability model the engine adapts
|
|
//! to, so that a second backend can be added without touching the code that
|
|
//! uses one (ARCH §8.1). Until this module existed that boundary was
|
|
//! documentation: seven files in `dr-ui` constructed a `NextcloudBackend`
|
|
//! directly and ten functions took one by concrete type, so the abstraction
|
|
//! bought nothing it was designed for and a WebDAV or local-folder backend
|
|
//! would have had nowhere to go.
|
|
//!
|
|
//! Everything above this module works through `&dyn RemoteBackend`, and every
|
|
//! account it opens is a [`dr_sync::Account`] — configuration with no server
|
|
//! in it. Adding a backend is implementing two traits and adding a line to
|
|
//! [`registry`]; nothing else in `dr-ui` changes. See `docs/storage.md`.
|
|
//!
|
|
//! # Why the registry is built here and not in `dr-sync`
|
|
//!
|
|
//! `dr-sync` must not depend on any connector, or the engine would drag a TLS
|
|
//! stack into a build that only wanted a folder. So the crate that already
|
|
//! depends on all of them — the interface — is where the list lives. It is
|
|
//! the only file in the application that names one.
|
|
|
|
use std::sync::{Arc, OnceLock};
|
|
|
|
use dr_sync::{Account, BackendProvider, BackendRegistry, Connection, RemoteBackend, RemoteError};
|
|
use dr_sync_folder::FolderProvider;
|
|
use dr_sync_nextcloud::{NextcloudProvider, NextcloudVfs};
|
|
|
|
/// Every storage backend this build has, in the order the launch screen
|
|
/// offers them.
|
|
///
|
|
/// Built once. A provider is stateless — it holds no connection and no
|
|
/// credential — so one instance serves every thread that asks.
|
|
pub fn registry() -> &'static BackendRegistry {
|
|
static REGISTRY: OnceLock<BackendRegistry> = OnceLock::new();
|
|
REGISTRY.get_or_init(|| {
|
|
let mut r = BackendRegistry::new();
|
|
r.register(Arc::new(NextcloudProvider));
|
|
// TRACES: FR-NC-6c
|
|
// The folder connector does the filesystem work and knows nothing
|
|
// about sync clients; the placeholder convention is supplied here,
|
|
// which is the one place that may name one. Detection is per folder
|
|
// and per connection — the same directory offers hydration while the
|
|
// client is running and not while it is down (ARCH §9.0).
|
|
r.register(Arc::new(FolderProvider::with_vfs_detector(|root| {
|
|
NextcloudVfs::looks_synced(root)
|
|
.then(|| Arc::new(NextcloudVfs::detect()) as Arc<dyn dr_sync_folder::Vfs>)
|
|
})));
|
|
r
|
|
})
|
|
}
|
|
|
|
/// The connector serving an account.
|
|
///
|
|
/// Errors when this build has none — a configuration file outlives the binary
|
|
/// that wrote it, and saying *which* backend is missing beats "could not open
|
|
/// library".
|
|
pub fn provider_for(account: &Account) -> Result<&'static Arc<dyn BackendProvider>, RemoteError> {
|
|
registry().for_account(account)
|
|
}
|
|
|
|
/// Whether an account's credential has to be fetched from secure storage.
|
|
///
|
|
/// Asked of the connector rather than inferred from the account, because an
|
|
/// empty login might be a folder library or might be a damaged record, and
|
|
/// guessing turns the second into a silent unauthenticated connection.
|
|
pub fn needs_secret(account: &Account) -> bool {
|
|
provider_for(account).is_ok_and(|p| p.sign_in().needs_secret())
|
|
}
|
|
|
|
/// Open a connection to a configured remote.
|
|
///
|
|
/// Returns the trait object every caller should hold. The error type is
|
|
/// `dr-sync`'s rather than a connector's, so a caller handles a failure
|
|
/// without learning which backend produced it.
|
|
pub(crate) fn connect(conn: &Connection) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
|
registry().connect(conn)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn both_backends_are_registered() {
|
|
// The list the launch screen offers. A backend missing from here is a
|
|
// backend the user cannot choose, however complete its connector is.
|
|
let ids: Vec<&str> = registry().providers().iter().map(|p| p.id()).collect();
|
|
assert!(ids.contains(&"nextcloud"), "{ids:?}");
|
|
assert!(ids.contains(&"folder"), "{ids:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_backend_with_a_login_wants_a_credential() {
|
|
assert!(needs_secret(&Account::new("nextcloud", "https://x")));
|
|
assert!(!needs_secret(&Account::new("folder", "/mnt/photos")));
|
|
}
|
|
|
|
#[test]
|
|
fn an_account_for_an_unknown_backend_names_it() {
|
|
let e = provider_for(&Account::new("s3", "bucket"))
|
|
.err()
|
|
.expect("no such connector")
|
|
.to_string();
|
|
assert!(e.contains("s3"), "{e}");
|
|
}
|
|
}
|