Merge master: pluggable storage, and a name that anchors

Conflicts were docs/traceability.md alone, and it is generated — so it
was regenerated rather than hand-merged. dr-face was untouched on the
other side; ui/dr-ui/src/faces.rs and identity_ui.rs auto-merged, the
first around recluster's anchoring and the second around load_faces.

Worth recording because the two branches met on the same problem from
different ends. Master's "Let a name hold a group together" is the fix
for the sixteen Catherines — fourteen of them empty — that this branch
found while measuring the library and reported without fixing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 12:30:01 +02:00
co-authored by Claude Opus 5
54 changed files with 6173 additions and 986 deletions
+50
View File
@@ -222,6 +222,56 @@ impl Cache {
Ok(())
}
/// TRACES: FR-NC-6c | FR-NC-6a
/// Record an original this cache does **not** own the bytes of.
///
/// The virtual-filesystem case. On a library kept by a sync client the
/// original is materialised *in the library folder itself*, so copying it
/// under `originals/` would hold two copies of every pinned photograph —
/// and the copy would be the one the budget could evict while the real
/// disk cost stayed.
///
/// So the bytes are left where they are and only the bookkeeping is kept.
/// `path` is deliberately `NULL`, which is what makes this safe:
/// [`release`](Self::release) deletes the file a row names, and a row that
/// names none deletes nothing. **That matters more than it sounds.**
/// Deleting a materialised file inside a synced folder does not free a
/// cache — it deletes the photograph, and the client propagates that to
/// the server and to every other device. Handing the disk back is the
/// backend's job (`RemoteBackend::dematerialise`), not this one's.
///
/// `bytes` is what the original occupies where it lies, for the budget and
/// for reporting; pass 0 where it is not known.
pub fn record_in_place(
&self,
conn: &Connection,
image: ImageId,
bytes: u64,
pinned: bool,
now: i64,
) -> Result<(), CatalogError> {
conn.execute(
"INSERT INTO image_cache
(image_id, tier_actual, tier_desired, bytes, last_used, pinned, path)
VALUES (?1, ?2, ?2, ?3, ?4, ?5, NULL)
ON CONFLICT(image_id) DO UPDATE SET
tier_actual = ?2,
tier_desired = max(tier_desired, ?2),
bytes = ?3,
last_used = ?4,
pinned = max(pinned, ?5),
path = NULL",
rusqlite::params![
image.0 as i64,
Tier::Original.stored(),
bytes as i64,
now,
i64::from(pinned),
],
)?;
Ok(())
}
/// Read a cached original back, if it is here.
///
/// Touches `last_used`, which is what makes the eviction order reflect