diff --git a/docs/traceability.md b/docs/traceability.md index c64ceca..eab73bc 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -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) | diff --git a/ui/dr-ui/src/derived_sync.rs b/ui/dr-ui/src/derived_sync.rs index 2bf950f..684148d 100644 --- a/ui/dr-ui/src/derived_sync.rs +++ b/ui/dr-ui/src/derived_sync.rs @@ -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 { + 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, + /// 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>, + /// Every `move_to`, as (from, to) names, in order. + moves: Arc>>, } 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>, ) -> Result, 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 { + 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, + generations: &[(usize, Vec)], name: &str, - ) -> (usize, SyncReport, Vec) { + ) -> (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, + 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