cultcache-rs: expose a store's schema catalog (versions) to consumers #167

Merged
libby merged 2 commits from rs-snapshot-schema-version into main 2026-11-13 18:49:47 +00:00
2 changed files with 208 additions and 0 deletions

View file

@ -480,6 +480,39 @@ struct PersistedSchemaCatalogEntry(
Vec<PersistedSchemaCatalogMember>,
);
/// One entry of a store file's schema catalog, read-only: the scalar fields every runtime publishes for a schema id. The members and
/// the canonical schema JSON the file also carries are not exposed. `schema_version` is the version string the writer published
/// (C# `CultSchemaCatalogEntry.SchemaVersion`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SchemaCatalogEntry {
pub schema_id: String,
pub schema_name: String,
pub schema_version: String,
pub content_hash: String,
pub compatible_schema_ids: Vec<String>,
}
impl From<&PersistedSchemaCatalogEntry> for SchemaCatalogEntry {
fn from(entry: &PersistedSchemaCatalogEntry) -> Self {
Self {
schema_id: entry.0.clone(),
schema_name: entry.1.clone(),
schema_version: entry.2.clone(),
content_hash: entry.3.clone(),
compatible_schema_ids: entry.5.clone(),
}
}
}
/// The catalog entry a record's schema id names, as the decoder resolves it: the first entry that has the id as its own, else the
/// first entry that lists it as compatible; `None` when the catalog publishes neither.
pub fn resolve_schema_entry<'a>(catalog: &'a [SchemaCatalogEntry], schema_id: &str) -> Option<&'a SchemaCatalogEntry> {
catalog
.iter()
.find(|entry| entry.schema_id == schema_id)
.or_else(|| catalog.iter().find(|entry| entry.compatible_schema_ids.iter().any(|id| id == schema_id)))
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
struct PersistedSchemaCatalogMember(
u32,
@ -1697,6 +1730,16 @@ impl SingleFileMessagePackBackingStore {
self.read_all_unlocked()
}
/// The schema catalog the store file publishes, read under the same shared lock [`CacheBackingStore::pull_all`] takes. An absent
/// file and a legacy envelope array have no catalog and give an empty one. Resolve a record's `schema_id` against it with
/// [`resolve_schema_entry`].
pub fn pull_schema_catalog(&self) -> Result<Vec<SchemaCatalogEntry>> {
if is_absent(&self.path)? && is_absent(&self.lock_path())? {
return Ok(Vec::new());
}
self.with_shared_lock(|| Ok(self.read_store_unlocked()?.catalog.iter().map(SchemaCatalogEntry::from).collect()))
}
fn write_all_unlocked(&self, entries: &[CultCacheEnvelope]) -> Result<()> {
let failed = |kind| CultCacheStoreWriteFailed {
path: self.path.clone(),

View file

@ -0,0 +1,165 @@
//! A consumer of a single-file store reads the schema version a record was published under from the store's catalog, through the
//! public surface only: `pull_all`, `pull_schema_catalog` and `resolve_schema_entry`
//! (`cultlib-gaps:follow_up:rs-snapshot-schema-version`).
use std::path::PathBuf;
use cultcache_rs::{
CacheBackingStore, CultCache, DatabaseEntry, SchemaCatalogEntry, SingleFileMessagePackBackingStore, resolve_schema_entry,
};
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "tests.sc_one", schema = "tests.sc_one")]
struct One {
#[cultcache(key = 0)]
text: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "tests.sc_two", schema = "tests.sc_two")]
struct Two {
#[cultcache(key = 0)]
text: String,
}
fn vectors(folder: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors").join(folder)
}
/// The version a store publishes for each record, in key order: `(key, schema name, schema version)`.
fn versions(store: &SingleFileMessagePackBackingStore) -> Vec<(String, String, String)> {
let catalog = store.pull_schema_catalog().unwrap();
let mut rows: Vec<_> = store
.pull_all()
.unwrap()
.iter()
.map(|envelope| {
let id = envelope.schema_id.as_deref().unwrap();
let entry = resolve_schema_entry(&catalog, id).unwrap_or_else(|| panic!("no entry for {id}"));
(envelope.key.clone(), entry.schema_name.clone(), entry.schema_version.clone())
})
.collect();
rows.sort();
rows
}
fn copy(from: PathBuf) -> (tempfile::TempDir, SingleFileMessagePackBackingStore) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.cc");
std::fs::copy(from, &path).unwrap();
(dir, SingleFileMessagePackBackingStore::new(&path))
}
#[test]
fn a_consumer_reads_the_version_of_each_type_a_rust_store_holds() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.cc");
let mut cache = CultCache::new();
cache.register_entry_type::<One>().unwrap();
cache.register_entry_type::<Two>().unwrap();
cache.add_backing_store(SingleFileMessagePackBackingStore::new(&path), ["tests.sc_one", "tests.sc_two"]).unwrap();
cache.pull_all_backing_stores().unwrap();
cache.put("a", &One { text: "1".into() }).unwrap();
cache.put("b", &Two { text: "2".into() }).unwrap();
drop(cache);
let consumer = SingleFileMessagePackBackingStore::new(&path);
assert_eq!(
versions(&consumer),
vec![
("a".to_string(), "tests.sc_one".to_string(), "tests.sc_one.v1".to_string()),
("b".to_string(), "tests.sc_two".to_string(), "tests.sc_two.v1".to_string()),
]
);
}
#[test]
fn an_id_only_an_entry_lists_as_compatible_resolves_to_that_entry() {
let (_dir, store) = copy(vectors("document-variants-c2a/readability/compatible-id-only-v3.bin"));
let catalog = store.pull_schema_catalog().unwrap();
assert_eq!(catalog.len(), 1);
assert_eq!(catalog[0].schema_id, "vectors.item.next");
assert_eq!(catalog[0].compatible_schema_ids, vec!["vectors.item".to_string()]);
let entry = resolve_schema_entry(&catalog, "vectors.item").unwrap();
assert_eq!(entry.schema_id, "vectors.item.next");
assert_eq!(entry.schema_version, "vectors.item.v1");
assert_eq!(resolve_schema_entry(&catalog, "vectors.nowhere"), None);
}
#[test]
fn an_id_an_entry_owns_resolves_to_its_owner_not_to_an_entry_listing_it() {
for vector in ["own-id-over-compatible-v3.bin", "compatible-before-owner-v3.bin"] {
let (_dir, store) = copy(vectors("document-variants-c2a/readability").join(vector));
let catalog = store.pull_schema_catalog().unwrap();
assert_eq!(catalog.len(), 2, "{vector}");
let entry = resolve_schema_entry(&catalog, "vectors.item").unwrap();
assert_eq!((entry.schema_id.as_str(), entry.schema_version.as_str()), ("vectors.item", "vectors.item.v1"), "{vector}");
}
}
#[test]
fn a_legacy_array_and_an_absent_file_publish_no_catalog() {
let (_dir, store) = copy(vectors("document-variants-c2a/readability/legacy-envelopes.bin"));
assert!(!store.pull_all().unwrap().is_empty());
assert_eq!(store.pull_schema_catalog().unwrap(), Vec::<SchemaCatalogEntry>::new());
let dir = tempfile::tempdir().unwrap();
let absent = SingleFileMessagePackBackingStore::new(dir.path().join("none.cc"));
assert_eq!(absent.pull_schema_catalog().unwrap(), Vec::<SchemaCatalogEntry>::new());
assert!(!dir.path().join("none.cc").exists() && !dir.path().join("none.cc.lock").exists(), "reading an absent store creates nothing");
}
// `tests/vectors/key-identity/foreign-store.cc` is written by the C# reference: two types, each published with its version.
#[test]
fn a_store_the_csharp_reference_wrote_gives_its_versions() {
let (_dir, store) = copy(vectors("key-identity/foreign-store.cc"));
assert_eq!(
versions(&store),
vec![
("j".to_string(), "tests.ki_alpha".to_string(), "tests.ki_alpha.v1".to_string()),
("k".to_string(), "tests.ki_gamma".to_string(), "tests.ki_gamma.v1".to_string()),
]
);
let catalog = store.pull_schema_catalog().unwrap();
assert_eq!(catalog[0].content_hash, "sha256:02f0c8b7ea07c11705c1365fc29136ada08c8076209cacfb8d5526229461ce7a");
}
type Entry = (&'static str, &'static str, &'static str, &'static str, &'static str, Vec<&'static str>, Vec<()>);
// A hand-built v3 store whose versions are not the `<name>.v1` the writers above publish, so a version read from anywhere but the
// version slot cannot pass. `shared` is listed by two entries (the first lister wins); `id.c` is owned by one entry and listed by
// an earlier one (the owner wins).
#[test]
fn distinct_versions_come_through_with_the_decoders_resolution() {
let catalog: Vec<Entry> = vec![
("id.a", "N.a", "9.9.9", "hash-a", "{}", vec!["id.a", "shared"], vec![]),
("id.b", "N.b", "7.0", "hash-b", "{}", vec!["id.b", "shared", "id.c"], vec![]),
("id.c", "N.c", "3", "hash-c", "{}", vec!["id.c"], vec![]),
];
let record = |key: &str, id: &str| (key.to_string(), id.to_string(), "2026-11-13T00:00:00Z".to_string(), b"x".to_vec());
let records = vec![record("r1", "shared"), record("r2", "id.c"), record("r3", "id.b"), record("r4", "id.a")];
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.cc");
std::fs::write(&path, rmp_serde::to_vec(&("cultcache.store.v3", catalog, records)).unwrap()).unwrap();
let store = SingleFileMessagePackBackingStore::new(&path);
let listed = store.pull_schema_catalog().unwrap();
assert_eq!(listed.iter().map(|e| e.schema_version.as_str()).collect::<Vec<_>>(), vec!["9.9.9", "7.0", "3"]);
assert_eq!(listed[1].content_hash, "hash-b");
assert_eq!(listed[1].compatible_schema_ids, vec!["id.b", "shared", "id.c"]);
let got: Vec<(String, String, String)> = {
let mut rows: Vec<_> = store
.pull_all()
.unwrap()
.iter()
.map(|e| {
let entry = resolve_schema_entry(&listed, e.schema_id.as_deref().unwrap()).unwrap();
(e.key.clone(), entry.schema_name.clone(), entry.schema_version.clone())
})
.collect();
rows.sort();
rows
};
let row = |k: &str, n: &str, v: &str| (k.to_string(), n.to_string(), v.to_string());
assert_eq!(got, vec![row("r1", "N.a", "9.9.9"), row("r2", "N.c", "3"), row("r3", "N.b", "7.0"), row("r4", "N.a", "9.9.9")]);
}