Make storage pluggable, and prove it with a folder backend
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.
A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:
- **Capabilities** — already there, and the reason the engine can drive
two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
whatever form its connector addresses, with no server in it. Loads
every existing config unchanged (`backend` defaults to `nextcloud`,
`endpoint` is stored under its historical `server` key), and
`Account::namespace()` reproduces the old catalog directory byte for
byte, because changing it would abandon a catalog, its thumbnail
shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
`ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
names a connector.
`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.
Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.
`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.
docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
This commit is contained in:
@@ -1167,16 +1167,11 @@ fn start_trash(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<CollectionsController>,
|
||||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||||
session: &Rc<
|
||||
dyn Fn() -> Option<(
|
||||
dr_sync_nextcloud::AppCredentials,
|
||||
dr_sync_nextcloud::Session,
|
||||
)>,
|
||||
>,
|
||||
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
|
||||
images: &[ImageId],
|
||||
reload: &Rc<dyn Fn()>,
|
||||
) {
|
||||
let Some((creds, sess)) = session() else {
|
||||
let Some(conn) = session() else {
|
||||
window.set_collection_error("Open a library first.".into());
|
||||
return;
|
||||
};
|
||||
@@ -1184,7 +1179,7 @@ fn start_trash(
|
||||
let moves = {
|
||||
let borrow = catalog.borrow();
|
||||
let Some(cat) = borrow.as_ref() else { return };
|
||||
match crate::trash::plan_trash(cat, &sess.root, images) {
|
||||
match crate::trash::plan_trash(cat, &conn.account.root, images) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
window.set_collection_error(format!("planning delete: {e}").into());
|
||||
@@ -1205,11 +1200,10 @@ fn start_trash(
|
||||
|
||||
let count = moves.len();
|
||||
let rx = crate::trash::spawn_move(
|
||||
creds,
|
||||
sess.user_id.clone(),
|
||||
conn.clone(),
|
||||
moves,
|
||||
crate::trash::Direction::ToTrash,
|
||||
crate::library::catalog_path(&sess.server, &sess.user_id),
|
||||
crate::library::catalog_path(&conn.account),
|
||||
);
|
||||
|
||||
drain_trash(
|
||||
@@ -1239,16 +1233,11 @@ fn start_restore(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<CollectionsController>,
|
||||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||||
session: &Rc<
|
||||
dyn Fn() -> Option<(
|
||||
dr_sync_nextcloud::AppCredentials,
|
||||
dr_sync_nextcloud::Session,
|
||||
)>,
|
||||
>,
|
||||
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
|
||||
images: &[ImageId],
|
||||
reload: &Rc<dyn Fn()>,
|
||||
) {
|
||||
let Some((creds, sess)) = session() else {
|
||||
let Some(conn) = session() else {
|
||||
window.set_collection_error("Open a library first.".into());
|
||||
return;
|
||||
};
|
||||
@@ -1282,11 +1271,10 @@ fn start_restore(
|
||||
|
||||
let count = moves.len();
|
||||
let rx = crate::trash::spawn_move(
|
||||
creds,
|
||||
sess.user_id.clone(),
|
||||
conn.clone(),
|
||||
moves,
|
||||
crate::trash::Direction::Restore,
|
||||
crate::library::catalog_path(&sess.server, &sess.user_id),
|
||||
crate::library::catalog_path(&conn.account),
|
||||
);
|
||||
|
||||
drain_trash(
|
||||
@@ -1466,10 +1454,7 @@ pub fn wire<S, R, P, C>(
|
||||
S: Fn() + 'static,
|
||||
R: Fn() -> Vec<ImageId> + 'static,
|
||||
P: Fn(usize, usize) -> Vec<ImageId> + 'static,
|
||||
C: Fn() -> Option<(
|
||||
dr_sync_nextcloud::AppCredentials,
|
||||
dr_sync_nextcloud::Session,
|
||||
)> + 'static,
|
||||
C: Fn() -> Option<dr_sync::Connection> + 'static,
|
||||
{
|
||||
// Coerced to trait objects here rather than at each use: `start_trash` and
|
||||
// `drain_trash` are shared by three callbacks, and a generic parameter would
|
||||
@@ -1479,12 +1464,7 @@ pub fn wire<S, R, P, C>(
|
||||
// A shift-click asks the catalog what lies between its two ends, and the
|
||||
// catalog belongs to the grid's controller — see `span_source`.
|
||||
*ctl.span_source.borrow_mut() = Some(Rc::new(span_ids));
|
||||
let session: Rc<
|
||||
dyn Fn() -> Option<(
|
||||
dr_sync_nextcloud::AppCredentials,
|
||||
dr_sync_nextcloud::Session,
|
||||
)>,
|
||||
> = Rc::new(session);
|
||||
let session: Rc<dyn Fn() -> Option<dr_sync::Connection>> = Rc::new(session);
|
||||
|
||||
// --- selection ---------------------------------------------------------
|
||||
{
|
||||
@@ -2098,8 +2078,8 @@ pub fn wire<S, R, P, C>(
|
||||
window.on_trash_empty(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
let (creds, sess) = match session() {
|
||||
Some(s) => s,
|
||||
let conn = match session() {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
@@ -2131,12 +2111,11 @@ pub fn wire<S, R, P, C>(
|
||||
let count = ids.len();
|
||||
|
||||
let rx = crate::trash::spawn_purge(
|
||||
creds,
|
||||
sess.user_id.clone(),
|
||||
conn.clone(),
|
||||
ids,
|
||||
paths,
|
||||
crate::library::catalog_path(&sess.server, &sess.user_id),
|
||||
crate::library::thumbs_dir(&sess.server, &sess.user_id),
|
||||
crate::library::catalog_path(&conn.account),
|
||||
crate::library::thumbs_dir(&conn.account),
|
||||
);
|
||||
|
||||
drain_trash(
|
||||
|
||||
Reference in New Issue
Block a user