Keep three generations of the catalog on the server

The server held one copy of the catalog, overwritten in place on every
push. When that copy was damaged there were two answers, both bad:
refuse to touch it for ever, which is what every device did for a week,
or overwrite it with ours, which loses whatever another device had
added since — the escape hatch of the previous commit.

A push now uploads to catalog.upload.sqlite, rotates catalog.sqlite to
.1 (and .1 to .2, .2 to .3, dropping the oldest), and moves the upload
into place. Rotation is server-side renames, oldest first so that every
destination is empty when it is written to — move_to refuses to
overwrite, by design — and a failure at any step leaves a gap in the
generations and never a missing current copy. The only transfer is the
upload itself.

A damaged current copy that arrived whole now merges from the newest
readable generation before ours goes over it, which loses nothing, and
is kept as .1 by the ordinary rotation rather than by a separate 40 MB
upload. NFR-R2 asks for the catalog to be backed up; this is the half
of it that lives with the copy other devices read.
This commit is contained in:
2026-09-13 19:31:58 +02:00
parent 6f62ac09f8
commit 2ce0fcc74a
2 changed files with 372 additions and 137 deletions
+5 -5
View File
@@ -10,7 +10,7 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
| Metric | Value |
|---|---|
| Source files scanned | 354 |
| TRACES tags found | 1472 |
| TRACES tags found | 1474 |
| Requirements defined | 191 |
| Requirements covered | 140 |
| **Coverage** | **73.3%** (140/191) |
@@ -106,13 +106,13 @@ _None._
| FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1296`](../core/dr-catalog/src/schema.rs#L1296), [`core/dr-catalog/src/schema.rs:895`](../core/dr-catalog/src/schema.rs#L895), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2940`](../ui/dr-ui/src/collections_ui.rs#L2940), [`ui/dr-ui/src/collections_ui.rs:3182`](../ui/dr-ui/src/collections_ui.rs#L3182), [`ui/dr-ui/src/collections_ui.rs:4083`](../ui/dr-ui/src/collections_ui.rs#L4083), [`ui/dr-ui/src/collections_ui.rs:785`](../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../ui/dr-ui/src/collections_ui.rs#L856), [`ui/dr-ui/src/lib.rs:2404`](../ui/dr-ui/src/lib.rs#L2404), [`ui/dr-ui/src/lib.rs:3762`](../ui/dr-ui/src/lib.rs#L3762), [`ui/dr-ui/src/library.rs:2109`](../ui/dr-ui/src/library.rs#L2109), [`ui/dr-ui/src/library.rs:2132`](../ui/dr-ui/src/library.rs#L2132), [`ui/dr-ui/src/library.rs:2407`](../ui/dr-ui/src/library.rs#L2407), [`ui/dr-ui/src/library_ui.rs:1445`](../ui/dr-ui/src/library_ui.rs#L1445), [`ui/dr-ui/src/library_ui.rs:1518`](../ui/dr-ui/src/library_ui.rs#L1518), [`ui/dr-ui/src/library_ui.rs:1619`](../ui/dr-ui/src/library_ui.rs#L1619), [`ui/dr-ui/src/library_ui.rs:1674`](../ui/dr-ui/src/library_ui.rs#L1674), [`ui/dr-ui/src/library_ui.rs:1809`](../ui/dr-ui/src/library_ui.rs#L1809), [`ui/dr-ui/src/library_ui.rs:1927`](../ui/dr-ui/src/library_ui.rs#L1927), [`ui/dr-ui/src/library_ui.rs:2694`](../ui/dr-ui/src/library_ui.rs#L2694), [`ui/dr-ui/src/library_ui.rs:344`](../ui/dr-ui/src/library_ui.rs#L344), [`ui/dr-ui/src/library_ui.rs:355`](../ui/dr-ui/src/library_ui.rs#L355), [`ui/dr-ui/src/library_ui.rs:370`](../ui/dr-ui/src/library_ui.rs#L370), [`ui/dr-ui/src/library_ui.rs:379`](../ui/dr-ui/src/library_ui.rs#L379), [`ui/dr-ui/src/library_ui.rs:509`](../ui/dr-ui/src/library_ui.rs#L509), [`ui/dr-ui/src/library_ui.rs:524`](../ui/dr-ui/src/library_ui.rs#L524), [`ui/dr-ui/src/library_ui.rs:571`](../ui/dr-ui/src/library_ui.rs#L571), [`ui/dr-ui/src/library_ui.rs:634`](../ui/dr-ui/src/library_ui.rs#L634), [`ui/dr-ui/src/library_ui.rs:665`](../ui/dr-ui/src/library_ui.rs#L665), [`ui/dr-ui/src/library_ui.rs:677`](../ui/dr-ui/src/library_ui.rs#L677), [`ui/dr-ui/src/library_ui.rs:6811`](../ui/dr-ui/src/library_ui.rs#L6811), [`ui/dr-ui/src/library_ui.rs:6829`](../ui/dr-ui/src/library_ui.rs#L6829), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:1518`](../ui/dr-ui/ui/app.slint#L1518), [`ui/dr-ui/ui/app.slint:3067`](../ui/dr-ui/ui/app.slint#L3067), [`ui/dr-ui/ui/app.slint:535`](../ui/dr-ui/ui/app.slint#L535), [`ui/dr-ui/ui/app.slint:543`](../ui/dr-ui/ui/app.slint#L543), [`ui/dr-ui/ui/app.slint:549`](../ui/dr-ui/ui/app.slint#L549), [`ui/dr-ui/ui/collections.slint:1037`](../ui/dr-ui/ui/collections.slint#L1037), [`ui/dr-ui/ui/collections.slint:1048`](../ui/dr-ui/ui/collections.slint#L1048), [`ui/dr-ui/ui/collections.slint:1127`](../ui/dr-ui/ui/collections.slint#L1127), [`ui/dr-ui/ui/collections.slint:272`](../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:424`](../ui/dr-ui/ui/collections.slint#L424), [`ui/dr-ui/ui/collections.slint:433`](../ui/dr-ui/ui/collections.slint#L433), [`ui/dr-ui/ui/collections.slint:436`](../ui/dr-ui/ui/collections.slint#L436), [`ui/dr-ui/ui/collections.slint:482`](../ui/dr-ui/ui/collections.slint#L482), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:720`](../ui/dr-ui/ui/collections.slint#L720), [`ui/dr-ui/ui/collections.slint:877`](../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/collections.slint:91`](../ui/dr-ui/ui/collections.slint#L91), [`ui/dr-ui/ui/icons.slint:262`](../ui/dr-ui/ui/icons.slint#L262) |
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1674`](../ui/dr-ui/src/library_ui.rs#L1674), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) |
| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:172`](../core/dr-sync-nextcloud/src/desktop_client.rs#L172), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:122`](../core/dr-types/src/lib.rs#L122), [`core/dr-types/src/lib.rs:204`](../core/dr-types/src/lib.rs#L204), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:4083`](../ui/dr-ui/src/collections_ui.rs#L4083), [`ui/dr-ui/src/collections_ui.rs:785`](../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../ui/dr-ui/src/collections_ui.rs#L856), [`ui/dr-ui/src/derived_sync.rs:614`](../ui/dr-ui/src/derived_sync.rs#L614), [`ui/dr-ui/src/derived_sync.rs:850`](../ui/dr-ui/src/derived_sync.rs#L850), [`ui/dr-ui/src/library.rs:1020`](../ui/dr-ui/src/library.rs#L1020), [`ui/dr-ui/src/library.rs:2229`](../ui/dr-ui/src/library.rs#L2229), [`ui/dr-ui/src/library.rs:2319`](../ui/dr-ui/src/library.rs#L2319), [`ui/dr-ui/src/library.rs:3889`](../ui/dr-ui/src/library.rs#L3889), [`ui/dr-ui/src/library.rs:4398`](../ui/dr-ui/src/library.rs#L4398), [`ui/dr-ui/src/library_ui.rs:1445`](../ui/dr-ui/src/library_ui.rs#L1445), [`ui/dr-ui/src/library_ui.rs:1518`](../ui/dr-ui/src/library_ui.rs#L1518), [`ui/dr-ui/src/library_ui.rs:1717`](../ui/dr-ui/src/library_ui.rs#L1717), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/ui/collections.slint:272`](../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:877`](../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/icons.slint:262`](../ui/dr-ui/ui/icons.slint#L262) |
| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:172`](../core/dr-sync-nextcloud/src/desktop_client.rs#L172), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:122`](../core/dr-types/src/lib.rs#L122), [`core/dr-types/src/lib.rs:204`](../core/dr-types/src/lib.rs#L204), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:4083`](../ui/dr-ui/src/collections_ui.rs#L4083), [`ui/dr-ui/src/collections_ui.rs:785`](../ui/dr-ui/src/collections_ui.rs#L785), [`ui/dr-ui/src/collections_ui.rs:856`](../ui/dr-ui/src/collections_ui.rs#L856), [`ui/dr-ui/src/derived_sync.rs:1002`](../ui/dr-ui/src/derived_sync.rs#L1002), [`ui/dr-ui/src/derived_sync.rs:631`](../ui/dr-ui/src/derived_sync.rs#L631), [`ui/dr-ui/src/library.rs:1020`](../ui/dr-ui/src/library.rs#L1020), [`ui/dr-ui/src/library.rs:2229`](../ui/dr-ui/src/library.rs#L2229), [`ui/dr-ui/src/library.rs:2319`](../ui/dr-ui/src/library.rs#L2319), [`ui/dr-ui/src/library.rs:3889`](../ui/dr-ui/src/library.rs#L3889), [`ui/dr-ui/src/library.rs:4398`](../ui/dr-ui/src/library.rs#L4398), [`ui/dr-ui/src/library_ui.rs:1445`](../ui/dr-ui/src/library_ui.rs#L1445), [`ui/dr-ui/src/library_ui.rs:1518`](../ui/dr-ui/src/library_ui.rs#L1518), [`ui/dr-ui/src/library_ui.rs:1717`](../ui/dr-ui/src/library_ui.rs#L1717), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/ui/collections.slint:272`](../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:877`](../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/icons.slint:262`](../ui/dr-ui/ui/icons.slint#L262) |
| FR-NC-6d | [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1) |
| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:105`](../core/dr-sync-nextcloud/src/lib.rs#L105), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:4278`](../ui/dr-ui/src/library.rs#L4278), [`ui/dr-ui/src/library_ui.rs:4463`](../ui/dr-ui/src/library_ui.rs#L4463), [`ui/dr-ui/ui/settings.slint:420`](../ui/dr-ui/ui/settings.slint#L420) |
| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:309`](../core/dr-types/src/settings.rs#L309), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1508`](../ui/dr-ui/src/lib.rs#L1508), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1508`](../ui/dr-ui/src/lib.rs#L1508) |
| FR-NC-8 | [`core/dr-catalog/src/rating.rs:204`](../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:929`](../core/dr-catalog/src/rating.rs#L929), [`core/dr-catalog/src/schema.rs:179`](../core/dr-catalog/src/schema.rs#L179), [`core/dr-pipeline/src/sidecar.rs:122`](../core/dr-pipeline/src/sidecar.rs#L122), [`core/dr-pipeline/src/sidecar.rs:2944`](../core/dr-pipeline/src/sidecar.rs#L2944), [`core/dr-pipeline/src/sidecar.rs:645`](../core/dr-pipeline/src/sidecar.rs#L645), [`core/dr-pipeline/src/sidecar.rs:720`](../core/dr-pipeline/src/sidecar.rs#L720), [`core/dr-pipeline/src/sidecar.rs:96`](../core/dr-pipeline/src/sidecar.rs#L96), [`ui/dr-ui/src/lib.rs:2374`](../ui/dr-ui/src/lib.rs#L2374), [`ui/dr-ui/src/library.rs:1062`](../ui/dr-ui/src/library.rs#L1062), [`ui/dr-ui/src/library.rs:2538`](../ui/dr-ui/src/library.rs#L2538), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500), [`ui/dr-ui/src/library.rs:760`](../ui/dr-ui/src/library.rs#L760), [`ui/dr-ui/src/library.rs:7751`](../ui/dr-ui/src/library.rs#L7751), [`ui/dr-ui/src/library_ui.rs:586`](../ui/dr-ui/src/library_ui.rs#L586), [`ui/dr-ui/src/presets.rs:288`](../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:336`](../ui/dr-ui/src/presets.rs#L336) |
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:204`](../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:929`](../core/dr-catalog/src/rating.rs#L929), [`core/dr-catalog/src/schema.rs:179`](../core/dr-catalog/src/schema.rs#L179), [`core/dr-catalog/src/schema.rs:557`](../core/dr-catalog/src/schema.rs#L557), [`core/dr-catalog/src/schema.rs:838`](../core/dr-catalog/src/schema.rs#L838), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:177`](../core/dr-pipeline/src/sidecar.rs#L177), [`core/dr-pipeline/src/sidecar.rs:204`](../core/dr-pipeline/src/sidecar.rs#L204), [`core/dr-pipeline/src/sidecar.rs:2473`](../core/dr-pipeline/src/sidecar.rs#L2473), [`core/dr-pipeline/src/sidecar.rs:2710`](../core/dr-pipeline/src/sidecar.rs#L2710), [`core/dr-pipeline/src/sidecar.rs:2944`](../core/dr-pipeline/src/sidecar.rs#L2944), [`core/dr-pipeline/src/sidecar.rs:380`](../core/dr-pipeline/src/sidecar.rs#L380), [`core/dr-pipeline/src/sidecar.rs:478`](../core/dr-pipeline/src/sidecar.rs#L478), [`core/dr-pipeline/src/sidecar.rs:645`](../core/dr-pipeline/src/sidecar.rs#L645), [`core/dr-pipeline/src/sidecar.rs:695`](../core/dr-pipeline/src/sidecar.rs#L695), [`core/dr-pipeline/src/sidecar.rs:720`](../core/dr-pipeline/src/sidecar.rs#L720), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/mask_sidecar.rs:1075`](../core/dr-pipeline/tests/mask_sidecar.rs#L1075), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:124`](../core/dr-sync-nextcloud/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:31`](../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:56`](../core/dr-sync/src/scan.rs#L56), [`core/dr-sync/src/scan.rs:926`](../core/dr-sync/src/scan.rs#L926), [`core/dr-xmp/src/lib.rs:733`](../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../core/dr-xmp/src/lib.rs#L746), [`ui/dr-ui/src/derived_sync.rs:614`](../ui/dr-ui/src/derived_sync.rs#L614), [`ui/dr-ui/src/derived_sync.rs:647`](../ui/dr-ui/src/derived_sync.rs#L647), [`ui/dr-ui/src/derived_sync.rs:878`](../ui/dr-ui/src/derived_sync.rs#L878), [`ui/dr-ui/src/library.rs:1062`](../ui/dr-ui/src/library.rs#L1062), [`ui/dr-ui/src/library.rs:1082`](../ui/dr-ui/src/library.rs#L1082), [`ui/dr-ui/src/library.rs:1391`](../ui/dr-ui/src/library.rs#L1391), [`ui/dr-ui/src/library.rs:1426`](../ui/dr-ui/src/library.rs#L1426), [`ui/dr-ui/src/library.rs:2538`](../ui/dr-ui/src/library.rs#L2538), [`ui/dr-ui/src/library.rs:73`](../ui/dr-ui/src/library.rs#L73), [`ui/dr-ui/src/library.rs:7583`](../ui/dr-ui/src/library.rs#L7583), [`ui/dr-ui/src/library.rs:760`](../ui/dr-ui/src/library.rs#L760), [`ui/dr-ui/src/library.rs:7751`](../ui/dr-ui/src/library.rs#L7751), [`ui/dr-ui/src/library.rs:832`](../ui/dr-ui/src/library.rs#L832), [`ui/dr-ui/src/library.rs:916`](../ui/dr-ui/src/library.rs#L916), [`ui/dr-ui/src/library_ui.rs:1302`](../ui/dr-ui/src/library_ui.rs#L1302), [`ui/dr-ui/src/presets.rs:288`](../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:336`](../ui/dr-ui/src/presets.rs#L336), [`ui/dr-ui/src/xmp_sync.rs:1`](../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:287`](../ui/dr-ui/src/xmp_sync.rs#L287) |
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:204`](../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:929`](../core/dr-catalog/src/rating.rs#L929), [`core/dr-catalog/src/schema.rs:179`](../core/dr-catalog/src/schema.rs#L179), [`core/dr-catalog/src/schema.rs:557`](../core/dr-catalog/src/schema.rs#L557), [`core/dr-catalog/src/schema.rs:838`](../core/dr-catalog/src/schema.rs#L838), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:177`](../core/dr-pipeline/src/sidecar.rs#L177), [`core/dr-pipeline/src/sidecar.rs:204`](../core/dr-pipeline/src/sidecar.rs#L204), [`core/dr-pipeline/src/sidecar.rs:2473`](../core/dr-pipeline/src/sidecar.rs#L2473), [`core/dr-pipeline/src/sidecar.rs:2710`](../core/dr-pipeline/src/sidecar.rs#L2710), [`core/dr-pipeline/src/sidecar.rs:2944`](../core/dr-pipeline/src/sidecar.rs#L2944), [`core/dr-pipeline/src/sidecar.rs:380`](../core/dr-pipeline/src/sidecar.rs#L380), [`core/dr-pipeline/src/sidecar.rs:478`](../core/dr-pipeline/src/sidecar.rs#L478), [`core/dr-pipeline/src/sidecar.rs:645`](../core/dr-pipeline/src/sidecar.rs#L645), [`core/dr-pipeline/src/sidecar.rs:695`](../core/dr-pipeline/src/sidecar.rs#L695), [`core/dr-pipeline/src/sidecar.rs:720`](../core/dr-pipeline/src/sidecar.rs#L720), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/mask_sidecar.rs:1075`](../core/dr-pipeline/tests/mask_sidecar.rs#L1075), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:124`](../core/dr-sync-nextcloud/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:31`](../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:56`](../core/dr-sync/src/scan.rs#L56), [`core/dr-sync/src/scan.rs:926`](../core/dr-sync/src/scan.rs#L926), [`core/dr-xmp/src/lib.rs:733`](../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../core/dr-xmp/src/lib.rs#L746), [`ui/dr-ui/src/derived_sync.rs:1030`](../ui/dr-ui/src/derived_sync.rs#L1030), [`ui/dr-ui/src/derived_sync.rs:602`](../ui/dr-ui/src/derived_sync.rs#L602), [`ui/dr-ui/src/derived_sync.rs:631`](../ui/dr-ui/src/derived_sync.rs#L631), [`ui/dr-ui/src/derived_sync.rs:664`](../ui/dr-ui/src/derived_sync.rs#L664), [`ui/dr-ui/src/derived_sync.rs:781`](../ui/dr-ui/src/derived_sync.rs#L781), [`ui/dr-ui/src/library.rs:1062`](../ui/dr-ui/src/library.rs#L1062), [`ui/dr-ui/src/library.rs:1082`](../ui/dr-ui/src/library.rs#L1082), [`ui/dr-ui/src/library.rs:1391`](../ui/dr-ui/src/library.rs#L1391), [`ui/dr-ui/src/library.rs:1426`](../ui/dr-ui/src/library.rs#L1426), [`ui/dr-ui/src/library.rs:2538`](../ui/dr-ui/src/library.rs#L2538), [`ui/dr-ui/src/library.rs:73`](../ui/dr-ui/src/library.rs#L73), [`ui/dr-ui/src/library.rs:7583`](../ui/dr-ui/src/library.rs#L7583), [`ui/dr-ui/src/library.rs:760`](../ui/dr-ui/src/library.rs#L760), [`ui/dr-ui/src/library.rs:7751`](../ui/dr-ui/src/library.rs#L7751), [`ui/dr-ui/src/library.rs:832`](../ui/dr-ui/src/library.rs#L832), [`ui/dr-ui/src/library.rs:916`](../ui/dr-ui/src/library.rs#L916), [`ui/dr-ui/src/library_ui.rs:1302`](../ui/dr-ui/src/library_ui.rs#L1302), [`ui/dr-ui/src/presets.rs:288`](../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:336`](../ui/dr-ui/src/presets.rs#L336), [`ui/dr-ui/src/xmp_sync.rs:1`](../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:287`](../ui/dr-ui/src/xmp_sync.rs#L287) |
| FR-PLAT-AND-2 | [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-sync-folder/src/lib.rs:242`](../core/dr-sync-folder/src/lib.rs#L242), [`core/dr-sync-folder/src/tests.rs:51`](../core/dr-sync-folder/src/tests.rs#L51), [`core/dr-sync/src/error.rs:113`](../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/error.rs:195`](../core/dr-sync/src/error.rs#L195), [`core/dr-sync/src/scan.rs:211`](../core/dr-sync/src/scan.rs#L211), [`core/dr-sync/src/scan.rs:519`](../core/dr-sync/src/scan.rs#L519), [`core/dr-sync/src/scan.rs:545`](../core/dr-sync/src/scan.rs#L545), [`core/dr-sync/src/scan.rs:574`](../core/dr-sync/src/scan.rs#L574), [`ui/dr-ui/src/library.rs:1291`](../ui/dr-ui/src/library.rs#L1291), [`ui/dr-ui/src/library.rs:1344`](../ui/dr-ui/src/library.rs#L1344), [`ui/dr-ui/src/library.rs:1375`](../ui/dr-ui/src/library.rs#L1375), [`ui/dr-ui/src/library.rs:1955`](../ui/dr-ui/src/library.rs#L1955), [`ui/dr-ui/src/library_ui.rs:1273`](../ui/dr-ui/src/library_ui.rs#L1273), [`ui/dr-ui/src/library_ui.rs:1369`](../ui/dr-ui/src/library_ui.rs#L1369), [`ui/dr-ui/src/library_ui.rs:1968`](../ui/dr-ui/src/library_ui.rs#L1968), [`ui/dr-ui/src/library_ui.rs:328`](../ui/dr-ui/src/library_ui.rs#L328), [`ui/dr-ui/src/library_ui.rs:555`](../ui/dr-ui/src/library_ui.rs#L555) |
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/runner.rs:1`](../core/dr-catalog/src/runner.rs#L1) |
| FR-PLAT-AND-4 | [`core/dr-catalog/src/runner.rs:1`](../core/dr-catalog/src/runner.rs#L1) |
@@ -137,7 +137,7 @@ _None._
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:3239`](../ui/dr-ui/src/lib.rs#L3239), [`ui/dr-ui/src/lib.rs:4068`](../ui/dr-ui/src/lib.rs#L4068), [`ui/dr-ui/src/lib.rs:4259`](../ui/dr-ui/src/lib.rs#L4259), [`ui/dr-ui/src/masks_ui.rs:1518`](../ui/dr-ui/src/masks_ui.rs#L1518), [`ui/dr-ui/ui/app.slint:2473`](../ui/dr-ui/ui/app.slint#L2473), [`ui/dr-ui/ui/app.slint:2513`](../ui/dr-ui/ui/app.slint#L2513), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/session.slint:75`](../ui/dr-ui/ui/session.slint#L75) |
| FR-UI-6 | [`ui/dr-ui/ui/widgets.slint:1`](../ui/dr-ui/ui/widgets.slint#L1) |
| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:195`](../core/dr-pipeline/src/descriptor.rs#L195), [`core/dr-pipeline/src/framing.rs:395`](../core/dr-pipeline/src/framing.rs#L395), [`core/dr-types/src/settings.rs:424`](../core/dr-types/src/settings.rs#L424), [`ui/dr-ui/src/develop.rs:1330`](../ui/dr-ui/src/develop.rs#L1330), [`ui/dr-ui/src/lib.rs:3893`](../ui/dr-ui/src/lib.rs#L3893), [`ui/dr-ui/ui/adjust.slint:974`](../ui/dr-ui/ui/adjust.slint#L974), [`ui/dr-ui/ui/app.slint:2229`](../ui/dr-ui/ui/app.slint#L2229), [`ui/dr-ui/ui/settings.slint:609`](../ui/dr-ui/ui/settings.slint#L609), [`ui/dr-ui/ui/toolrail.slint:89`](../ui/dr-ui/ui/toolrail.slint#L89) |
| FR-UI-8 | [`core/dr-catalog/src/collections.rs:654`](../core/dr-catalog/src/collections.rs#L654), [`core/dr-catalog/src/collections.rs:675`](../core/dr-catalog/src/collections.rs#L675), [`core/dr-types/src/place.rs:1`](../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/derived_sync.rs:216`](../ui/dr-ui/src/derived_sync.rs#L216), [`ui/dr-ui/src/derived_sync.rs:702`](../ui/dr-ui/src/derived_sync.rs#L702), [`ui/dr-ui/src/derived_sync.rs:712`](../ui/dr-ui/src/derived_sync.rs#L712), [`ui/dr-ui/src/derived_sync.rs:794`](../ui/dr-ui/src/derived_sync.rs#L794), [`ui/dr-ui/src/derived_sync.rs:81`](../ui/dr-ui/src/derived_sync.rs#L81), [`ui/dr-ui/src/library.rs:1123`](../ui/dr-ui/src/library.rs#L1123), [`ui/dr-ui/src/library.rs:4945`](../ui/dr-ui/src/library.rs#L4945), [`ui/dr-ui/src/library.rs:5663`](../ui/dr-ui/src/library.rs#L5663), [`ui/dr-ui/src/library.rs:5729`](../ui/dr-ui/src/library.rs#L5729), [`ui/dr-ui/src/library_ui.rs:195`](../ui/dr-ui/src/library_ui.rs#L195), [`ui/dr-ui/src/library_ui.rs:2155`](../ui/dr-ui/src/library_ui.rs#L2155), [`ui/dr-ui/src/library_ui.rs:2344`](../ui/dr-ui/src/library_ui.rs#L2344), [`ui/dr-ui/src/library_ui.rs:2361`](../ui/dr-ui/src/library_ui.rs#L2361), [`ui/dr-ui/src/library_ui.rs:2459`](../ui/dr-ui/src/library_ui.rs#L2459), [`ui/dr-ui/src/library_ui.rs:5758`](../ui/dr-ui/src/library_ui.rs#L5758), [`ui/dr-ui/src/library_ui.rs:5798`](../ui/dr-ui/src/library_ui.rs#L5798), [`ui/dr-ui/src/library_ui.rs:5827`](../ui/dr-ui/src/library_ui.rs#L5827), [`ui/dr-ui/src/library_ui.rs:6090`](../ui/dr-ui/src/library_ui.rs#L6090), [`ui/dr-ui/src/library_ui.rs:6313`](../ui/dr-ui/src/library_ui.rs#L6313), [`ui/dr-ui/src/library_ui.rs:7037`](../ui/dr-ui/src/library_ui.rs#L7037), [`ui/dr-ui/src/library_ui.rs:891`](../ui/dr-ui/src/library_ui.rs#L891), [`ui/dr-ui/src/library_ui.rs:924`](../ui/dr-ui/src/library_ui.rs#L924), [`ui/dr-ui/src/library_ui.rs:970`](../ui/dr-ui/src/library_ui.rs#L970), [`ui/dr-ui/src/place.rs:1`](../ui/dr-ui/src/place.rs#L1) |
| FR-UI-8 | [`core/dr-catalog/src/collections.rs:654`](../core/dr-catalog/src/collections.rs#L654), [`core/dr-catalog/src/collections.rs:675`](../core/dr-catalog/src/collections.rs#L675), [`core/dr-types/src/place.rs:1`](../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/derived_sync.rs:216`](../ui/dr-ui/src/derived_sync.rs#L216), [`ui/dr-ui/src/derived_sync.rs:81`](../ui/dr-ui/src/derived_sync.rs#L81), [`ui/dr-ui/src/derived_sync.rs:854`](../ui/dr-ui/src/derived_sync.rs#L854), [`ui/dr-ui/src/derived_sync.rs:864`](../ui/dr-ui/src/derived_sync.rs#L864), [`ui/dr-ui/src/derived_sync.rs:946`](../ui/dr-ui/src/derived_sync.rs#L946), [`ui/dr-ui/src/library.rs:1123`](../ui/dr-ui/src/library.rs#L1123), [`ui/dr-ui/src/library.rs:4945`](../ui/dr-ui/src/library.rs#L4945), [`ui/dr-ui/src/library.rs:5663`](../ui/dr-ui/src/library.rs#L5663), [`ui/dr-ui/src/library.rs:5729`](../ui/dr-ui/src/library.rs#L5729), [`ui/dr-ui/src/library_ui.rs:195`](../ui/dr-ui/src/library_ui.rs#L195), [`ui/dr-ui/src/library_ui.rs:2155`](../ui/dr-ui/src/library_ui.rs#L2155), [`ui/dr-ui/src/library_ui.rs:2344`](../ui/dr-ui/src/library_ui.rs#L2344), [`ui/dr-ui/src/library_ui.rs:2361`](../ui/dr-ui/src/library_ui.rs#L2361), [`ui/dr-ui/src/library_ui.rs:2459`](../ui/dr-ui/src/library_ui.rs#L2459), [`ui/dr-ui/src/library_ui.rs:5758`](../ui/dr-ui/src/library_ui.rs#L5758), [`ui/dr-ui/src/library_ui.rs:5798`](../ui/dr-ui/src/library_ui.rs#L5798), [`ui/dr-ui/src/library_ui.rs:5827`](../ui/dr-ui/src/library_ui.rs#L5827), [`ui/dr-ui/src/library_ui.rs:6090`](../ui/dr-ui/src/library_ui.rs#L6090), [`ui/dr-ui/src/library_ui.rs:6313`](../ui/dr-ui/src/library_ui.rs#L6313), [`ui/dr-ui/src/library_ui.rs:7037`](../ui/dr-ui/src/library_ui.rs#L7037), [`ui/dr-ui/src/library_ui.rs:891`](../ui/dr-ui/src/library_ui.rs#L891), [`ui/dr-ui/src/library_ui.rs:924`](../ui/dr-ui/src/library_ui.rs#L924), [`ui/dr-ui/src/library_ui.rs:970`](../ui/dr-ui/src/library_ui.rs#L970), [`ui/dr-ui/src/place.rs:1`](../ui/dr-ui/src/place.rs#L1) |
| NFR-A11Y-2 | [`ui/dr-ui/tests/ui_controls_are_accessible.rs:1`](../ui/dr-ui/tests/ui_controls_are_accessible.rs#L1), [`ui/dr-ui/ui/adjust.slint:171`](../ui/dr-ui/ui/adjust.slint#L171), [`ui/dr-ui/ui/app.slint:2646`](../ui/dr-ui/ui/app.slint#L2646), [`ui/dr-ui/ui/app.slint:2684`](../ui/dr-ui/ui/app.slint#L2684) |
| NFR-A11Y-3 | [`ui/dr-ui/src/histogram.rs:356`](../ui/dr-ui/src/histogram.rs#L356), [`ui/dr-ui/ui/histogram.slint:137`](../ui/dr-ui/ui/histogram.slint#L137), [`ui/dr-ui/ui/library.slint:761`](../ui/dr-ui/ui/library.slint#L761), [`ui/dr-ui/ui/library.slint:909`](../ui/dr-ui/ui/library.slint#L909), [`ui/dr-ui/ui/peaking.slint:1`](../ui/dr-ui/ui/peaking.slint#L1) |
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:3679`](../ui/dr-ui/src/library.rs#L3679) |
@@ -156,7 +156,7 @@ _None._
| NFR-PORT-2 | [`core/dr-gpu/src/lib.rs:1`](../core/dr-gpu/src/lib.rs#L1) |
| NFR-PORT-3 | [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1) |
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-gpu/src/lib.rs:192`](../core/dr-gpu/src/lib.rs#L192), [`core/dr-sync/src/account.rs:226`](../core/dr-sync/src/account.rs#L226), [`ui/dr-ui/src/library.rs:1170`](../ui/dr-ui/src/library.rs#L1170) |
| NFR-R2 | [`core/dr-catalog/src/recovery.rs:1`](../core/dr-catalog/src/recovery.rs#L1), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) |
| NFR-R2 | [`core/dr-catalog/src/recovery.rs:1`](../core/dr-catalog/src/recovery.rs#L1), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`ui/dr-ui/src/derived_sync.rs:602`](../ui/dr-ui/src/derived_sync.rs#L602) |
| NFR-R4 | [`core/dr-types/src/settings.rs:92`](../core/dr-types/src/settings.rs#L92), [`ui/dr-ui/src/library.rs:1563`](../ui/dr-ui/src/library.rs#L1563), [`ui/dr-ui/src/library.rs:1579`](../ui/dr-ui/src/library.rs#L1579), [`ui/dr-ui/src/library_ui.rs:3411`](../ui/dr-ui/src/library_ui.rs#L3411), [`ui/dr-ui/src/library_ui.rs:387`](../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:519`](../ui/dr-ui/src/library_ui.rs#L519), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/src/xmp_sync.rs:1`](../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/ui/settings.slint:1001`](../ui/dr-ui/ui/settings.slint#L1001), [`ui/dr-ui/ui/settings.slint:184`](../ui/dr-ui/ui/settings.slint#L184) |
| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) |
| NFR-R6 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/lib.rs:255`](../core/dr-catalog/src/lib.rs#L255), [`core/dr-catalog/src/recovery.rs:1`](../core/dr-catalog/src/recovery.rs#L1) |
+367 -132
View File
@@ -591,11 +591,28 @@ fn legacy_upload_of_ours(store: &ThumbStore, id: u32, remote_size: u64) -> bool
.unwrap_or(false)
}
/// Exchange the catalog, for its collections.
/// Exchange the catalog, for its collections and its people.
///
/// Only collections merge — see [`dr_catalog::sync`]. The rest of a catalog
/// describes local state (folder ETags, cache paths, job rows) and importing
/// another device's version would be actively wrong.
/// Only collections, keywords and people merge — see [`dr_catalog::sync`]. The
/// rest of a catalog describes local state (folder ETags, cache paths, job
/// rows) and importing another device's version would be actively wrong.
///
/// # Generations
///
/// TRACES: FR-NC-9 | NFR-R2
/// The server keeps the current copy and the [`GENERATIONS`] before it:
/// `catalog.sqlite`, then `catalog.1.sqlite` (the one it replaced), `.2`,
/// `.3`. A push uploads to a temporary name, rotates, and moves the upload into
/// place — so at no moment is there no current copy, and a copy that turns out
/// damaged has the one before it to fall back on. Rotation is server-side
/// renames; the only transfer is the upload itself.
///
/// This is what a damaged copy used to lack. With one copy and nothing behind
/// it, "the current file will not open" left two answers, both bad: refuse for
/// ever, or overwrite with ours and lose whatever another device had added
/// since. Now it has a third — merge from the newest readable generation, which
/// loses nothing — and the damaged file itself is kept as `.1` by the ordinary
/// rotation rather than by a separate upload.
async fn sync_catalog(
backend: &dyn RemoteBackend,
base: &RemotePath,
@@ -603,7 +620,7 @@ async fn sync_catalog(
scratch: &Path,
report: &mut SyncReport,
) -> Result<(), String> {
let remote_name = "catalog.sqlite";
let remote_name = CATALOG_NAME;
let target = RemotePath::new(format!("{}/{remote_name}", base.as_str()));
// ---- take theirs first -----------------------------------------------
@@ -635,46 +652,48 @@ async fn sync_catalog(
};
if let Some(bytes) = theirs {
let downloaded = scratch.join("catalog-remote.sqlite");
if std::fs::write(&downloaded, &bytes).is_ok() {
match dr_catalog::Catalog::open(catalog_path) {
Ok(catalog) => match catalog.merge_remote_catalog(&downloaded) {
Ok(merge) => {
report.catalog_merged = true;
report.collections_gained = merge.inserted + merge.updated;
report.members_gained = merge.members_added;
}
// TRACES: FR-NC-9
// Damaged beyond reading, and the whole file arrived. See
// [`replace_corrupt_remote`] for why this one failure is
// the exception to "never write over what you could not
// read": there is nothing left in it to preserve, and
// refusing for ever is what pinned a damaged file in place
// on every device for a week.
Err(dr_catalog::CatalogError::Corrupt { detail }) => {
let _ = std::fs::remove_file(&downloaded);
if !replace_corrupt_remote(backend, base, remote_name, &bytes, &detail)
.await
{
return Ok(());
}
report.catalog_replaced = true;
}
// Unreadable is not the same as absent: it may be a newer
// format, or a torn upload. Ours must not go over it.
Err(e) => {
log::warn!("not pushing the catalog: merging the server's copy: {e}");
let _ = std::fs::remove_file(&downloaded);
return Ok(());
}
},
Err(e) => {
log::warn!("not pushing the catalog: opening ours to merge: {e}");
let _ = std::fs::remove_file(&downloaded);
let catalog = match dr_catalog::Catalog::open(catalog_path) {
Ok(c) => c,
Err(e) => {
log::warn!("not pushing the catalog: opening ours to merge: {e}");
return Ok(());
}
};
match merge_downloaded(&catalog, scratch, &bytes, report) {
Ok(()) => {}
// TRACES: FR-NC-9
// Damaged beyond reading, and the whole file arrived — so it is
// not a short download, and no device will read it either. Fall
// back to the generation before it; only with none readable is
// ours the whole truth. See the header for why refusing here for
// ever was the wrong answer.
Err(dr_catalog::CatalogError::Corrupt { detail }) => {
if !arrived_whole(backend, base, remote_name, bytes.len(), &detail).await {
return Ok(());
}
match merge_from_generations(backend, base, &catalog, scratch, report).await {
Some(n) => log::warn!(
"the catalog on the server is damaged ({detail}) and all {} bytes of \
it arrived, so no device can read it; merged from the generation \
before it ({}) instead, and replacing it",
bytes.len(),
generation_name(n)
),
None => log::warn!(
"the catalog on the server is damaged ({detail}) and all {} bytes of \
it arrived, so no device can read it, and no earlier generation is \
readable either; replacing it with this device's copy",
bytes.len()
),
}
report.catalog_replaced = true;
}
// Unreadable is not the same as absent: it may be a newer
// format. Ours must not go over it.
Err(e) => {
log::warn!("not pushing the catalog: merging the server's copy: {e}");
return Ok(());
}
let _ = std::fs::remove_file(&downloaded);
}
}
@@ -690,15 +709,148 @@ async fn sync_catalog(
.map_err(|e| e.to_string())?;
let bytes = std::fs::read(&snapshot).map_err(|e| e.to_string())?;
match backend.put(&target, bytes, None).await {
Ok(_) => report.catalog_uploaded = true,
Err(e) => log::warn!("uploading catalog: {e}"),
}
let _ = std::fs::remove_file(&snapshot);
// To a temporary name first. The upload is the only step that can fail
// half-way, and a half-uploaded *current* copy is exactly the damaged file
// this whole scheme exists to survive. Under its own name a failure leaves
// the current copy untouched and costs one stray file, retried next pass.
let staging = RemotePath::new(format!("{}/{UPLOAD_NAME}", base.as_str()));
if let Err(e) = backend.put(&staging, bytes, None).await {
log::warn!("uploading catalog: {e}");
return Ok(());
}
// Rotate, then move the upload into place. Every step here is a rename on
// the server, and every destination is empty by the time it is written to
// — a `move_to` will not overwrite, by design — so a failure at any point
// leaves a gap in the generations and never a missing current copy.
if let Err(e) = rotate_generations(backend, base).await {
log::warn!("not replacing the catalog: rotating the earlier copies: {e}");
return Ok(());
}
match backend.move_to(&RemoteId::Path(staging), &target).await {
Ok(()) => report.catalog_uploaded = true,
Err(e) => log::warn!("moving the uploaded catalog into place: {e}"),
}
Ok(())
}
/// The current copy's name on the server.
const CATALOG_NAME: &str = "catalog.sqlite";
/// Where a push lands before it is rotated into place.
const UPLOAD_NAME: &str = "catalog.upload.sqlite";
/// How many earlier copies the server keeps behind the current one.
///
/// Three, because what they are for is surviving one damaged push and the one
/// or two syncs it may take for a device to notice. More would cost nothing in
/// transfer — rotation is renames — but each is a 40 MB file on the account's
/// quota, and the local backups (NFR-R2) are the long-term store.
const GENERATIONS: usize = 3;
/// `catalog.N.sqlite` for `1 <= N <= GENERATIONS`; `.1` is the newest.
fn generation_name(n: usize) -> String {
format!("catalog.{n}.sqlite")
}
/// Merge a downloaded catalog into ours, through a file in scratch.
///
/// Attaching needs a path, and the download is bytes. Written and removed here
/// so the callers — the current copy, and each generation tried after it —
/// cannot disagree about cleanup.
fn merge_downloaded(
catalog: &dr_catalog::Catalog,
scratch: &Path,
bytes: &[u8],
report: &mut SyncReport,
) -> Result<(), dr_catalog::CatalogError> {
let downloaded = scratch.join("catalog-remote.sqlite");
std::fs::write(&downloaded, bytes).map_err(|e| dr_catalog::CatalogError::Io(e.to_string()))?;
let result = catalog.merge_remote_catalog(&downloaded);
let _ = std::fs::remove_file(&downloaded);
let merge = result?;
report.catalog_merged = true;
report.collections_gained += merge.inserted + merge.updated;
report.members_gained += merge.members_added;
Ok(())
}
/// TRACES: FR-NC-9
/// Merge from the newest generation that reads, when the current copy will
/// not. Returns which one, or `None` when none of them does.
///
/// Newest first, and the first readable one wins: a generation is a complete
/// snapshot, so an older one adds nothing a newer one lacks. A generation that
/// is absent, damaged, or from a newer schema is skipped the same way — none of
/// those is a reason to stop looking further back.
async fn merge_from_generations(
backend: &dyn RemoteBackend,
base: &RemotePath,
catalog: &dr_catalog::Catalog,
scratch: &Path,
report: &mut SyncReport,
) -> Option<usize> {
for n in 1..=GENERATIONS {
let path = RemotePath::new(format!("{}/{}", base.as_str(), generation_name(n)));
let bytes = match read_derived(backend, &path).await {
Ok(b) => b,
Err(RemoteError::NotFound(_)) => continue,
Err(e) => {
log::debug!("skipping {}: {e}", generation_name(n));
continue;
}
};
match merge_downloaded(catalog, scratch, &bytes, report) {
Ok(()) => return Some(n),
Err(e) => log::debug!("skipping {}: {e}", generation_name(n)),
}
}
None
}
/// Make room for a new current copy: drop the oldest generation and shift the
/// rest back by one, ending with the current copy as `.1`.
///
/// Oldest first, so that each destination is empty when it is moved into —
/// `move_to` refuses to overwrite, and rightly. A name that is not there is
/// not an error at any step: a library that has synced twice has no `.3` yet.
async fn rotate_generations(backend: &dyn RemoteBackend, base: &RemotePath) -> Result<(), String> {
let at = |name: String| RemotePath::new(format!("{}/{name}", base.as_str()));
match backend
.delete(&RemoteId::Path(at(generation_name(GENERATIONS))), None)
.await
{
Ok(()) | Err(RemoteError::NotFound(_)) => {}
Err(e) => return Err(format!("dropping {}: {e}", generation_name(GENERATIONS))),
}
for n in (1..GENERATIONS).rev() {
match backend
.move_to(
&RemoteId::Path(at(generation_name(n))),
&at(generation_name(n + 1)),
)
.await
{
Ok(()) | Err(RemoteError::NotFound(_)) => {}
Err(e) => return Err(format!("moving {} back: {e}", generation_name(n))),
}
}
match backend
.move_to(
&RemoteId::Path(at(CATALOG_NAME.into())),
&at(generation_name(1)),
)
.await
{
Ok(()) | Err(RemoteError::NotFound(_)) => Ok(()),
Err(e) => Err(format!("setting the current copy back: {e}")),
}
}
/// TRACES: FR-UI-8
/// The place's name inside the derived folder.
///
@@ -876,90 +1028,37 @@ async fn read_derived(
}
/// TRACES: FR-NC-9
/// Set a damaged remote catalog aside so ours can replace it.
/// Whether a download that will not open is the server's whole file.
///
/// # Why this is allowed to destroy something
///
/// Everything else in [`sync_catalog`] refuses to upload when it could not read
/// the server's copy, because the upload is a read-modify-write and writing
/// blind discards another device's collections. That rule is right for every
/// failure it was written for — a timeout, a dropped connection, a newer schema
/// — because in all of them the remote is *fine* and only our view of it
/// failed.
///
/// A file SQLite calls malformed is not that. It is not a view that failed; it
/// is a file whose contents no device can ever read again. Refusing to write
/// over it preserves nothing, and every client then declines in turn: the
/// damaged copy is pinned in place for ever, and collections and people stop
/// crossing between devices silently, on all of them at once. That is not
/// theoretical — it is what this library did from 2026-09-07, on the desktop
/// and on a freshly installed phone alike, both logging "catalog not pushed"
/// on every pass for a week while 32 collections sat undelivered.
///
/// # What makes it safe
///
/// **The whole file has to have arrived.** A truncated download is also
/// unreadable, and it is the far more likely story on a phone — this library's
/// logs are full of aborted bodies and DNS failures. So the size the server
/// advertises is compared against what we actually received, and anything short
/// is treated as the transport failure it is. Only a complete file that still
/// will not open is judged damaged.
///
/// **Nothing is deleted.** The damaged bytes are uploaded beside the catalog
/// under a dated name first, and the replacement only proceeds once that has
/// landed. If some later build learns to salvage collections out of a damaged
/// catalog, the file is still there to salvage them from.
///
/// Returns whether the caller may now push over the remote.
async fn replace_corrupt_remote(
/// A truncated download is also unreadable, and on a phone it is the far
/// likelier story — this library's logs are full of aborted bodies and DNS
/// failures. Treating that as damage would let one bad connection discard a
/// catalog the server was holding perfectly well. So the size the server
/// advertises is compared against what actually arrived, and anything short,
/// or any size the listing cannot confirm, is the transport failure it is:
/// the caller must leave the server's copy alone.
async fn arrived_whole(
backend: &dyn RemoteBackend,
base: &RemotePath,
remote_name: &str,
bytes: &[u8],
name: &str,
received: usize,
detail: &str,
) -> bool {
let advertised = match remote_size(backend, base, remote_name).await {
Some(n) => n,
None => {
log::warn!(
"not pushing the catalog: the copy on the server will not open ({detail}), \
but its size could not be confirmed, so it may simply have arrived short"
);
return false;
}
};
if advertised != bytes.len() as u64 {
let Some(advertised) = remote_size(backend, base, name).await else {
log::warn!(
"not pushing the catalog: the copy on the server will not open ({detail}), \
but only {} of {advertised} bytes arrived — that is a truncated download, \
not a damaged file, so the server's copy is left alone",
bytes.len()
but its size could not be confirmed, so it may simply have arrived short"
);
return false;
}
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let aside_name = format!("catalog.corrupt-{stamp}.sqlite");
let aside = RemotePath::new(format!("{}/{aside_name}", base.as_str()));
if let Err(e) = backend.put(&aside, bytes.to_vec(), None).await {
};
if advertised != received as u64 {
log::warn!(
"not pushing the catalog: the copy on the server is damaged ({detail}), \
but it could not be set aside as {aside_name} ({e}), and it will not be \
overwritten until a copy of it is safe"
"not pushing the catalog: the copy on the server will not open ({detail}), \
but only {received} of {advertised} bytes arrived — that is a truncated \
download, not a damaged file, so the server's copy is left alone"
);
return false;
}
log::warn!(
"the catalog on the server is damaged ({detail}) and all {advertised} bytes of it \
arrived, so no device can read it. Kept as {aside_name}; replacing it with this \
device's copy, which is what lets collections and people sync again"
);
true
}
@@ -1118,6 +1217,12 @@ mod derived_guard_tests {
/// This is what says whether a body that will not open is a damaged
/// file or merely a short download.
advertise: Option<u64>,
/// Bodies by file name, for tests that need the current copy and a
/// generation to differ. When empty every read serves `body`; when
/// not, a name absent here reads as `NotFound`.
bodies: std::collections::HashMap<String, Vec<u8>>,
/// Every `move_to`, as (from, to) names, in order.
moves: Arc<std::sync::Mutex<Vec<(String, String)>>>,
}
impl Fussy {
@@ -1140,6 +1245,8 @@ mod derived_guard_tests {
last_put: last_put.clone(),
caps: dr_sync::Capabilities::minimal(),
advertise: None,
bodies: Default::default(),
moves: Default::default(),
},
puts,
last_put,
@@ -1189,7 +1296,7 @@ mod derived_guard_tests {
}
async fn get(
&self,
_id: &RemoteId,
id: &RemoteId,
_r: Option<std::ops::Range<u64>>,
) -> Result<Vec<u8>, RemoteError> {
match &self.fail_with {
@@ -1198,7 +1305,17 @@ mod derived_guard_tests {
Err(RemoteError::NotMaterialised(s.clone()))
}
Some(_) => Err(RemoteError::PermissionDenied),
None => Ok(self.body.clone()),
None if self.bodies.is_empty() => Ok(self.body.clone()),
None => {
let name = match id {
RemoteId::Path(p) => p.name().to_string(),
_ => String::new(),
};
self.bodies
.get(&name)
.cloned()
.ok_or(RemoteError::NotFound(name))
}
}
}
async fn put(
@@ -1218,7 +1335,15 @@ mod derived_guard_tests {
) -> Result<(), RemoteError> {
Ok(())
}
async fn move_to(&self, _f: &RemoteId, _t: &RemotePath) -> Result<(), RemoteError> {
async fn move_to(&self, f: &RemoteId, t: &RemotePath) -> Result<(), RemoteError> {
let from = match f {
RemoteId::Path(p) => p.name().to_string(),
_ => String::new(),
};
self.moves
.lock()
.unwrap()
.push((from, t.name().to_string()));
Ok(())
}
async fn create_dir(&self, _p: &RemotePath) -> Result<(), RemoteError> {
@@ -1294,16 +1419,45 @@ mod derived_guard_tests {
// not a read that failed; it is a file no device will ever read again, and
// leaving it alone pins it there for every client at once.
/// A body that is definitely not a SQLite database, with a chosen size the
/// listing will or will not agree with.
/// The bytes of a catalog holding one collection, as a peer would upload.
fn a_catalog_with(collection: &str, name: &str) -> Vec<u8> {
let dir = std::env::temp_dir().join(format!("dr-generation-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("catalog.sqlite");
let cat = dr_catalog::Catalog::open(&path).unwrap();
cat.connection()
.execute(
"INSERT INTO collections(uuid, name, kind, created, revision, modified)
VALUES (?1, ?2, 0, 0, 1, 1)",
rusqlite::params![format!("uuid-{collection}"), collection],
)
.unwrap();
let snap = dir.join("snap.sqlite");
cat.snapshot_for_upload(&snap).unwrap();
let bytes = std::fs::read(&snap).unwrap();
let _ = std::fs::remove_dir_all(&dir);
bytes
}
/// A body that is definitely not a SQLite database as the current copy,
/// with a chosen advertised size and whatever generations the test wants.
async fn corrupt_remote(
body_len: usize,
advertised: Option<u64>,
generations: &[(usize, Vec<u8>)],
name: &str,
) -> (usize, SyncReport, Vec<u8>) {
) -> (usize, SyncReport, Vec<(String, String)>) {
let (catalog_path, scratch) = fixture(name);
let (mut backend, puts, last) = Fussy::serving(None, vec![0xAB; body_len]);
let (mut backend, puts, _) = Fussy::serving(None, Vec::new());
backend.advertise = advertised;
backend
.bodies
.insert(CATALOG_NAME.to_string(), vec![0xAB; body_len]);
for (n, bytes) in generations {
backend.bodies.insert(generation_name(*n), bytes.clone());
}
let moves = backend.moves.clone();
let mut report = SyncReport::default();
sync_catalog(
&backend,
@@ -1314,20 +1468,59 @@ mod derived_guard_tests {
)
.await
.unwrap();
let sent = last.lock().unwrap().clone();
(puts.load(Ordering::SeqCst), report, sent)
let moves = moves.lock().unwrap().clone();
(puts.load(Ordering::SeqCst), report, moves)
}
#[tokio::test]
async fn a_damaged_catalog_that_arrived_whole_is_set_aside_and_replaced() {
async fn a_damaged_catalog_that_arrived_whole_is_replaced() {
// The deadlock this exists to break: every device downloads the same
// unreadable file, every device declines to overwrite it, and
// collections and people stop crossing between devices for ever.
let (puts, report, _) = corrupt_remote(64, Some(64), "corrupt-whole").await;
assert_eq!(puts, 2, "the damaged copy is kept, then ours goes over it");
let (puts, report, moves) = corrupt_remote(64, Some(64), &[], "corrupt-whole").await;
assert_eq!(puts, 1, "ours goes up, to the staging name");
assert!(report.catalog_replaced, "and the report says what happened");
assert!(report.catalog_uploaded);
assert!(!report.catalog_merged, "there was nothing to merge");
assert!(
!report.catalog_merged,
"there was nothing readable to merge"
);
// The damaged file is kept by the rotation, not thrown away.
assert!(moves.contains(&(CATALOG_NAME.into(), generation_name(1))));
assert_eq!(
moves.last().unwrap(),
&(UPLOAD_NAME.to_string(), CATALOG_NAME.to_string()),
"and the upload is moved into place last"
);
}
#[tokio::test]
async fn a_damaged_catalog_falls_back_to_the_generation_before_it() {
// What generations are for. The device that pushed the damaged copy
// may have been the only one holding some collection; the generation
// before it still has everything every device had agreed on.
let older = a_catalog_with("Iceland", "gen1");
let (puts, report, _) =
corrupt_remote(64, Some(64), &[(1, older)], "corrupt-with-gen").await;
assert!(report.catalog_merged, "merged from catalog.1.sqlite");
assert_eq!(report.collections_gained, 1, "and gained what it held");
assert!(report.catalog_replaced);
assert_eq!(puts, 1);
}
#[tokio::test]
async fn a_damaged_generation_is_skipped_for_the_one_behind_it() {
// Two bad pushes in a row must not be worse than one.
let older = a_catalog_with("Faroe", "gen2");
let (_, report, _) = corrupt_remote(
64,
Some(64),
&[(1, vec![0xCD; 64]), (2, older)],
"corrupt-two-deep",
)
.await;
assert!(report.catalog_merged);
assert_eq!(report.collections_gained, 1);
}
#[tokio::test]
@@ -1336,8 +1529,9 @@ mod derived_guard_tests {
// aborts bodies constantly, and a partial download will not open
// either — treating that as damage would let one bad connection
// destroy a catalog the server was holding perfectly well.
let (puts, report, _) = corrupt_remote(64, Some(4096), "corrupt-short").await;
let (puts, report, moves) = corrupt_remote(64, Some(4096), &[], "corrupt-short").await;
assert_eq!(puts, 0, "nothing is written over a copy that arrived short");
assert!(moves.is_empty(), "and nothing is rotated");
assert!(!report.catalog_replaced);
assert!(!report.catalog_uploaded);
}
@@ -1347,11 +1541,52 @@ mod derived_guard_tests {
// Not knowing is not the same as knowing it is whole. Without a size
// to compare against there is no way to tell damage from truncation,
// and the answer to "I cannot tell" has to stay "do not overwrite".
let (puts, report, _) = corrupt_remote(64, None, "corrupt-unconfirmed").await;
let (puts, report, _) = corrupt_remote(64, None, &[], "corrupt-unconfirmed").await;
assert_eq!(puts, 0);
assert!(!report.catalog_replaced);
}
#[tokio::test]
async fn a_push_rotates_oldest_first_and_lands_last() {
// The order is the safety: every destination is empty when it is
// moved into, so a failure at any step leaves a gap and never a
// missing current copy.
let (puts, report, moves) =
run_with_moves(Some(RemoteError::NotFound("nope".into())), "rotation").await;
assert_eq!(puts, 1);
assert!(report.catalog_uploaded);
assert_eq!(
moves,
vec![
(generation_name(2), generation_name(3)),
(generation_name(1), generation_name(2)),
(CATALOG_NAME.to_string(), generation_name(1)),
(UPLOAD_NAME.to_string(), CATALOG_NAME.to_string()),
]
);
}
async fn run_with_moves(
fail_with: Option<RemoteError>,
name: &str,
) -> (usize, SyncReport, Vec<(String, String)>) {
let (catalog_path, scratch) = fixture(name);
let (backend, puts) = Fussy::reading(fail_with);
let moves = backend.moves.clone();
let mut report = SyncReport::default();
sync_catalog(
&backend,
&RemotePath::new(".darkroom-derived"),
&catalog_path,
&scratch,
&mut report,
)
.await
.unwrap();
let moves = moves.lock().unwrap().clone();
(puts.load(Ordering::SeqCst), report, moves)
}
// --- the place (FR-UI-8) ---------------------------------------------
//
// The same "do not write over what you could not read" rule as above, for a