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:
+74
-6
@@ -56,8 +56,9 @@ darkroom/
|
||||
│ ├── dr-gpu wgpu device, tile scheduler, WGSL shaders, mask rasteriser
|
||||
│ ├── dr-colour lcms2 bindings, camera profiles, working-space transforms
|
||||
│ ├── dr-export encoders, resampling, output sizing
|
||||
│ ├── dr-sync RemoteBackend trait, sync engine, cache rules, merge
|
||||
│ └── dr-sync-nextcloud the only backend implementation (§8.4)
|
||||
│ ├── dr-sync RemoteBackend + BackendProvider, Account, sync engine, merge
|
||||
│ ├── dr-sync-nextcloud WebDAV, oc:fileid, chunked v2, Login Flow v2 (§8.4)
|
||||
│ └── dr-sync-folder a plain directory: disk, mount, synced folder (§8.4a)
|
||||
├── ui/
|
||||
│ ├── dr-ui Slint components, adaptive layout, descriptor→control mapping
|
||||
│ └── dr-widgets custom controls per WidgetKind (curve, wheel, crop, brush)
|
||||
@@ -572,9 +573,34 @@ recovery. Panics in decode are caught at the boundary, since RAW parsing handles
|
||||
|
||||
## 8. Sync architecture
|
||||
|
||||
Sync is pluggable. `dr-sync` defines a `RemoteBackend` trait; **only the Nextcloud connector is
|
||||
implemented**, but the boundary is designed so S3, generic WebDAV, or a self-hosted photo server can
|
||||
be added without touching the sync engine.
|
||||
Sync is pluggable. `dr-sync` defines a `RemoteBackend` trait and the capability model the engine
|
||||
adapts to; two connectors implement it — `dr-sync-nextcloud` and `dr-sync-folder` — and S3, generic
|
||||
WebDAV, or a self-hosted photo server can be added without touching the engine.
|
||||
|
||||
**`docs/storage.md` is the contract**: the four traits a connector meets, the four steps to add one,
|
||||
and what each shipped connector actually declares. This section says *why* the seam is shaped the
|
||||
way it is; that document says how to use it.
|
||||
|
||||
### 8.0 A trait is not a seam
|
||||
|
||||
Worth stating because this was got wrong for a release. `RemoteBackend` existed from the start and
|
||||
the code above it still knew it was talking to Nextcloud: seven files in `dr-ui` constructed a
|
||||
`NextcloudBackend` directly, ten functions took one by concrete type, an account *was* a server URL
|
||||
beside a DAV user id, and the local cache directory was named after a hostname. The abstraction was
|
||||
real and bought nothing.
|
||||
|
||||
Pluggable storage needs four things, and only the first is a trait over operations:
|
||||
|
||||
| | What | Where |
|
||||
|---|---|---|
|
||||
| 1 | Operations | `RemoteBackend` (§8.3) |
|
||||
| 2 | Capabilities — what is *cheap*, so the engine adapts rather than assumes | `Capabilities` (§8.1) |
|
||||
| 3 | Configuration — what an account is, with no server in it | `Account`, `Connection` |
|
||||
| 4 | Registration — how a connector is discovered without being named | `BackendProvider`, `BackendRegistry` |
|
||||
|
||||
`ui/dr-ui/src/remote.rs` is the only file above `dr-sync` that names a connector. `dr-sync` itself
|
||||
depends on none of them, so a build that only wants a folder library does not compile a TLS stack to
|
||||
get one.
|
||||
|
||||
### 8.1 Why capability negotiation, not a common denominator
|
||||
|
||||
@@ -702,7 +728,8 @@ Design notes worth keeping:
|
||||
|
||||
### 8.4 The Nextcloud connector
|
||||
|
||||
The only implementation. Mapping to the trait:
|
||||
The reference implementation, and the one whose peculiarities the capability model exists to keep.
|
||||
Mapping to the trait:
|
||||
|
||||
| Trait method | Nextcloud |
|
||||
|---|---|
|
||||
@@ -739,6 +766,47 @@ never `Depth: infinity`. Two implementation details worth keeping:
|
||||
and proves nothing about children, so it is pure overhead; a test asserts zero probes in that
|
||||
case. Only `PropagatingEtags` makes an unchanged parent prove an unchanged subtree.
|
||||
|
||||
### 8.4a The folder connector
|
||||
|
||||
A plain directory: a local disk, a network mount, an external drive, or the folder a Nextcloud
|
||||
desktop client already syncs. No server, no account, no credential — which makes it the route that
|
||||
works on a machine with no secrets daemon.
|
||||
|
||||
It exists for two reasons. It is genuinely useful, and a second implementation is the only way to
|
||||
find out whether the first was an abstraction or a description. Adding it is what turned §8.0's four
|
||||
items from a claim into a fact.
|
||||
|
||||
| Trait method | Folder |
|
||||
|---|---|
|
||||
| `capabilities` | `LocalEtags`, **no** stable ids, ranges, no chunking, conditional |
|
||||
| `list` | `read_dir` + `metadata`; validator is `size`-`mtime` |
|
||||
| `dir_validator` | `Unsupported` — a directory's mtime does not propagate |
|
||||
| `delta` | `Unsupported` — a folder keeps no change feed |
|
||||
| `get` + range | `seek` + `take`; a short read past the end is not an error |
|
||||
| `put` | write to a temporary beside the destination, `rename` over it |
|
||||
| `put` `IfAbsent` | `O_CREAT | O_EXCL` — genuinely atomic |
|
||||
| `put` `IfMatch` | `stat`, compare, then the same rename — narrows the race, does not close it |
|
||||
| `delete` | files, and *empty* directories only — see below |
|
||||
| `move_to` | `rename`, falling back to copy + unlink across a mount boundary |
|
||||
| auth | none |
|
||||
|
||||
Three things worth carrying forward:
|
||||
|
||||
- **`LocalEtags` is the honest answer, and it costs nothing.** A POSIX directory's mtime describes
|
||||
its own entry list and nothing below it, so there is no propagation to exploit and the engine
|
||||
walks the tree every scan. The walk that was expensive was expensive because it was 50k
|
||||
`PROPFIND`s; 50k `stat` calls take well under a second. This is the capability model paying for
|
||||
itself — one engine, two backends, each running at the speed it actually runs at.
|
||||
- **Identity is a path hash, not an inode.** An inode is stable across a rename but differs between
|
||||
devices and is reused after a delete, so two machines would disagree about which photograph a
|
||||
thumbnail belonged to and a recycled inode would attach an old thumbnail to a new image.
|
||||
Re-deriving a thumbnail is a cost; showing the wrong one is a bug. `stable_ids: false` reports the
|
||||
consequence.
|
||||
- **`delete` is deliberately not recursive**, unlike WebDAV's `DELETE` on a collection. There is no
|
||||
server-side trash behind a local folder, so a caller with a wrong path would have no way back.
|
||||
Nothing in the engine deletes a directory — the soft delete is a `move_to` (§FR-CAT-15) — so the
|
||||
guard is free.
|
||||
|
||||
### 8.5 Sidecar conflict resolution
|
||||
|
||||
`put` with `Precondition::IfMatch(validator)`. On precondition failure:
|
||||
|
||||
Reference in New Issue
Block a user