Read the library's sidecars, so a cull done elsewhere arrives

Judgements only ever travelled outward. A rating went to the catalog and to the
photograph's sidecar, the sidecar reached the server, and there it stopped: the
scan indexes files, `derived_sync` exchanges thumbnails, face shards and
collections, `dr_catalog::merge` reconciles everything in a catalog except
`versions.rating` and `versions.flag`, and the one sidecar reader that existed
ran when a single photograph was opened in develop and handed its answer to the
develop graph. `JobKind::ReadSidecar` was declared for exactly this when the job
queue was written and was never enqueued or handled anywhere.

The grid draws `versions.rating`. So a day of culling on the tablet could not
reach the laptop by any path the application had, and the laptop's catalog says
so plainly: 23,568 images, one of them judged.

`pull_sidecars` closes it, off the back of work the scan already does.
`dr_sync::scan` reports the `.drsc` files it meets in listings it was making
anyway — no extra request, and a directory whose ETag is unchanged is still
pruned before it is listed at all. A new `sidecars` table records the ETag of
each one this device has taken in, so the fetch is one GET per sidecar that
genuinely changed rather than one per photograph. A library nobody has edited
costs nothing.

The judgement is taken rather than maximised. The sidecar is the authoritative
store and the fuse has already settled any contest between devices on
`revision`, so lowering a rating from four to one on the tablet lowers it here —
taking the larger would have refused every demotion the photographer ever made,
which is most of what a second pass over a shoot is. A zero is the exception: it
means *never judged*, not "judged zero", so a sidecar carrying none cannot erase
a star this device holds. That is `merge_judgement`'s asymmetry and it carries
the same known cost — clearing a rating does not propagate.

A sidecar names a stem, so both halves of a RAW-and-JPEG pair are judged: they
are one photograph (FR-CAT-11) sharing one document, and judging only one of
them would leave the grid disagreeing with itself over which it drew. The `LIKE`
that finds them is a filter, not the decision — `sidecar_path` is applied to
every candidate, because a folder is entitled to contain a `%` and a rating
landing on the wrong frame would be silent and permanent.

Failing to read one is not a failure to scan: the ETag goes unrecorded, the
ratings already here stay where they are, and the next scan tries again. The
count is reported to the status line as well as the log, because a grid that
silently gains three hundred stars is indistinguishable from one that has gone
wrong — and because while this number was structurally zero there was nothing to
tell the photographer their cull had not arrived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 16:14:03 +02:00
co-authored by Claude Opus 5
parent ca2a135e28
commit 1ad35e2b87
5 changed files with 656 additions and 45 deletions
+49 -1
View File
@@ -15,7 +15,7 @@ use rusqlite::Connection;
use crate::error::CatalogError;
/// Schema version this build writes and understands.
pub const SCHEMA_VERSION: i64 = 12;
pub const SCHEMA_VERSION: i64 = 13;
/// Apply migrations up to [`SCHEMA_VERSION`].
///
@@ -112,6 +112,13 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
tx.commit()?;
}
if from < 13 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V13)?;
tx.pragma_update(None, "user_version", 13)?;
tx.commit()?;
}
Ok(from)
}
@@ -521,6 +528,47 @@ const V12: &str = r#"
DELETE FROM face_index WHERE source_edge <= 1024;
"#;
const V13: &str = r#"
-- TRACES: FR-CAT-8 | FR-NC-9
-- Which sidecars this device has read, and at what ETag.
--
-- The sidecar is the authoritative store for a rating and an edit, and until
-- this table existed nothing ever read one back into the catalog: judgements
-- travelled outward only. A cull done on a tablet reached the server and
-- stopped there, because the scan indexes photographs, the derived sync moves
-- thumbnails and collections, and the one reader that existed ran when a single
-- photograph was opened in develop and fed only the develop graph. The grid
-- draws `versions.rating`, so another device's afternoon of culling was
-- invisible on this one -- permanently, by every path the app had.
--
-- What this holds is the ETag, not the content. It is the record of what has
-- already been taken in, so a pull fetches only what changed: `dr_sync::scan`
-- reports every sidecar it saw in listings it was making anyway, and this
-- decides which of them are worth a GET.
--
-- Keyed on the sidecar's own remote path rather than on an image id. One
-- sidecar can describe two images -- a RAW and the JPEG beside it are one
-- photograph (FR-CAT-11) and share a document -- and a path is what the scan
-- reports and what a fetch addresses, so keying on anything else would mean
-- deriving one from the other in two places.
--
-- Rebuildable like the rest of the catalog: losing this table costs one pass
-- that re-reads every sidecar and reaches exactly the same state.
--
-- `IF NOT EXISTS` because NFR-R5 asks for migrations that are idempotent on
-- retry, and this one can genuinely be re-entered: a catalog whose
-- `user_version` was rewound -- by a rollback to an older build, or by a
-- recovery -- would otherwise fail its next open on a table it already has.
CREATE TABLE IF NOT EXISTS sidecars (
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
etag TEXT,
-- Unix seconds, for diagnosing a pull that is not making progress.
read_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(root_id, path)
);
"#;
const V9: &str = r#"
-- TRACES: FR-CULL-8
-- A record that face detection has *run* on an image, distinct from what it
+100
View File
@@ -28,11 +28,36 @@ pub struct ScanProgress {
pub images_found: usize,
}
/// TRACES: FR-CAT-8 | FR-NC-9
/// Extension of a DarkRoom sidecar, as it appears in a listing.
///
/// Mirrors `dr_pipeline::sidecar::EXTENSION`. Duplicated rather than shared for
/// the reason [`TRASH_DIR`] is duplicated in `dr_catalog`: this crate depends
/// on nothing above it, and one `const` is a smaller price than a dependency
/// on the whole edit format to recognise four characters in a filename.
pub const SIDECAR_EXTENSION: &str = "drsc";
/// The result of a scan.
#[derive(Debug, Clone, Default)]
pub struct ScanResult {
/// Files matching the format filter.
pub images: Vec<RemoteEntry>,
/// TRACES: FR-CAT-8 | FR-NC-9
/// Sidecars seen in the same listings, with the ETag they had.
///
/// **Collected here because it is free.** A sidecar is a file in the same
/// directory as the photograph it describes, so every one of them is
/// already in a `PROPFIND` response this walk has paid for. Discovering
/// them any other way — a probe per image — would be one request per
/// photograph over a link that may be mobile data, which is why the pull
/// did not exist at all before this.
///
/// The validator is what makes the pull incremental: a sidecar whose ETag
/// is unchanged since the last scan holds nothing this device has not
/// already read, and is not fetched. ETag pruning means an untouched
/// subtree is never even listed, so a library nobody has edited costs
/// nothing.
pub sidecars: Vec<RemoteEntry>,
/// Directories whose contents were **actually listed**, with the ETag
/// observed at that moment.
///
@@ -231,6 +256,10 @@ where
if filter.allows_name(entry.path.name()) {
result.images.push(entry);
result.progress.images_found += 1;
} else if is_sidecar(entry.path.name()) {
// Not counted in `images_found`: the progress figure is
// what the user is shown, and it means photographs.
result.sidecars.push(entry);
}
}
}
@@ -241,10 +270,22 @@ where
// Sort so a scan is reproducible and the grid has a stable order.
result.images.sort_by(|a, b| a.path.cmp(&b.path));
result.sidecars.sort_by(|a, b| a.path.cmp(&b.path));
result.directories.sort_by(|a, b| a.0.cmp(&b.0));
Ok(result)
}
/// Whether a filename is a DarkRoom sidecar.
///
/// Case-insensitive on the extension alone. A server that upper-cased the
/// suffix — or a file copied through a filesystem that did — still describes a
/// photograph, and failing to recognise it would silently lose the edit rather
/// than fail visibly.
fn is_sidecar(name: &str) -> bool {
name.rsplit_once('.')
.is_some_and(|(stem, ext)| !stem.is_empty() && ext.eq_ignore_ascii_case(SIDECAR_EXTENSION))
}
/// Whether pruning is worth attempting against this backend.
///
/// Only propagating ETags make an unchanged parent prove an unchanged
@@ -867,4 +908,63 @@ mod tests {
// the root plus its two children.
assert_eq!(r.directories.len(), 3);
}
/// TRACES: FR-CAT-8 | FR-NC-9
/// Sidecars are reported, so a judgement made elsewhere can be read back.
///
/// They are already in every listing the walk pays for; collecting them
/// costs no request. Discovering them any other way would be a probe per
/// photograph, which is why nothing read them at all before this.
#[tokio::test]
async fn sidecars_are_collected_from_the_listings_the_walk_already_makes() {
let mut b = FakeBackend::sample(ChangeDetection::PropagatingEtags);
b.tree.insert(
"Photos/2025".into(),
vec![
file("Photos/2025/a.CR2"),
file("Photos/2025/a.drsc"),
file("Photos/2025/b.jpg"),
// Upper-cased by a filesystem somewhere along the way; still a
// sidecar, and losing it would lose the edit silently.
file("Photos/2025/b.DRSC"),
],
);
let before = *b.lists.borrow();
let r = scan(
&b,
&RemotePath::new("Photos"),
&FormatFilter::all(),
&HashMap::new(),
|_| {},
)
.await
.unwrap();
assert_eq!(
r.sidecars
.iter()
.map(|e| e.path.as_str().to_string())
.collect::<Vec<_>>(),
vec!["Photos/2025/a.drsc", "Photos/2025/b.DRSC"]
);
assert_eq!(*b.lists.borrow() - before, 3, "no extra requests");
// And they are not photographs: the count the user is shown must not
// double because a library has been edited.
assert_eq!(r.progress.images_found, 3);
assert!(r.images.iter().all(|e| !e.path.name().contains("drsc")));
}
/// A file whose *name* is only an extension is not a sidecar for anything.
#[test]
fn a_bare_extension_is_not_a_sidecar() {
assert!(is_sidecar("a.drsc"));
assert!(is_sidecar("a.DRSC"));
assert!(is_sidecar("a.b.drsc"));
assert!(!is_sidecar(".drsc"));
assert!(!is_sidecar("drsc"));
assert!(!is_sidecar("a.drsc.tmp"));
assert!(!is_sidecar("a.CR2"));
}
}