Single-file readers refuse duplicate keys in all four runtimes; c0 bad-canonical row #110
16 changed files with 569 additions and 40 deletions
|
|
@ -245,11 +245,34 @@ def _derive_catalog(records: list[tuple[CultCacheEnvelope, bool]]) -> list[CultC
|
|||
return [entry for entry, _ in chosen.values()]
|
||||
|
||||
|
||||
def _retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConflictError:
|
||||
"""The refusal of a write that would put a second type under a key the store already holds (C# CacheBackingStore.Retyped)."""
|
||||
return SchemaConflictError(
|
||||
_schema_id_for(held),
|
||||
[held.type],
|
||||
held.key,
|
||||
message=(
|
||||
f"Record '{held.key}' is a {held.type} (schema id '{_schema_id_for(held)}'); writing a {written.type} there would "
|
||||
"retype the key. Remove the record first, in the same batch or an earlier commit, then add the other type's."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _encode_snapshot(
|
||||
supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope], format_version: str
|
||||
) -> list[Any]:
|
||||
replaced = {(envelope.type, envelope.key) for envelope in supplied}
|
||||
kept = [envelope for envelope in arrived if (envelope.type, envelope.key) not in replaced]
|
||||
# A key names one record whatever its type, so a supplied record replaces the arrived one by key alone, and only of the
|
||||
# same type: retyping a key is a removal and an add, so a record of another type under the key refuses the write.
|
||||
held_by_key = {envelope.key: envelope for envelope in arrived}
|
||||
landing: dict[str, CultCacheEnvelope] = {}
|
||||
for envelope in supplied:
|
||||
for held in (landing.get(envelope.key), held_by_key.get(envelope.key)):
|
||||
if held is not None and held.type != envelope.type:
|
||||
raise _retyped(held, envelope)
|
||||
landing[envelope.key] = envelope
|
||||
supplied = list(landing.values())
|
||||
replaced = set(landing)
|
||||
kept = [envelope for envelope in arrived if envelope.key not in replaced]
|
||||
envelopes = [*kept, *supplied]
|
||||
catalog = [
|
||||
_encode_catalog_entry(entry)
|
||||
|
|
@ -400,6 +423,7 @@ def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None
|
|||
catalog_by_schema_id.setdefault(compatible_schema_id, entry)
|
||||
|
||||
envelopes: list[CultCacheEnvelope] = []
|
||||
seen: set[str] = set()
|
||||
for raw_record in decoded[2]:
|
||||
if not isinstance(raw_record, list) or len(raw_record) < 4:
|
||||
raise ValueError("CultCache persisted records must be MessagePack arrays")
|
||||
|
|
@ -412,6 +436,9 @@ def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None
|
|||
key, schema_id, stored_at, payload = raw_record
|
||||
if not isinstance(key, str) or not key:
|
||||
raise ValueError("CultCache persisted records must declare a key")
|
||||
if key in seen:
|
||||
raise ValueError(f"CultCache record {key!r} appears more than once")
|
||||
seen.add(key)
|
||||
if not isinstance(schema_id, str) or not schema_id:
|
||||
raise ValueError("CultCache persisted records must declare a schema id")
|
||||
if not isinstance(stored_at, str) or not stored_at:
|
||||
|
|
@ -437,9 +464,13 @@ def _decode_legacy_envelopes(decoded: Any) -> list[CultCacheEnvelope]:
|
|||
if not isinstance(decoded, list):
|
||||
raise ValueError("CultCache MessagePack store is not a recognized snapshot")
|
||||
envelopes: list[CultCacheEnvelope] = []
|
||||
seen: set[Any] = set()
|
||||
for item in decoded:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("CultCache legacy envelopes must be MessagePack maps")
|
||||
if item.get("key") in seen:
|
||||
raise ValueError(f"CultCache record {item['key']!r} appears more than once")
|
||||
seen.add(item.get("key"))
|
||||
try:
|
||||
payload = item["payload"]
|
||||
envelopes.append(
|
||||
|
|
@ -484,12 +515,14 @@ def _decode_catalog_entry(value: Any) -> CultCacheSchemaCatalogEntry:
|
|||
raise ValueError(f'CultCache schema catalog entry "{schema_id}" has invalid compatible schema ids')
|
||||
if not isinstance(members, list):
|
||||
raise ValueError(f'CultCache schema catalog entry "{schema_id}" has invalid members')
|
||||
if not isinstance(canonical_schema_json, str):
|
||||
raise ValueError(f'CultCache schema catalog entry "{schema_id}" has an invalid canonical schema')
|
||||
return CultCacheSchemaCatalogEntry(
|
||||
schema_id=schema_id,
|
||||
schema_name=schema_name,
|
||||
schema_version=schema_version if isinstance(schema_version, str) and schema_version else f"{schema_name}.v1",
|
||||
content_hash=content_hash if isinstance(content_hash, str) and content_hash else schema_id,
|
||||
canonical_schema_json=canonical_schema_json if isinstance(canonical_schema_json, str) else "",
|
||||
canonical_schema_json=canonical_schema_json,
|
||||
compatible_schema_ids=tuple(compatible_schema_ids),
|
||||
members=tuple(_decode_catalog_member(member) for member in members),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1604,6 +1604,99 @@ class CultCacheTests(unittest.TestCase):
|
|||
self.assertIsNotNone(refused.exception.__cause__, f"{vector} {operation}")
|
||||
self.assertEqual(store_path.read_bytes(), content, f"{vector} {operation} rewrote a file it cannot read")
|
||||
|
||||
@staticmethod
|
||||
def _rec(type: str, key: str, second: int = 0) -> CultCacheEnvelope:
|
||||
return CultCacheEnvelope(key=key, type=type, payload=b"\x90", stored_at=f"2026-09-30T00:00:0{second}Z")
|
||||
|
||||
def test_a_supplied_record_replaces_an_arrived_one_of_the_same_type_by_key(self) -> None:
|
||||
import msgpack # type: ignore
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "store.msgpack"
|
||||
store = SingleFileMessagePackBackingStore(path)
|
||||
store.push(self._rec("first", "k"))
|
||||
store.push(self._rec("first", "k", 1))
|
||||
store.push_all([self._rec("first", "k", 2), self._rec("first", "k", 3)])
|
||||
records = msgpack.unpackb(path.read_bytes(), raw=False)[2]
|
||||
self.assertEqual([record[0] for record in records], ["k"])
|
||||
self.assertEqual([(e.type, e.stored_at) for e in store.pull_all()], [("first", "2026-09-30T00:00:03Z")])
|
||||
|
||||
def test_a_write_that_would_retype_a_key_is_refused_and_leaves_the_file(self) -> None:
|
||||
from cultcache_py import SchemaConflictError
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "store.msgpack"
|
||||
store = SingleFileMessagePackBackingStore(path)
|
||||
store.push(self._rec("first", "k"))
|
||||
content = path.read_bytes()
|
||||
for run in (
|
||||
lambda: store.push(self._rec("second", "k", 1)),
|
||||
lambda: store.push_all([self._rec("other", "j"), self._rec("second", "k", 1)]),
|
||||
):
|
||||
with self.assertRaises(SchemaConflictError) as refused:
|
||||
run()
|
||||
self.assertEqual(refused.exception.record_key, "k")
|
||||
self.assertEqual(refused.exception.schema_names, ["first"])
|
||||
self.assertIn("would retype the key", str(refused.exception))
|
||||
self.assertEqual(path.read_bytes(), content)
|
||||
# Retyping is a removal and an add.
|
||||
store.delete("first", "k")
|
||||
store.push(self._rec("second", "k", 1))
|
||||
self.assertEqual([e.type for e in SingleFileMessagePackBackingStore(path).pull_all()], ["second"])
|
||||
|
||||
def test_a_batch_naming_one_key_under_two_types_is_refused_whole(self) -> None:
|
||||
from cultcache_py import SchemaConflictError
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "store.msgpack"
|
||||
store = SingleFileMessagePackBackingStore(path)
|
||||
with self.assertRaises(SchemaConflictError):
|
||||
store.push_all([self._rec("first", "k"), self._rec("second", "k", 1)])
|
||||
self.assertFalse(path.exists())
|
||||
|
||||
def test_a_cache_put_of_a_second_type_under_a_key_is_refused_and_persists_nothing_new(self) -> None:
|
||||
from cultcache_py import SchemaConflictError
|
||||
|
||||
other = define_document_type("other", encode=lambda item: asdict(item), decode=lambda raw: Item(**raw))
|
||||
document = item_doc()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "store.msgpack"
|
||||
|
||||
def build() -> CultCache:
|
||||
cache = (
|
||||
CultCache.builder()
|
||||
.register_document_type(document)
|
||||
.register_document_type(other)
|
||||
.add_generic_store(SingleFileMessagePackBackingStore(path))
|
||||
.build()
|
||||
)
|
||||
cache.pull_all_backing_stores()
|
||||
return cache
|
||||
|
||||
cache = build()
|
||||
cache.put(document, "shared", Item(name="A", category="c", value=1))
|
||||
content = path.read_bytes()
|
||||
with self.assertRaises(SchemaConflictError):
|
||||
cache.put(other, "shared", Item(name="B", category="c", value=2))
|
||||
self.assertEqual(path.read_bytes(), content)
|
||||
self.assertIsNone(cache.get(other, "shared"))
|
||||
reloaded = build()
|
||||
self.assertEqual(reloaded.get_required(document, "shared").value, 1)
|
||||
self.assertIsNone(reloaded.get(other, "shared"))
|
||||
cache.delete(document, "shared")
|
||||
cache.put(other, "shared", Item(name="B", category="c", value=2))
|
||||
self.assertEqual(build().get_required(other, "shared").value, 2)
|
||||
|
||||
def test_a_catalog_entry_with_a_non_string_canonical_schema_is_unreadable(self) -> None:
|
||||
# Direct probe of the c0 vector; its manifest row is added by the TS cut.
|
||||
vector = self._C2A_VECTORS.parent / "document-variants-c0" / "current-catalog-bad-canonical.msgpack"
|
||||
content = vector.read_bytes()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "store.msgpack"
|
||||
path.write_bytes(content)
|
||||
with self.assertRaises(StoreUnreadableError):
|
||||
SingleFileMessagePackBackingStore(path).pull_all()
|
||||
self.assertEqual(path.read_bytes(), content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ use serde::de::DeserializeOwned;
|
|||
use std::any::Any;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
|
|
@ -929,7 +931,8 @@ fn store_header(bytes: &[u8]) -> StoreHeader {
|
|||
}
|
||||
|
||||
/// A write the catalog cannot describe: records of different types under one schema id. This writer derives one entry per carried
|
||||
/// schema id from its records, so that is its only conflict. Nothing is written. It names the id, the two names and a record key.
|
||||
/// schema id from its records, so that is its only conflict besides a single-file write that would put a second type under a
|
||||
/// key the store already holds (the key is the record's identity there). Nothing is written. It names the id, the two names and a record key.
|
||||
#[derive(Debug)]
|
||||
pub struct SchemaConflictError {
|
||||
pub schema_id: String,
|
||||
|
|
@ -1476,6 +1479,7 @@ impl SingleFileMessagePackBackingStore {
|
|||
}
|
||||
|
||||
fn write_all_unlocked(&self, entries: &[CultCacheEnvelope]) -> Result<()> {
|
||||
require_one_record_per_key(entries)?;
|
||||
let failed = |kind| CultCacheStoreWriteFailed {
|
||||
path: self.path.clone(),
|
||||
kind,
|
||||
|
|
@ -1745,6 +1749,7 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore {
|
|||
fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> {
|
||||
self.with_exclusive_lock(|| {
|
||||
let mut entries = self.read_all_unlocked()?;
|
||||
// A write replaces the record of its own type under its key; a second type under the key reaches the commit guard.
|
||||
entries.retain(|candidate| entry_id(candidate) != entry_id(entry));
|
||||
entries.push(entry.clone());
|
||||
entries.sort_by_key(entry_id);
|
||||
|
|
@ -1768,7 +1773,13 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
let mut entries = entries.to_vec();
|
||||
// Of same-type entries sharing a key the last one given is the record; a second type under a key reaches the
|
||||
// commit guard.
|
||||
let mut by_id = BTreeMap::<(String, String), &CultCacheEnvelope>::new();
|
||||
for entry in entries {
|
||||
by_id.insert(entry_id(entry), entry);
|
||||
}
|
||||
let mut entries: Vec<CultCacheEnvelope> = by_id.into_values().cloned().collect();
|
||||
entries.sort_by_key(entry_id);
|
||||
self.write_all_unlocked(&entries)
|
||||
})
|
||||
|
|
@ -3709,7 +3720,41 @@ impl DiskStore {
|
|||
/// The legacy envelope array: an array of envelope maps. Its acceptance is the one verdict on an array whose first slot
|
||||
/// is not a store header, for reading it and for replacing it.
|
||||
fn decode_legacy_envelopes(bytes: &[u8]) -> Result<Vec<CultCacheEnvelope>> {
|
||||
decode_grown(bytes).context("failed to decode legacy CultCache envelope array")
|
||||
let envelopes: Vec<CultCacheEnvelope> =
|
||||
decode_grown(bytes).context("failed to decode legacy CultCache envelope array")?;
|
||||
require_unique_keys(&envelopes)?;
|
||||
Ok(envelopes)
|
||||
}
|
||||
|
||||
/// A record's identity in a store is its key string alone, compared ordinal: a store holding a key twice is unreadable,
|
||||
/// whatever the format of the file that holds it.
|
||||
fn require_unique_keys(envelopes: &[CultCacheEnvelope]) -> Result<()> {
|
||||
let mut keys = HashSet::<&str>::new();
|
||||
for envelope in envelopes {
|
||||
if !keys.insert(envelope.key.as_str()) {
|
||||
bail!("CultCache record {:?} appears more than once", envelope.key);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The commit guard every single-file write passes: a store holds one record per key, so a snapshot about to be written
|
||||
/// may not hold a key twice. Two types under a key are a retype, refused with the key and both types (retyping is
|
||||
/// remove-then-add); the same type twice is a malformed snapshot. Nothing is written.
|
||||
fn require_one_record_per_key(entries: &[CultCacheEnvelope]) -> Result<()> {
|
||||
let mut held = HashMap::<&str, &CultCacheEnvelope>::new();
|
||||
for entry in entries {
|
||||
let Some(first) = held.insert(entry.key.as_str(), entry) else { continue };
|
||||
if first.r#type == entry.r#type {
|
||||
bail!("CultCache snapshot holds record {:?} of type {:?} more than once", entry.key, entry.r#type);
|
||||
}
|
||||
return Err(anyhow::Error::new(SchemaConflictError {
|
||||
schema_id: entry.schema_id.clone().unwrap_or_else(|| entry.r#type.clone()),
|
||||
schema_names: vec![first.r#type.clone(), entry.r#type.clone()],
|
||||
record_key: entry.key.clone(),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decodes a store whose header the caller has already read as a readable one: its envelopes and its catalog.
|
||||
|
|
@ -3748,6 +3793,7 @@ fn decode_store_snapshot(bytes: &[u8]) -> Result<(Vec<CultCacheEnvelope>, Vec<Pe
|
|||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
require_unique_keys(&envelopes)?;
|
||||
Ok((envelopes, snapshot.1))
|
||||
}
|
||||
|
||||
|
|
@ -4219,32 +4265,158 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn settings_one() -> Settings {
|
||||
Settings { theme: "green".to_string(), retries: 1 }
|
||||
}
|
||||
|
||||
fn note_one() -> Note {
|
||||
Note { title: "same key".to_string(), body: "different type".to_string() }
|
||||
}
|
||||
|
||||
fn retype_refusal(error: &anyhow::Error) -> &SchemaConflictError {
|
||||
error.downcast_ref::<SchemaConflictError>().unwrap_or_else(|| panic!("{error:#}"))
|
||||
}
|
||||
|
||||
fn reloaded(path: &Path) -> Result<Vec<CultCacheEnvelope>> {
|
||||
SingleFileMessagePackBackingStore::new(path).pull_all()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_identity_is_polymorphic_by_type_and_key() -> Result<()> {
|
||||
fn a_second_type_under_a_stored_key_is_refused_and_the_store_and_cache_stay() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store_path = temp.path().join("cache.cc");
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_registry(TestEntries)?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
|
||||
|
||||
cache.put(
|
||||
"shared",
|
||||
&Settings {
|
||||
theme: "green".to_string(),
|
||||
retries: 1,
|
||||
},
|
||||
)?;
|
||||
cache.put(
|
||||
"shared",
|
||||
&Note {
|
||||
title: "same key".to_string(),
|
||||
body: "different type".to_string(),
|
||||
},
|
||||
)?;
|
||||
|
||||
assert_eq!(cache.snapshot().len(), 2);
|
||||
assert_eq!(cache.get_required::<Note>("shared")?.title, "same key");
|
||||
cache.put("shared", &settings_one())?;
|
||||
let before = std::fs::read(&store_path)?;
|
||||
let error = cache.put("shared", ¬e_one()).unwrap_err();
|
||||
assert_eq!(retype_refusal(&error).record_key, "shared");
|
||||
assert_eq!(std::fs::read(&store_path)?, before, "the refused write changed the file");
|
||||
assert_eq!(cache.snapshot().len(), 1);
|
||||
assert_eq!(cache.get_required::<Settings>("shared")?.theme, "green");
|
||||
assert!(cache.get::<Note>("shared")?.is_none());
|
||||
|
||||
// Retyping is remove-then-add, and what was written reads back.
|
||||
assert!(cache.delete::<Settings>("shared")?);
|
||||
cache.put("shared", ¬e_one())?;
|
||||
let held = reloaded(&store_path)?;
|
||||
assert_eq!(held.len(), 1);
|
||||
assert_eq!(held[0].r#type, Note::TYPE);
|
||||
|
||||
// The same type under the key replaces it.
|
||||
cache.put("shared", &Note { title: "again".to_string(), body: String::new() })?;
|
||||
let held = reloaded(&store_path)?;
|
||||
assert_eq!(held.len(), 1);
|
||||
assert_eq!(cache.get_required::<Note>("shared")?.title, "again");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_holding_a_second_type_under_a_key_is_refused_whole() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store_path = temp.path().join("batch.cc");
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_registry(TestEntries)?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?;
|
||||
cache.put("kept", &settings_one())?;
|
||||
let before = std::fs::read(&store_path)?;
|
||||
|
||||
let (settings, _) = cache.prepare_entry("shared", &settings_one())?;
|
||||
let (note, _) = cache.prepare_entry("shared", ¬e_one())?;
|
||||
let error = cache.put_prepared_batch(vec![settings.clone(), note.clone()]).unwrap_err();
|
||||
assert_eq!(retype_refusal(&error).record_key, "shared");
|
||||
assert_eq!(std::fs::read(&store_path)?, before);
|
||||
assert_eq!(cache.snapshot().len(), 1);
|
||||
|
||||
// The store's own batch write refuses it too, in either order.
|
||||
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
|
||||
for batch in [[settings.clone(), note.clone()], [note, settings]] {
|
||||
let error = store.push_all(&batch, PushAllOptions::default()).unwrap_err();
|
||||
assert_eq!(retype_refusal(&error).record_key, "shared");
|
||||
assert_eq!(std::fs::read(&store_path)?, before);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stores_own_push_refuses_a_second_type_under_a_key() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store_path = temp.path().join("push.cc");
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_registry(TestEntries)?;
|
||||
let (settings, _) = cache.prepare_entry("shared", &settings_one())?;
|
||||
let (note, _) = cache.prepare_entry("shared", ¬e_one())?;
|
||||
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
|
||||
store.push(&settings)?;
|
||||
let before = std::fs::read(&store_path)?;
|
||||
let error = store.push(¬e).unwrap_err();
|
||||
let mut names = retype_refusal(&error).schema_names.clone();
|
||||
names.sort();
|
||||
assert_eq!(names, vec![Note::TYPE.to_string(), Settings::TYPE.to_string()]);
|
||||
assert_eq!(std::fs::read(&store_path)?, before);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_single_file_writer_commits_a_second_type_under_a_held_key() -> Result<()> {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_registry(TestEntries)?;
|
||||
let (held, _) = cache.prepare_entry("k", &settings_one())?;
|
||||
let (other_key, _) = cache.prepare_entry("c", &settings_one())?;
|
||||
let (note, _) = cache.prepare_entry("k", ¬e_one())?;
|
||||
let absent_note = CultCacheExpectedEnvelope { key: "k".to_string(), r#type: Note::TYPE.to_string(), current: None };
|
||||
type Writer = fn(&mut SingleFileMessagePackBackingStore, &[CultCacheEnvelope; 3], &CultCacheExpectedEnvelope) -> Result<bool>;
|
||||
let writers: Vec<(&str, Writer)> = vec![
|
||||
("push", |s, e, _| s.push(&e[2]).map(|()| true)),
|
||||
("push_all", |s, e, _| s.push_all(&[e[0].clone(), e[2].clone()], PushAllOptions::default()).map(|()| true)),
|
||||
("insert_entry_if_absent", |s, e, _| s.insert_entry_if_absent(e[2].clone())),
|
||||
("compare_exchange", |s, e, x| s.compare_exchange(std::slice::from_ref(x), &[e[2].clone()])),
|
||||
("compare_and_swap_batch", |s, e, _| s.compare_and_swap_batch(&[], vec![e[2].clone()])),
|
||||
("append_if_snapshot_unchanged", |s, e, _| s.append_if_snapshot_unchanged(&[e[0].clone(), e[1].clone()], vec![e[2].clone()])),
|
||||
("replace_and_append_if_snapshot_unchanged", |s, e, _| {
|
||||
s.replace_and_append_if_snapshot_unchanged(&[e[0].clone(), e[1].clone()], vec![e[2].clone()])
|
||||
}),
|
||||
("replace_and_delete_if_snapshot_unchanged", |s, e, _| {
|
||||
s.replace_and_delete_if_snapshot_unchanged(&[e[0].clone(), e[1].clone()], vec![e[2].clone()], std::slice::from_ref(&e[1]))
|
||||
}),
|
||||
("compare_exchange_snapshot", |s, e, _| s.compare_exchange_snapshot(&[e[1].clone(), e[0].clone()], &[e[0].clone(), e[2].clone()])),
|
||||
("try_compare_exchange_snapshot", |s, e, _| {
|
||||
s.try_compare_exchange_snapshot(&[e[1].clone(), e[0].clone()], &[e[0].clone(), e[2].clone()]).map(|_| true)
|
||||
}),
|
||||
];
|
||||
let envelopes = [held.clone(), other_key.clone(), note];
|
||||
for (name, write) in writers {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = temp.path().join("store.cc");
|
||||
let mut store = SingleFileMessagePackBackingStore::new(&path);
|
||||
store.push_all(&[held.clone(), other_key.clone()], PushAllOptions::default())?;
|
||||
let before = std::fs::read(&path)?;
|
||||
let error = write(&mut store, &envelopes, &absent_note).expect_err(name);
|
||||
assert_eq!(retype_refusal(&error).record_key, "k", "{name}");
|
||||
assert_eq!(std::fs::read(&path)?, before, "{name} changed the file");
|
||||
assert!(store.pull_all().is_ok(), "{name}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legacy_array_with_a_key_under_two_types_is_not_decodable() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store_path = temp.path().join("legacy.cc");
|
||||
let record = |kind: &str| {
|
||||
CultCacheEnvelope {
|
||||
key: "a".to_string(),
|
||||
r#type: kind.to_string(),
|
||||
payload: rmp_serde::to_vec(&Vec::<u8>::new()).unwrap(),
|
||||
stored_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
schema_id: None,
|
||||
}
|
||||
};
|
||||
std::fs::write(&store_path, rmp_serde::to_vec_named(&vec![record("old.one"), record("old.two")])?)?;
|
||||
let error = SingleFileMessagePackBackingStore::new(&store_path).pull_all().unwrap_err();
|
||||
assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::Undecodable), "{error:#}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -5908,6 +6080,26 @@ mod tests {
|
|||
}
|
||||
|
||||
|
||||
// The writer keeps one record per key: another type under a held key is refused and the file stays; the same type replaces.
|
||||
#[test]
|
||||
fn a_write_of_another_type_under_an_existing_key_is_refused_and_the_same_type_replaces() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = temp.path().join("store.msgpack");
|
||||
let mut store = SingleFileMessagePackBackingStore::new(&path);
|
||||
let mut other = snapshot_envelope("k", b"two");
|
||||
other.r#type = "snapshot.other".to_string();
|
||||
other.schema_id = Some("snapshot.other".to_string());
|
||||
store.push(&snapshot_envelope("k", b"one"))?;
|
||||
let before = std::fs::read(&path)?;
|
||||
assert!(store.push(&other).unwrap_err().downcast_ref::<SchemaConflictError>().is_some());
|
||||
assert!(store.push_all(&[snapshot_envelope("k", b"one"), other.clone()], PushAllOptions::default()).is_err());
|
||||
assert_eq!(std::fs::read(&path)?, before);
|
||||
store.push(&snapshot_envelope("k", b"three"))?;
|
||||
let read = store.pull_all()?;
|
||||
assert_eq!((read.len(), read[0].payload.as_slice()), (1, b"three".as_slice()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// A file is replaced by a rewrite exactly when this runtime's own reader opens it: one verdict per file, asked by open,
|
||||
// push_all and push alike. The bytes and every runtime's verdict are tests/vectors/document-variants-c2a/readability.
|
||||
// A refusal is a CultCacheStoreUnreadable, and a replaced file carries the header its old content decides.
|
||||
|
|
|
|||
|
|
@ -60,9 +60,8 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
const disk = this.#readDisk();
|
||||
this.#format = disk.format;
|
||||
const existing = disk.envelopes;
|
||||
const filtered = existing.filter(
|
||||
(candidate) => !(candidate.type === entry.type && candidate.key === entry.key),
|
||||
);
|
||||
requireOneTypePerKey(this.filePath, [entry], existing);
|
||||
const filtered = existing.filter((candidate) => candidate.key !== entry.key);
|
||||
await this.#writeAll([entry], filtered);
|
||||
});
|
||||
}
|
||||
|
|
@ -103,7 +102,9 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
this.#format = STORE_FORMAT_VERSION;
|
||||
}
|
||||
|
||||
await this.#writeAll(entries, []);
|
||||
requireOneTypePerKey(this.filePath, entries, []);
|
||||
// A key is held once: of same-type entries sharing a key in one batch, the last is kept.
|
||||
await this.#writeAll([...new Map(entries.map((entry) => [entry.key, entry])).values()], []);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +138,28 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
}
|
||||
}
|
||||
|
||||
// A store holds a key once, and the reader refuses a file that holds it twice, so a write that would put a second type under a
|
||||
// key (against a record on disk, or within its own batch) is refused before anything is written. Retyping a record is a delete
|
||||
// of the old type followed by a put of the new one.
|
||||
function requireOneTypePerKey(path: string, supplied: CultCacheEnvelope[], held: CultCacheEnvelope[]): void {
|
||||
const typeByKey = new Map<string, CultCacheEnvelope>();
|
||||
for (const entry of held) {
|
||||
typeByKey.set(entry.key, entry);
|
||||
}
|
||||
for (const entry of supplied) {
|
||||
const prior = typeByKey.get(entry.key);
|
||||
if (prior !== undefined && prior.type !== entry.type) {
|
||||
throw new SchemaConflictError(
|
||||
schemaIdForEnvelope(entry),
|
||||
[prior.type, entry.type],
|
||||
entry.key,
|
||||
`CultCache store ${path} holds record "${entry.key}" as type "${prior.type}", so type "${entry.type}" cannot be written under the same key; remove the record first.`,
|
||||
);
|
||||
}
|
||||
typeByKey.set(entry.key, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one reader of a CultCache single-file store, for this package's store and for any consumer that must read a
|
||||
* store file (an authority bundle, say) rather than decode it itself. A path with nothing at it is an absent, empty,
|
||||
|
|
@ -204,8 +227,9 @@ type DecodedSnapshot = {
|
|||
};
|
||||
|
||||
function encodeSnapshot(supplied: CultCacheEnvelope[], arrived: CultCacheEnvelope[], format: StoreFormat): unknown[] {
|
||||
const replaced = new Set(supplied.map((entry) => `${entry.type}::${entry.key}`));
|
||||
const kept = arrived.filter((entry) => !replaced.has(`${entry.type}::${entry.key}`));
|
||||
// A key is held once: a supplied record replaces the arrived record under its key whatever the type.
|
||||
const replaced = new Set(supplied.map((entry) => entry.key));
|
||||
const kept = arrived.filter((entry) => !replaced.has(entry.key));
|
||||
const entries = [...kept, ...supplied];
|
||||
const catalog = catalogEntriesFor([
|
||||
...kept.map((envelope) => ({ envelope, registered: false })),
|
||||
|
|
@ -416,6 +440,7 @@ function decodeStoreFile(data: Uint8Array): Omit<SingleFileStoreRead, "absent">
|
|||
throw new Error("CultCache file is not a recognized CultCache MessagePack store.");
|
||||
}
|
||||
|
||||
requireUniqueKeys(legacy);
|
||||
const normalized = legacy.map((entry) => ({ ...entry, payload: normalizePayload(entry.payload) }));
|
||||
return { format: STORE_FORMAT_VERSION, envelopes: envelopeArraySchema.parse(normalized).map((entry) => ({ ...entry, payload: documentPayloadFromBytes(entry.payload) })) as CultCacheEnvelope[] };
|
||||
}
|
||||
|
|
@ -449,9 +474,21 @@ function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined {
|
|||
}
|
||||
|
||||
const records = recordsRaw.map(decodeRecord);
|
||||
requireUniqueKeys(records);
|
||||
return { format: decoded[0], catalogBySchemaId, records };
|
||||
}
|
||||
|
||||
// A store holds a key once, whatever the type: a repeat is refused in a v1 snapshot and in a legacy envelope array alike.
|
||||
function requireUniqueKeys(records: Array<{ key: string }>): void {
|
||||
const seen = new Set<string>();
|
||||
for (const { key } of records) {
|
||||
if (seen.has(key)) {
|
||||
throw new Error(`CultCache record "${key}" appears more than once.`);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeCatalogEntry(value: unknown): CultCacheSchemaCatalogEntry {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error("CultCache schema catalog entries must be MessagePack arrays.");
|
||||
|
|
@ -479,12 +516,16 @@ function decodeCatalogEntry(value: unknown): CultCacheSchemaCatalogEntry {
|
|||
throw new Error(`CultCache schema catalog entry "${schemaId}" has invalid members.`);
|
||||
}
|
||||
|
||||
if (typeof canonicalSchemaJson !== "string") {
|
||||
throw new Error(`CultCache schema catalog entry "${schemaId}" has an invalid canonical schema.`);
|
||||
}
|
||||
|
||||
return {
|
||||
schemaId,
|
||||
schemaName,
|
||||
schemaVersion: isNonEmptyString(schemaVersion) ? schemaVersion : `${schemaName}.v1`,
|
||||
contentHash: isNonEmptyString(contentHash) ? contentHash : schemaId,
|
||||
canonicalSchemaJson: typeof canonicalSchemaJson === "string" ? canonicalSchemaJson : "",
|
||||
canonicalSchemaJson,
|
||||
compatibleSchemaIds,
|
||||
members: members.map(decodeCatalogMember),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -143,6 +143,33 @@ test("CultCache supports registry bootstrap, global documents, and lookup by nam
|
|||
});
|
||||
});
|
||||
|
||||
test("a put that would land a second type under a persisted key is refused, the cache and the file are unchanged, and the store reloads", async () => {
|
||||
const alpha = defineDocumentType({ type: "alpha", schema: z.object({ v: z.number() }) });
|
||||
const beta = defineDocumentType({ type: "beta", schema: z.object({ v: z.number() }) });
|
||||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
|
||||
const registry = defineDocumentRegistry(alpha, beta);
|
||||
const open = () => CultCache.builder().withRegistry(registry).withGenericStore(new SingleFileMessagePackBackingStore(storePath)).build();
|
||||
|
||||
const cache = open();
|
||||
await cache.put(alpha, "shared", { v: 1 });
|
||||
const before = [...(await readFile(storePath))];
|
||||
await assert.rejects(() => cache.put(beta, "shared", { v: 2 }), SchemaConflictError);
|
||||
assert.deepEqual([...(await readFile(storePath))], before);
|
||||
assert.deepEqual(cache.get(alpha, "shared"), { v: 1 });
|
||||
assert.equal(cache.get(beta, "shared"), undefined);
|
||||
|
||||
const reloaded = open();
|
||||
await reloaded.pullAllBackingStores();
|
||||
assert.deepEqual(reloaded.get(alpha, "shared"), { v: 1 });
|
||||
|
||||
assert.equal(await cache.delete(alpha, "shared"), true);
|
||||
await cache.put(beta, "shared", { v: 2 });
|
||||
const retyped = open();
|
||||
await retyped.pullAllBackingStores();
|
||||
assert.equal(retyped.get(alpha, "shared"), undefined);
|
||||
assert.deepEqual(retyped.get(beta, "shared"), { v: 2 });
|
||||
});
|
||||
|
||||
test("CultCache can register name and index lookups after entries already exist", async () => {
|
||||
const noteDocument = defineDocumentType({
|
||||
type: "note",
|
||||
|
|
@ -2582,6 +2609,20 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads",
|
|||
}
|
||||
});
|
||||
|
||||
// One key is held once: a push of a second type under a stored key is refused, so the writer cannot emit a store its own reader refuses.
|
||||
test("SingleFileMessagePackBackingStore push refuses a second type under a stored key", async () => {
|
||||
const seed = CultCache.builder().withDocumentType(ownsId).withDocumentType(listsId).build();
|
||||
await seed.put(ownsId, "k", { name: "owner" });
|
||||
await seed.put(listsId, "k", { name: "lister" });
|
||||
const file = join(await mkdtemp(join(tmpdir(), "cultcache-push-key-")), "store.msgpack");
|
||||
const store = new SingleFileMessagePackBackingStore(file);
|
||||
await store.push(seed.getRequiredEnvelope(ownsId, "k"));
|
||||
await assert.rejects(() => store.push(seed.getRequiredEnvelope(listsId, "k")), SchemaConflictError);
|
||||
const records = (decode(await readFile(file)) as unknown[][])[2] as unknown[][];
|
||||
assert.deepEqual(records.map((record) => [record[0], record[1]]), [["k", "id.owned"]]);
|
||||
assert.equal((await store.pullAll()).length, 1);
|
||||
});
|
||||
|
||||
// Owner beats lister: a definition that owns an id and one that lists it as compatible register together in either order. A record
|
||||
// under the id is the owner's, and a lister's only when nothing owns it; two listers and no owner name no single definition.
|
||||
const ownsId = defineDocumentType({ type: "tests.owns", schema: z.object({ name: z.string() }), schemaId: "id.owned", schemaName: "tests.owns" });
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { decode, encode } from "@msgpack/msgpack";
|
|||
|
||||
import { documentPayloadBytes, documentPayloadFromBytes } from "../src/document";
|
||||
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
|
||||
import { SchemaConflictError, StoreUnreadableError } from "../src/store-format";
|
||||
import type { CultCacheEnvelope } from "../src/types";
|
||||
|
||||
import {
|
||||
|
|
@ -571,15 +572,84 @@ const snapshotOf = async (store: SingleFileMessagePackBackingStore): Promise<str
|
|||
.map((entry) => `${entry.type}/${entry.key}:${[...documentPayloadBytes(entry.payload)].at(-1)}`)
|
||||
.sort();
|
||||
|
||||
test("push replaces the record at the same type and key and keeps every other", async () => {
|
||||
test("push replaces the record under the same key and type and keeps every other; retyping is delete then push", async () => {
|
||||
const directory = await scratch();
|
||||
try {
|
||||
const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc"));
|
||||
await store.pushAll([entryOf("a", "t", 1), entryOf("b", "t", 2), entryOf("a", "u", 3)]);
|
||||
await store.pushAll([entryOf("a", "t", 1), entryOf("b", "t", 2), entryOf("c", "u", 3)]);
|
||||
await store.push(entryOf("a", "t", 9));
|
||||
assert.deepEqual(await snapshotOf(store), ["t/a:9", "t/b:2", "u/a:3"]);
|
||||
await store.push(entryOf("c", "t", 4));
|
||||
assert.deepEqual(await snapshotOf(store), ["t/a:9", "t/b:2", "t/c:4", "u/a:3"]);
|
||||
assert.deepEqual(await snapshotOf(store), ["t/a:9", "t/b:2", "u/c:3"]);
|
||||
await store.push(entryOf("d", "t", 4));
|
||||
assert.deepEqual(await snapshotOf(store), ["t/a:9", "t/b:2", "t/d:4", "u/c:3"]);
|
||||
await store.delete(entryOf("a", "t"));
|
||||
await store.push(entryOf("a", "u", 5));
|
||||
assert.deepEqual(await snapshotOf(store), ["t/b:2", "t/d:4", "u/a:5", "u/c:3"]);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a push that would land a second type under an existing key is refused and the file is byte for byte unchanged", async () => {
|
||||
const directory = await scratch();
|
||||
try {
|
||||
const path = join(directory, "store.cc");
|
||||
const store = new SingleFileMessagePackBackingStore(path);
|
||||
await store.pushAll([entryOf("k", "t", 1), entryOf("m", "t", 2)]);
|
||||
const before = [...(await readFile(path))];
|
||||
await assert.rejects(
|
||||
() => store.push(entryOf("k", "u", 9)),
|
||||
(error: unknown) => error instanceof SchemaConflictError && error.recordKey === "k" && /holds record "k" as type "t"/u.test(error.message),
|
||||
);
|
||||
assert.deepEqual([...(await readFile(path))], before);
|
||||
assert.deepEqual(await snapshotOf(new SingleFileMessagePackBackingStore(path)), ["t/k:1", "t/m:2"]);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a pushAll batch that carries one key under two types is refused and writes nothing", async () => {
|
||||
const directory = await scratch();
|
||||
try {
|
||||
const path = join(directory, "store.cc");
|
||||
const store = new SingleFileMessagePackBackingStore(path);
|
||||
await assert.rejects(() => store.pushAll([entryOf("k", "t", 1), entryOf("k", "u", 2)]), SchemaConflictError);
|
||||
assert.equal((await readdir(directory)).includes("store.cc"), false);
|
||||
await store.pushAll([entryOf("a", "t", 1)]);
|
||||
const before = [...(await readFile(path))];
|
||||
await assert.rejects(() => store.pushAll([entryOf("k", "t", 1), entryOf("k", "u", 2)]), SchemaConflictError);
|
||||
assert.deepEqual([...(await readFile(path))], before);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a pushAll batch that carries one key twice under one type keeps the last and the file reads back with one record", async () => {
|
||||
const directory = await scratch();
|
||||
try {
|
||||
const path = join(directory, "store.cc");
|
||||
await new SingleFileMessagePackBackingStore(path).pushAll([entryOf("k", "t", 1), entryOf("a", "t", 3), entryOf("k", "t", 2)]);
|
||||
assert.deepEqual(await snapshotOf(new SingleFileMessagePackBackingStore(path)), ["t/a:3", "t/k:2"]);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a legacy envelope array that holds a key under two types is refused on open, as a v1 store is", async () => {
|
||||
const directory = await scratch();
|
||||
try {
|
||||
const path = join(directory, "legacy.cc");
|
||||
const bytes = encode([
|
||||
{ key: "a", type: "t", payload: { n: 1 }, storedAt: "2026-10-20T00:00:00Z" },
|
||||
{ key: "a", type: "u", payload: { n: 2 }, storedAt: "2026-10-20T00:00:00Z" },
|
||||
]);
|
||||
await writeFile(path, bytes);
|
||||
const store = new SingleFileMessagePackBackingStore(path);
|
||||
await assert.rejects(
|
||||
() => store.pullAll(),
|
||||
(error: unknown) => error instanceof StoreUnreadableError && /"a" appears more than once/u.test(error.message),
|
||||
);
|
||||
await assert.rejects(() => store.push(entryOf("b")), StoreUnreadableError);
|
||||
assert.deepEqual([...(await readFile(path))], [...bytes]);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -589,11 +659,12 @@ test("delete removes only the record at that type and key", async () => {
|
|||
const directory = await scratch();
|
||||
try {
|
||||
const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc"));
|
||||
await store.pushAll([entryOf("a", "t", 1), entryOf("b", "t", 2), entryOf("a", "u", 3)]);
|
||||
await store.pushAll([entryOf("a", "t", 1), entryOf("b", "t", 2), entryOf("c", "u", 3)]);
|
||||
await store.delete(entryOf("a", "t"));
|
||||
assert.deepEqual(await snapshotOf(store), ["t/b:2", "u/a:3"]);
|
||||
assert.deepEqual(await snapshotOf(store), ["t/b:2", "u/c:3"]);
|
||||
await store.delete(entryOf("zzz", "t"));
|
||||
assert.deepEqual(await snapshotOf(store), ["t/b:2", "u/a:3"]);
|
||||
await store.delete(entryOf("c", "t"));
|
||||
assert.deepEqual(await snapshotOf(store), ["t/b:2", "u/c:3"]);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ public static class CultDocumentMessagePackSerialization
|
|||
/// <summary>
|
||||
/// Reads a store file: exactly one complete MessagePack array of three slots (header, schema catalog, records), or an empty
|
||||
/// array, which is an empty store. A truncated file, bytes after the array, a value that is not an array, a first slot that
|
||||
/// is not a header string, a store with fewer or more slots, a record whose schema the catalog does not publish, or a body
|
||||
/// is not a header string, a store with fewer or more slots, a record whose schema the catalog does not publish, two records under one key, or a body
|
||||
/// that does not decode are refused with
|
||||
/// <see cref="CultStoreUnreadableException"/>, so a reader never takes part of a file for the whole and never skips a slot
|
||||
/// it does not understand. The rewriting paths use this same reader as their verdict on whether a file may be replaced.
|
||||
|
|
@ -366,6 +366,7 @@ public static class CultDocumentMessagePackSerialization
|
|||
}
|
||||
|
||||
RequirePublishedSchemas(snapshot);
|
||||
RequireUniqueKeys(snapshot);
|
||||
return (snapshot, catalogBytes, recordBytes);
|
||||
}
|
||||
catch (CultStoreUnreadableException)
|
||||
|
|
@ -397,6 +398,17 @@ public static class CultDocumentMessagePackSerialization
|
|||
}
|
||||
}
|
||||
|
||||
// A key names one record: identity is the key string alone, compared by ordinal code units (no case folding, no normalisation).
|
||||
private static void RequireUniqueKeys(CultPersistedStoreSnapshot snapshot)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var record in snapshot.Records)
|
||||
{
|
||||
if (!seen.Add(record.Key))
|
||||
throw new CultStoreUnreadableException($"Record '{record.Key}' appears more than once.");
|
||||
}
|
||||
}
|
||||
|
||||
// Slot 0 of a non-empty store, read before anything after it: a binary or nil stamp is refused as such, and a string stamp is
|
||||
// judged by the caller's rule when it passes one. Bytes that do not open with an array and a string are left to the full read.
|
||||
private static void JudgeStamp(byte[] payload, Action<string>? requireReadable)
|
||||
|
|
|
|||
|
|
@ -74,6 +74,18 @@ namespace GameCult.Caching.Tests
|
|||
return path;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ADuplicateKeyIsRefusedNamingTheKeyAndTheFile()
|
||||
{
|
||||
var path = Path.Combine(_directory, "dup.cc");
|
||||
File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), "dup-key-same-schema-v3.bin")));
|
||||
|
||||
Assert.That(() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }),
|
||||
Throws.TypeOf<CultStoreUnreadableException>()
|
||||
.With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path)
|
||||
.And.Message.Contains("more than once"));
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(Vectors))]
|
||||
public void OpenReadsExactlyTheFilesTheVectorsSayItReads(string vector, bool reads)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
“²cultcache.store.v3‘—¬vectors.item¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item<65>“”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta”¥Alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha
|
||||
|
|
@ -0,0 +1 @@
|
|||
“²cultcache.store.v3‘—¬vectors.item¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item<65>’”¢Ã©¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”£eÌ<65>¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta
|
||||
|
|
@ -0,0 +1 @@
|
|||
“²cultcache.store.v3‘—¬vectors.item¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item<65>“”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha
|
||||
|
|
@ -0,0 +1 @@
|
|||
“²cultcache.store.v3’—¬vectors.item¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item<65>—vectors.othervectors.other°vectors.other.v1vectors.otherÙN{"schemaName":"vectors.other","schemaVersion":"vectors.other.v1","members":[]}‘vectors.other<65>“”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¥alphavectors.other¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta
|
||||
|
|
@ -0,0 +1 @@
|
|||
“²cultcache.store.v3‘—¬vectors.item¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item<65>“”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha
|
||||
|
|
@ -5,6 +5,7 @@ A file may be replaced by a flush or a commit exactly when the runtime's own rea
|
|||
covers open, flush and commit. Each vector is the bytes named; the trailing and truncated ones are cut from v3-base.
|
||||
"""
|
||||
import pathlib
|
||||
import unicodedata
|
||||
|
||||
import msgpack
|
||||
|
||||
|
|
@ -12,12 +13,21 @@ here = pathlib.Path(__file__).resolve().parent
|
|||
whole = (here.parent / "v3-base.msgpack").read_bytes()
|
||||
pack = lambda value: msgpack.packb(value, use_bin_type=True)
|
||||
base = msgpack.unpackb(whole, raw=False)
|
||||
# A second published schema, and a record of it under the key alpha.
|
||||
other_entry = ["vectors.other", "vectors.other", "vectors.other.v1", "vectors.other", '{"schemaName":"vectors.other","schemaVersion":"vectors.other.v1","members":[]}', ["vectors.other"], []]
|
||||
other_record = ["alpha", "vectors.other"] + base[2][0][2:]
|
||||
# The same visible key as U+00E9 (NFC) and U+0065 U+0301 (NFD): equal after normalisation, unequal ordinally.
|
||||
nfc, nfd = "\u00e9", "e\u0301"
|
||||
assert unicodedata.normalize("NFC", nfd) == unicodedata.normalize("NFC", nfc) and nfc != nfd
|
||||
assert "Alpha".casefold() == "alpha" and "Alpha" != "alpha"
|
||||
|
||||
vectors = {
|
||||
"zero-byte": b"",
|
||||
"empty-array": pack([]),
|
||||
# A legacy envelope array: an array of maps. Rust, TypeScript and Python read it; C# has no legacy reader.
|
||||
"legacy-envelopes": pack([{"key": "old", "type": "old.type", "payload": pack([]), "storedAt": "2026-01-01T00:00:00Z"}]),
|
||||
# A legacy envelope array holding key a under two types: a key is one record, whatever the type. Refused like a v3 store with a shared key.
|
||||
"legacy-dup-key": pack([{"key": "a", "type": "old.one", "payload": pack([]), "storedAt": "2026-01-01T00:00:00Z"}, {"key": "a", "type": "old.two", "payload": pack([]), "storedAt": "2026-01-01T00:00:00Z"}]),
|
||||
"legacy-trailing": None, # filled below: the legacy array plus bytes after it
|
||||
"ints": pack([1, 2, 3]),
|
||||
"nil-first": pack([None, "x"]),
|
||||
|
|
@ -55,6 +65,16 @@ vectors = {
|
|||
"variant-slot-v3": pack([base[0], base[1], base[2] + [["gamma"] + base[2][0][1:3] + [b"", ["alpha", [[0, [[0, ""]], "", "gamma"]]]]]]),
|
||||
# v3-base with a fourth top-level slot after the records.
|
||||
"extra-top-slot": pack(base + [0]),
|
||||
# A store holding two records under one key is unreadable, whatever else they agree or differ on. Every record names a
|
||||
# schema the catalog publishes, so the shared key is the only defect.
|
||||
"dup-key-same-schema-v3": pack([base[0], base[1], base[2] + [base[2][0]]]),
|
||||
# Two records under one key, each naming a different published schema: the key alone is the identity.
|
||||
"dup-key-other-schema-v3": pack([base[0], base[1] + [other_entry], [base[2][0], other_record, base[2][1]]]),
|
||||
# alpha twice with different payload bytes: last-wins would silently pick one.
|
||||
"dup-key-different-payload-v3": pack([base[0], base[1], base[2] + [base[2][0][:3] + [b"\x92\xa5alpha\x09"]]]),
|
||||
# Keys are ordinal strings: no case folding and no Unicode normalisation, so these are distinct keys and the store reads.
|
||||
"distinct-keys-case-v3": pack([base[0], base[1], base[2] + [["Alpha"] + base[2][0][1:]]]),
|
||||
"distinct-keys-nfd-v3": pack([base[0], base[1], [[nfc] + base[2][0][1:], [nfd] + base[2][1][1:]]]),
|
||||
}
|
||||
vectors["legacy-trailing"] = vectors["legacy-envelopes"] + b"\x01\x02\x03"
|
||||
for name, data in vectors.items():
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
’„£key¡a¤type§old.one§payloadÄ<01>¨storedAt´2026-01-01T00:00:00Z„£key¡a¤type§old.two§payloadÄ<01>¨storedAt´2026-01-01T00:00:00Z
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
# Whether each runtime's own reader opens the file. A flush and a commit replace a file exactly when it reads; where a runtime has a soft flush (TypeScript, Rust) it leaves a file that reads byte for byte and refuses one that does not.
|
||||
# A store holding two records under one key is unreadable; keys are ordinal strings (no case folding, no normalisation), so distinct spellings are distinct keys.
|
||||
# Columns: file, csharp, rust, typescript, python. `../v3-base.msgpack` is the one valid store.
|
||||
../v3-base.msgpack reads reads reads reads
|
||||
zero-byte.bin refuses refuses refuses refuses
|
||||
empty-array.bin reads reads reads reads
|
||||
legacy-envelopes.bin refuses reads reads reads
|
||||
legacy-dup-key.bin refuses refuses refuses refuses
|
||||
legacy-trailing.bin refuses refuses refuses refuses
|
||||
ints.bin refuses refuses refuses refuses
|
||||
nil-first.bin refuses refuses refuses refuses
|
||||
|
|
@ -30,3 +32,9 @@ missing-schema-versioned-v3.bin refuses refuses refuses refuses
|
|||
compatible-id-only-v3.bin reads reads reads reads
|
||||
own-id-over-compatible-v3.bin reads reads reads reads
|
||||
compatible-before-owner-v3.bin reads reads reads reads
|
||||
dup-key-same-schema-v3.bin refuses refuses refuses refuses
|
||||
dup-key-other-schema-v3.bin refuses refuses refuses refuses
|
||||
dup-key-different-payload-v3.bin refuses refuses refuses refuses
|
||||
distinct-keys-case-v3.bin reads reads reads reads
|
||||
distinct-keys-nfd-v3.bin reads reads reads reads
|
||||
../../document-variants-c0/current-catalog-bad-canonical.msgpack refuses refuses refuses refuses
|
||||
|
|
|
|||
Loading…
Reference in a new issue