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
+19
View File
@@ -647,6 +647,25 @@ proxies, then thumbnails. **Metadata and sidecars are never evicted** — they a
extension, and size, so users do not know what they actually have. Sync failures of legibility are
more damaging than failures of transport.
**FR-NC-6d — Placeholder libraries.** Where a library is a folder kept by a sync client in
virtual-files mode, the app shall treat a placeholder as *the photograph, not downloaded* — never as
a one-byte file and never as a missing one.
- A placeholder is catalogued under the photograph's own name, with an identity that does not change
when it is downloaded
- Reading one yields a distinct, actionable error; it shall **not** be reported as absent, because
the sidecar writer creates a new document when a sidecar is absent and would discard the existing
one (FR-CAT-8)
- Its size is reported as unknown rather than as the stub's byte count
Where the client offers hydration, content may be fetched **as a borrow**: a file is returned to the
state it was found in, so a pass releases what it downloaded and leaves alone what the user already
had. Releasing means asking the client to dehydrate — **never deleting**, which inside a synced tree
would propagate to the server and remove the photograph everywhere.
Hydration is whole-file and shall never serve browsing (ARCH §9.0 finding 3, §9.0a). It is for the
originals tier and for passes the user has been quoted a cost on and has agreed to.
**FR-NC-7 — Upload.** Files above 5MB use **chunked upload v2** against
`/remote.php/dav/uploads/<userid>/`: `MKCOL` to create the upload folder, `PUT` each chunk, then
`MOVE` the `.file` pseudo-entry to the destination. Chunks are 5MB–5GB and named 1–10000.