Treat a placeholder as the photograph, not as a one-byte file

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.
This commit is contained in:
2026-08-29 09:57:52 +02:00
parent 6c363cee97
commit c102ba9df2
22 changed files with 1555 additions and 53 deletions
+31 -2
View File
@@ -25,7 +25,7 @@ use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender};
use dr_catalog::{Catalog, JobKind, Priority};
use dr_sync::{Account, Connection, RemoteBackend, RemoteId, RemotePath};
use dr_sync::{Account, Connection, RemoteBackend, RemoteError, RemoteId, RemotePath};
use dr_thumbs::ThumbStore;
use crate::sidecar_cache::SidecarCache;
@@ -944,7 +944,35 @@ async fn drain_one(
let path = RemotePath::new(path_str.to_string());
let id = RemoteId::Path(path.clone());
let remote = backend.get(&id, None).await.ok();
// TRACES: FR-NC-6c
// A miss and a placeholder are not the same answer, and conflating them
// destroys work. This read decides whether the sidecar already on the
// remote is merged in; treating "the content is not on this device" as
// "there is no sidecar" writes a fresh document over an existing one and
// discards every edit another device put there — the exact loss the
// format's unknown-key preservation exists to prevent.
//
// A sidecar is a few kilobytes, so the right response to a placeholder is
// to fetch it, not to give up. Where that is impossible — no client
// running — the entry stays queued, which is what the outbox is for.
let remote = match backend.get(&id, None).await {
Ok(bytes) => Some(bytes),
Err(RemoteError::NotFound(_)) => None,
Err(RemoteError::NotMaterialised(_)) => {
backend
.materialise(&id)
.await
.map_err(|e| format!("sidecar is not on this device ({e})"))?;
match backend.get(&id, None).await {
Ok(bytes) => Some(bytes),
Err(e) => return Err(format!("sidecar could not be read ({e})")),
}
}
// Anything else — a refused read, a dead connection — leaves the entry
// queued rather than resolved by overwriting.
Err(e) => return Err(format!("sidecar could not be read ({e})")),
};
if let Some(bytes) = remote.as_deref() {
if !bytes.is_empty() {
@@ -5440,6 +5468,7 @@ mod tests {
size,
modified: None,
has_preview: false,
materialised: true,
}
}
+11 -2
View File
@@ -25,7 +25,7 @@ use std::sync::{Arc, OnceLock};
use dr_sync::{Account, BackendProvider, BackendRegistry, Connection, RemoteBackend, RemoteError};
use dr_sync_folder::FolderProvider;
use dr_sync_nextcloud::NextcloudProvider;
use dr_sync_nextcloud::{NextcloudProvider, NextcloudVfs};
/// Every storage backend this build has, in the order the launch screen
/// offers them.
@@ -37,7 +37,16 @@ pub fn registry() -> &'static BackendRegistry {
REGISTRY.get_or_init(|| {
let mut r = BackendRegistry::new();
r.register(Arc::new(NextcloudProvider));
r.register(Arc::new(FolderProvider));
// 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
})
}