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:
2026-08-29 09:57:52 +02:00
parent 1b8b7998a2
commit f12aece07e
40 changed files with 3617 additions and 960 deletions
+25 -3
View File
@@ -731,9 +731,16 @@ returns.
WebDAV `SEARCH` (RFC 5323) against `/remote.php/dav/` filtered by mimetype and paginated via
`d:limit`/`d:nresults`, in preference to walking thousands of folders with PROPFIND.
**FR-NC-12 — Backend independence.** Sync shall be implemented against a backend interface, with
Nextcloud as the only implementation in v1. No protocol detail specific to Nextcloud may appear
outside its connector.
**FR-NC-12 — Backend independence.** Sync shall be implemented against a backend interface. No
protocol detail specific to any one backend may appear outside its connector, and no layer above
the interface may name a connector — with the single exception of the registry that constructs them
(`dr_ui::remote`).
A trait over operations is not sufficient on its own, and the first release proved it: `dr-ui`
constructed the Nextcloud backend directly in seven files, an account *was* a server URL beside a
DAV user id, and the local cache directory was named after a hostname. Independence requires four
things — operations, declared capabilities, an account model with no server in it, and a
registration mechanism (ARCH §8.0, `docs/storage.md`).
Backends **declare capabilities** rather than conforming to a lowest common denominator, because
the property that makes Nextcloud sync fast — directory ETags propagating up the tree, so an
@@ -748,6 +755,21 @@ Where a capability is absent the app shall **degrade visibly, not silently**:
- Without conditional writes, sidecar conflict detection falls back to revision comparison, which
narrows but does not close the race; this is surfaced as a reduced-safety mode
**FR-NC-13 — Folder libraries.** A library shall be openable as a **plain directory** — a local
disk, a network mount, an external drive, or a folder another client already syncs — with no
account, no server and no credential.
This is a requirement rather than a convenience for three reasons. It is what a photographer with
an archive drive and no server actually has. It is the only route that works where no secrets
daemon exists, which FR-NC-2 otherwise treats as a degraded mode. And a second connector is the
only way to keep FR-NC-12 honest: an interface with one implementation cannot be shown to be an
interface.
The folder connector shall declare its capabilities truthfully rather than flatteringly — in
particular it shall **not** claim propagating directory ETags, because a POSIX directory's mtime
describes its own entry list and nothing beneath it, and a backend that claimed otherwise would
hide edits rather than merely run slowly (ARCH §8.4a).
### 3.8 Platform integration
#### Android