From 4d23c8620bf150f92c1b9e77a8a72f67413f92d6 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 22:48:18 +0000 Subject: [PATCH] cultcache-rs: pin the foreign key in another store and the SoA retype (survivors of hand mutation) Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-rs/src/lib.rs | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 693a11c0..50a9af4f 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -4492,6 +4492,54 @@ mod tests { Ok(()) } + #[test] + fn retype_a_foreign_key_in_another_store_refuses_the_pull_and_every_write_to_it() -> Result<()> { + let temp = tempfile::tempdir()?; + let (routed, generic) = (temp.path().join("routed.cc"), temp.path().join("generic.cc")); + store_with(&routed, |cache| cache.put("k", &settings_one()).map(drop))?; + store_with(&generic, |cache| cache.put("n", ¬e_one()).map(drop))?; + let open = || -> Result { + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_backing_store(SingleFileMessagePackBackingStore::new(&routed), [Settings::TYPE])?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic))?; + Ok(cache) + }; + + // A registered record in one store and a foreign one at the same key in another: the pull is refused. + store_with(&generic, |cache| cache.put("k", ¬e_one()).map(drop))?; + let mut cache = open()?; + assert_eq!(retype_refusal(&cache.pull_all_backing_stores().unwrap_err()).record_key, "k"); + assert!(cache.snapshot().is_empty() && cache.foreign_records().is_empty()); + + // The foreign key lives in the generic store; a write routed to the other store is refused by the cache alone. + fs::remove_file(&generic)?; + store_with(&generic, |cache| cache.put("n", ¬e_one()).map(drop))?; + let mut cache = open()?; + cache.pull_all_backing_stores()?; + let before = (cache.snapshot(), fs::read(&routed)?, fs::read(&generic)?); + let (staged, _) = cache.prepare_entry("n", &settings_one())?; + for error in [cache.put("n", &settings_one()).unwrap_err(), cache.put_prepared_batch(vec![staged]).unwrap_err()] { + assert_eq!(retype_refusal(&error).record_key, "n"); + } + assert_eq!((cache.snapshot(), fs::read(&routed)?, fs::read(&generic)?), before); + Ok(()) + } + + #[test] + fn retype_load_soa_refuses_a_second_type_at_a_key_and_admits_nothing() -> Result<()> { + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + cache.put("kept", &settings_one())?; + let before = cache.snapshot(); + let (settings, _) = cache.prepare_entry("k", &settings_one())?; + let (note, _) = cache.prepare_entry("k", ¬e_one())?; + let error = cache.load_soa(CultCacheSoaTable::from_envelopes(vec![settings, note])).unwrap_err(); + assert_eq!(retype_refusal(&error).record_key, "k"); + assert_eq!(cache.snapshot(), before); + Ok(()) + } + #[test] fn retype_a_later_registration_claims_the_foreign_record_at_the_next_load() -> Result<()> { let temp = tempfile::tempdir()?;