From f96a0e7f7a275bb87ee0a3f57ec67c212d62279b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 08:17:59 +0200 Subject: [PATCH] Catalog writers merge entries that share an id; every reader resolves an id by its owner first Entries that share a schema id are one schema seen by different writers, so the writer lists every id any of them lists, in any order: C# single-file and directory (CultSchemaCatalogEntry.MergeById), TypeScript and Python. Rust synthesises one entry per id and has no such merge. An id that one entry owns and another lists as compatible names the owner in Rust, TypeScript, Python and the TS inspector, as C# already did (own-id-over-compatible-v3). Also: a directory manifest in an unreadable format is the typed refusal naming it, cultnet-py applies and reports a record under the local schema id, and Rust's error prints its cause once. Vectors for the Mesh layout discriminator: a nil and an integer at catalog slot 5. Co-Authored-By: Claude Sonnet 5.5 --- .../cultcache-py/src/cultcache_py/stores.py | 22 +++++-- packages/cultcache-py/tests/test_cultcache.py | 35 ++++++++++ packages/cultcache-rs/src/lib.rs | 44 +++++++++---- .../cultcache-ts/src/cult-cache-inspector.ts | 11 +++- .../single-file-messagepack-backing-store.ts | 31 +++++++-- packages/cultcache-ts/test/cult-cache.test.ts | 55 ++++++++++++++++ .../cultnet-py/src/cultnet_py/replication.py | 15 ++--- packages/cultnet-py/tests/test_cultnet.py | 3 +- .../DirectoryMessagePackBackingStore.cs | 15 +++-- src/GameCult.Caching/CultCache.cs | 31 ++++++++- src/GameCult.Caching/GameCult.Caching.csproj | 1 + .../PreCut2StoreFormatTests.cs | 2 + .../StoreReadabilityTests.cs | 61 ++++++++++++++++++ .../CultMeshVariantRefusalTests.cs | 2 + .../current-catalog-bad-slot5-int.msgpack | Bin 0 -> 905 bytes .../current-catalog-bad-slot5-nil.msgpack | Bin 0 -> 905 bytes .../vectors/document-variants-c0/generate.py | 6 ++ .../readability/generate.py | 2 + .../readability/manifest.txt | 1 + .../readability/own-id-over-compatible-v3.bin | 1 + 20 files changed, 290 insertions(+), 48 deletions(-) create mode 100644 tests/vectors/document-variants-c0/current-catalog-bad-slot5-int.msgpack create mode 100644 tests/vectors/document-variants-c0/current-catalog-bad-slot5-nil.msgpack create mode 100644 tests/vectors/document-variants-c2a/readability/own-id-over-compatible-v3.bin diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 0ad56f70..3c6d61c6 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -4,6 +4,7 @@ import base64 import json import os import threading +from dataclasses import replace from pathlib import Path from typing import Any @@ -172,7 +173,16 @@ def _encode_snapshot(envelopes: list[CultCacheEnvelope], format_version: str) -> if supplied is not None and (supplied.schema_id == schema_id or schema_id in supplied.compatible_schema_ids) else _default_catalog_entry(envelope) ) - catalog_by_schema_id.setdefault(published.schema_id, published) + # Entries that share an id are one schema seen by different writers: the entry written lists every id any of them lists. + existing = catalog_by_schema_id.get(published.schema_id) + catalog_by_schema_id[published.schema_id] = ( + published + if existing is None + else replace( + existing, + compatible_schema_ids=tuple(dict.fromkeys((*existing.compatible_schema_ids, *published.compatible_schema_ids))), + ) + ) catalog = [ _encode_catalog_entry(entry) @@ -200,12 +210,14 @@ def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None if not isinstance(decoded[1], list) or not isinstance(decoded[2], list): raise ValueError("CultCache v1 snapshot must contain a schema catalog and record array") + # An id names the entry that owns it; only an id no entry owns names the entry that lists it as compatible. + entries = [_decode_catalog_entry(raw_entry) for raw_entry in decoded[1]] catalog_by_schema_id: dict[str, CultCacheSchemaCatalogEntry] = {} - for raw_entry in decoded[1]: - entry = _decode_catalog_entry(raw_entry) - catalog_by_schema_id[entry.schema_id] = entry + for entry in entries: + catalog_by_schema_id.setdefault(entry.schema_id, entry) + for entry in entries: for compatible_schema_id in entry.compatible_schema_ids: - catalog_by_schema_id[compatible_schema_id] = entry + catalog_by_schema_id.setdefault(compatible_schema_id, entry) envelopes: list[CultCacheEnvelope] = [] for raw_record in decoded[2]: diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 2099acb1..4596c5ab 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -315,6 +315,41 @@ class CultCacheTests(unittest.TestCase): self.assertEqual([entry[0] for entry in stored[1]], ["tests.store-publishes.record"]) self.assertEqual(len(SingleFileMessagePackBackingStore(path).pull_all()), 1) + # Entries that share a schema id are one schema seen by different writers: the catalog written lists every id any of them lists, + # whichever order the records arrive in. + def test_the_store_writer_merges_the_compatible_ids_of_entries_that_share_a_schema_id_in_either_order(self) -> None: + import msgpack # type: ignore + from cultcache_py import CultCacheSchemaCatalogEntry + from cultcache_py.stores import _encode_snapshot + + def entry(compatible: tuple[str, ...]) -> CultCacheSchemaCatalogEntry: + return CultCacheSchemaCatalogEntry( + schema_id="tests.merge.x", schema_name="tests.merge", schema_version="tests.merge.v1", + content_hash="tests.merge.x", canonical_schema_json="", compatible_schema_ids=compatible, members=(), + ) + + def record(key: str, schema_id: str, compatible: tuple[str, ...]) -> CultCacheEnvelope: + return CultCacheEnvelope( + key=key, type="tests.merge", payload=msgpack.packb({"key": key}, use_bin_type=True), + stored_at="2026-09-30T00:00:00Z", schema_id=schema_id, catalog_entry=entry(compatible), + ) + + a = record("a", "tests.merge.x", ("tests.merge.x",)) + b = record("b", "tests.merge.y", ("tests.merge.x", "tests.merge.y")) + for order in ([a, b], [b, a]): + catalog = _encode_snapshot(order, "cultcache.store.v1")[1] + self.assertEqual([sorted(entry[5]) for entry in catalog], [["tests.merge.x", "tests.merge.y"]]) + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + SingleFileMessagePackBackingStore(path).push_all([b]) + SingleFileMessagePackBackingStore(path).push(a) + self.assertEqual(len(SingleFileMessagePackBackingStore(path).pull_all()), 2) + + def test_an_id_an_entry_owns_names_that_entry_not_one_that_lists_it_as_compatible(self) -> None: + store = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "readability" / "own-id-over-compatible-v3.bin") + self.assertEqual([envelope.type for envelope in store.pull_all()], ["vectors.item", "vectors.item"]) + def test_a_record_loaded_under_a_compatible_schema_id_is_written_back_under_the_id_its_catalog_entry_carries(self) -> None: import msgpack # type: ignore diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index f7a63a0f..2fa649b0 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -379,7 +379,7 @@ impl StoreUnreadableError { impl std::fmt::Display for StoreUnreadableError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "failed to decode MessagePack {}: {:#}", self.path.display(), self.source) + write!(formatter, "failed to decode MessagePack {}", self.path.display()) } } @@ -2669,16 +2669,16 @@ fn decode_store_snapshot(bytes: &[u8]) -> Result> { let snapshot: PersistedStoreSnapshot = rmp_serde::from_slice(bytes).context("failed to decode CultCache v1 snapshot")?; - let catalog = snapshot - .1 - .into_iter() - .flat_map(|entry| { - // A schema is published by its id or as a compatible id. - let published: Vec = std::iter::once(entry.0).chain(entry.5).collect(); - let name = entry.1; - published.into_iter().map(move |id| (id, name.clone())) - }) - .collect::>(); + // A schema id names the entry that has it as its own id; only an id no entry owns names the entry that lists it as compatible. + let mut catalog = BTreeMap::::new(); + for entry in &snapshot.1 { + catalog.entry(entry.0.clone()).or_insert_with(|| entry.1.clone()); + } + for entry in &snapshot.1 { + for compatible in &entry.5 { + catalog.entry(compatible.clone()).or_insert_with(|| entry.1.clone()); + } + } snapshot .2 .into_iter() @@ -4801,8 +4801,10 @@ mod tests { if !reads { let error = outcome.unwrap_err(); let typed = error.downcast_ref::().unwrap_or_else(|| panic!("{what}: {error:#}")); - let cause = std::error::Error::source(typed).unwrap_or_else(|| panic!("{what}: the cause is the source")); - assert!(typed.to_string().contains(&cause.to_string()), "{what}: the message carries the cause: {typed}"); + let cause = std::error::Error::source(typed).unwrap_or_else(|| panic!("{what}: the cause is the source")).to_string(); + // The message is the error's own; the cause is printed once, by the chain. + assert!(!typed.to_string().contains(&cause), "{what}: the message repeats the cause: {typed}"); + assert_eq!(format!("{error:#}").matches(&cause).count(), 1, "{what}: {error:#}"); assert_eq!(std::fs::read(&path)?, bytes, "{what} rewrote a file it cannot read"); } else if operation != "open" { assert_eq!(store_header(&std::fs::read(&path)?)?.as_deref(), Some(header), "{what}"); @@ -4821,6 +4823,22 @@ mod tests { Ok(()) } + // An id one entry owns and a later entry lists as compatible names the entry that owns it. + #[test] + fn an_id_an_entry_owns_names_that_entry_not_one_that_lists_it_as_compatible() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + std::fs::copy( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/vectors/document-variants-c2a/readability/own-id-over-compatible-v3.bin"), + &path, + )?; + let envelopes = SingleFileMessagePackBackingStore::new(&path).pull_all()?; + assert_eq!(envelopes.len(), 2); + assert!(envelopes.iter().all(|envelope| envelope.r#type == "vectors.item"), "{envelopes:?}"); + Ok(()) + } + // A store cut short is refused whatever its version, and left as it is. #[test] fn a_truncated_v1_or_v2_store_is_refused_on_open_and_rewrite() -> Result<()> { diff --git a/packages/cultcache-ts/src/cult-cache-inspector.ts b/packages/cultcache-ts/src/cult-cache-inspector.ts index 09eb9ef1..ad684859 100644 --- a/packages/cultcache-ts/src/cult-cache-inspector.ts +++ b/packages/cultcache-ts/src/cult-cache-inspector.ts @@ -68,11 +68,18 @@ function inspectV1Snapshot(filePath: string, fileSizeBytes: number, decoded: [St } const catalog = catalogRaw.map(decodeCatalogEntry); + // An id names the entry that owns it; only an id no entry owns names the entry that lists it as compatible. const catalogBySchemaId = new Map(); for (const entry of catalog) { - catalogBySchemaId.set(entry.schemaId, entry); + if (!catalogBySchemaId.has(entry.schemaId)) { + catalogBySchemaId.set(entry.schemaId, entry); + } + } + for (const entry of catalog) { for (const compatibleSchemaId of entry.compatibleSchemaIds) { - catalogBySchemaId.set(compatibleSchemaId, entry); + if (!catalogBySchemaId.has(compatibleSchemaId)) { + catalogBySchemaId.set(compatibleSchemaId, entry); + } } } const records = recordsRaw.map((record) => decodeV1Record(record, catalogBySchemaId)); diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 263c5764..46af9bbc 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -239,9 +239,19 @@ function catalogEntriesFor(entries: CultCacheEnvelope[]): Map(); - for (const entry of catalogRaw) { - const catalogEntry = decodeCatalogEntry(entry); - catalogBySchemaId.set(catalogEntry.schemaId, catalogEntry); + for (const catalogEntry of catalogEntries) { + if (!catalogBySchemaId.has(catalogEntry.schemaId)) { + catalogBySchemaId.set(catalogEntry.schemaId, catalogEntry); + } + } + for (const catalogEntry of catalogEntries) { for (const compatibleSchemaId of catalogEntry.compatibleSchemaIds ?? []) { - catalogBySchemaId.set(compatibleSchemaId, catalogEntry); + if (!catalogBySchemaId.has(compatibleSchemaId)) { + catalogBySchemaId.set(compatibleSchemaId, catalogEntry); + } } } diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 2e865443..951b3896 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -1344,6 +1344,61 @@ test("the store publishes a record's schema id even when the envelope's catalog assert.equal((await new SingleFileMessagePackBackingStore(file).pullAll()).length, 1); }); +// Entries that share a schema id are one schema seen by different writers: the catalog written lists every id any of them lists, +// whichever order the records arrive in. +test("the store writer merges the compatible ids of entries that share a schema id, in either order", async () => { + const entry = (compatibleSchemaIds: string[]) => ({ + schemaId: "tests.merge.x", + schemaName: "tests.merge", + schemaVersion: "tests.merge.v1", + contentHash: "tests.merge.x", + canonicalSchemaJson: "", + compatibleSchemaIds, + members: [], + }); + const record = (key: string, schemaId: string, compatibleSchemaIds: string[]) => ({ + key, + type: "tests.merge", + schemaId, + payload: encode({ key }), + storedAt: "2026-09-30T00:00:00.0000000Z", + catalogEntry: entry(compatibleSchemaIds), + }); + const a = record("a", "tests.merge.x", ["tests.merge.x"]); + const b = record("b", "tests.merge.y", ["tests.merge.x", "tests.merge.y"]); + const dir = await mkdtemp(join(tmpdir(), "cultcache-merge-")); + + for (const [name, order] of [["ab", [a, b]], ["ba", [b, a]]] as const) { + const file = join(dir, `${name}.msgpack`); + await new SingleFileMessagePackBackingStore(file).pushAll([...order]); + const decoded = decode(await readFile(file)) as [string, unknown[][], unknown[][]]; + assert.deepEqual(decoded[1].map((catalogEntry) => (catalogEntry[5] as string[]).slice().sort()), [["tests.merge.x", "tests.merge.y"]], name); + assert.equal((await new SingleFileMessagePackBackingStore(file).pullAll()).length, 2, name); + } + + // A put over a key of a file whose other record is stored under the wider entry's compatible id. + const file = join(dir, "push.msgpack"); + await new SingleFileMessagePackBackingStore(file).pushAll([b]); + await new SingleFileMessagePackBackingStore(file).push(a); + assert.equal((await new SingleFileMessagePackBackingStore(file).pullAll()).length, 2); +}); + +test("an id an entry owns names that entry, not one that lists it as compatible", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-own-id-")); + const file = await copyVector(dir, join(c2aVectors, "readability", "own-id-over-compatible-v3.bin"), "own.msgpack"); + const envelopes = await new SingleFileMessagePackBackingStore(file).pullAll(); + assert.deepEqual(envelopes.map((envelope) => envelope.type), ["vectors.item", "vectors.item"]); + const inspection = inspectCultCacheBytes("own.msgpack", await readFile(file)); + assert.deepEqual(inspection.records.map((record) => record.schemaName), ["vectors.item", "vectors.item"]); +}); + +// The inspector reads what the reader reads: a record under an id its catalog lists only as compatible is inspected. +test("CultCache inspector reads a record whose schema the catalog publishes only as a compatible id", async () => { + const bytes = await readFile(join(c2aVectors, "readability", "compatible-id-only-v3.bin")); + const inspection = inspectCultCacheBytes("compatible.msgpack", bytes); + assert.deepEqual(inspection.records.map((record) => record.schemaName), ["vectors.item", "vectors.item"]); +}); + test("a record loaded under a compatible schema id is written back under the id its catalog entry carries", async () => { const file = join(await mkdtemp(join(tmpdir(), "cultcache-foreign-id-")), "store.msgpack"); const payload = encode({ name: "old" }); diff --git a/packages/cultnet-py/src/cultnet_py/replication.py b/packages/cultnet-py/src/cultnet_py/replication.py index 42a59f9d..bb26a8c8 100644 --- a/packages/cultnet-py/src/cultnet_py/replication.py +++ b/packages/cultnet-py/src/cultnet_py/replication.py @@ -37,11 +37,9 @@ def apply_raw_document_record( ) -> CultNetAppliedRecord: wire = record.to_wire() if isinstance(record, CultNetRawDocumentRecord) else record schema_id = str(wire["schemaId"]) - document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( - documents_by_schema_id, - schema_id, - wire, - ) + document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, wire) + # The record is applied, stamped and reported under the local schema id, as the cache stores it and a delete reports it. + resolved_schema_id = document.catalog_entry().schema_id envelope = raw_record_to_envelope(document, resolved_schema_id, wire) value = cache.put_envelope(document, envelope) return CultNetAppliedRecord( @@ -82,11 +80,8 @@ def apply_raw_snapshot( if not isinstance(record, dict): continue schema_id = str(record["schemaId"]) - document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( - documents_by_schema_id, - schema_id, - record, - ) + document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, record) + resolved_schema_id = document.catalog_entry().schema_id envelope = raw_record_to_envelope(document, resolved_schema_id, record) batch = batches.setdefault(document.type, (document, [])) batch[1].append((resolved_schema_id, envelope)) diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index fd12e9a4..b8aad39d 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -1818,7 +1818,8 @@ class CultNetTests(unittest.TestCase): return cache cache = open_cache() - apply_raw_document_record(cache, schema_document_map([document]), record) + applied = apply_raw_document_record(cache, schema_document_map([document]), record) + self.assertEqual(applied.schema_id, document.catalog_entry().schema_id) self.assertEqual( cache.get_required_envelope(document, "policy:1").schema_id, document.catalog_entry().schema_id ) diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 15f4423c..7063e3dc 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -251,11 +251,11 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore currentIndex[key] = ToIndexRecord(stored); } - var catalogCandidates = currentManifest.SchemaCatalog - .Concat(_durableCatalog) - .Concat(Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry())) - .GroupBy(entry => entry.SchemaId, StringComparer.Ordinal) - .Select(group => group.Last()) + var catalogCandidates = CultSchemaCatalogEntry.MergeById( + currentManifest.SchemaCatalog + .Concat(_durableCatalog) + .Concat(Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry())), + preferLast: true) .ToArray(); var usedSchemaIds = currentIndex.Values.Select(record => record.SchemaId).ToHashSet(StringComparer.Ordinal); var targetCatalog = catalogCandidates @@ -464,8 +464,9 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore if (!string.Equals(snapshot.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) && !string.Equals(snapshot.FormatVersion, IndexedFormatVersionWithIds, StringComparison.Ordinal)) { - throw new InvalidOperationException( - $"Directory store {_manifestFile.FullName} is {snapshot.FormatVersion}; only {IndexedFormatVersion} and {IndexedFormatVersionWithIds} are readable."); + throw new CultStoreUnreadableException( + $"Directory store {_manifestFile.FullName} is {snapshot.FormatVersion}; only {IndexedFormatVersion} and {IndexedFormatVersionWithIds} are readable.", + _manifestFile.FullName); } return snapshot; diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 2a695857..ff753ce8 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -28,6 +28,33 @@ namespace GameCult.Caching public string[] CompatibleSchemaIds { get; set; } = Array.Empty(); public CultSchemaMemberCatalogEntry[] Members { get; set; } = Array.Empty(); + + // One entry per schema id, whatever order the candidates come in: entries that share an id are one schema seen by different + // writers, so the entry written lists every id any of them lists, and its other fields are the first's (or the last's). + internal static IEnumerable MergeById(IEnumerable entries, bool preferLast = false) + { + foreach (var group in entries.GroupBy(entry => entry.SchemaId, StringComparer.Ordinal)) + { + var members = group.ToArray(); + var chosen = preferLast ? members[^1] : members[0]; + if (members.Length == 1) + { + yield return chosen; + continue; + } + + yield return new CultSchemaCatalogEntry + { + SchemaId = chosen.SchemaId, + SchemaName = chosen.SchemaName, + SchemaVersion = chosen.SchemaVersion, + ContentHash = chosen.ContentHash, + CanonicalSchemaJson = chosen.CanonicalSchemaJson, + CompatibleSchemaIds = members.SelectMany(entry => entry.CompatibleSchemaIds).Distinct(StringComparer.Ordinal).ToArray(), + Members = chosen.Members + }; + } + } } public sealed class CultSchemaMemberCatalogEntry @@ -3637,9 +3664,7 @@ namespace GameCult.Caching var snapshot = new CultPersistedStoreSnapshot { FormatVersion = HeaderFor(holdsElementIds, existingHeader, wholeStore, directoryStore: false, holdsVariants: ordered.Any(record => record.Variant != null)), - SchemaCatalog = catalog - .GroupBy(entry => entry.SchemaId, StringComparer.Ordinal) - .Select(group => group.First()) + SchemaCatalog = CultSchemaCatalogEntry.MergeById(catalog) // An entry stays while it publishes a schema id some record carries, as its own id or a compatible one. .Where(entry => used.Contains(entry.SchemaId) || entry.CompatibleSchemaIds.Any(used.Contains)) .OrderBy(entry => entry.SchemaName, StringComparer.Ordinal) diff --git a/src/GameCult.Caching/GameCult.Caching.csproj b/src/GameCult.Caching/GameCult.Caching.csproj index 51253f8f..91c9133b 100644 --- a/src/GameCult.Caching/GameCult.Caching.csproj +++ b/src/GameCult.Caching/GameCult.Caching.csproj @@ -7,6 +7,7 @@ + diff --git a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs index d00630af..848ecb1b 100644 --- a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs +++ b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs @@ -127,6 +127,8 @@ namespace GameCult.Caching.Tests [TestCase("current-catalog-bad-canonical.msgpack")] [TestCase("current-catalog-bad-members.msgpack")] [TestCase("current-catalog-bad-compat.msgpack")] + [TestCase("current-catalog-bad-slot5-nil.msgpack")] + [TestCase("current-catalog-bad-slot5-int.msgpack")] public void SingleFileRefusesAMalformedCurrentCatalogEntry(string vector) { Assert.That(Refusal(vector), Is.Not.Empty); diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs index 2351f9d9..d80ee40d 100644 --- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs +++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs @@ -155,6 +155,67 @@ namespace GameCult.Caching.Tests Assert.That(rewritten.Records.Select(record => record.SchemaId), Does.Contain("vectors.old.id")); } + // An id one entry owns and a later entry lists as compatible names the entry that owns it. + [Test] + public void AnIdAnEntryOwnsNamesThatEntryNotOneThatListsItAsCompatible() + { + var path = Path.Combine(_directory, "own-id.cc"); + File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), "own-id-over-compatible-v3.bin"))); + + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + Assert.That(cache.Get(new CultRecordKey("alpha")), Is.Not.Null, "the record read as the schema that owns its id"); + } + + // A directory rewrite where the entry that lists a kept record's id shares its own id with the schema being written keeps that + // id in the entry it writes, so the manifest still reads. + [Test] + public void ADirectoryRewriteMergesEntriesThatShareAnIdAndKeepsEveryIdTheyList() + { + var path = DirectoryStore("merged.cc"); + var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var entry = manifest.SchemaCatalog.Single(); + manifest.Records.Single().SchemaId = "vectors.old.id"; + entry.CompatibleSchemaIds = new[] { entry.SchemaId, "vectors.old.id" }; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(manifest)); + + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true })) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))); + + var rewritten = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + Assert.That(rewritten.SchemaCatalog.Single().CompatibleSchemaIds, Does.Contain("vectors.old.id")); + } + + // Entries that share an id are merged whatever order they arrive in: every id any of them lists survives. + [Test] + public void EntriesThatShareAnIdMergeTheirCompatibleIdsInEitherOrder() + { + CultSchemaCatalogEntry Entry(params string[] compatible) => new() + { + SchemaId = "x", SchemaName = "n", SchemaVersion = "n.v1", ContentHash = "h", CompatibleSchemaIds = compatible + }; + var plain = Entry("x"); + var wide = Entry("x", "y"); + + foreach (var order in new[] { new[] { plain, wide }, new[] { wide, plain } }) + foreach (var preferLast in new[] { false, true }) + { + var merged = CultSchemaCatalogEntry.MergeById(order, preferLast).Single(); + Assert.That(merged.CompatibleSchemaIds, Is.EquivalentTo(new[] { "x", "y" })); + } + } + + // A directory manifest in a format the directory store does not read is refused with the typed exception, naming it. + [Test] + public void ADirectoryManifestInAnotherFormatIsRefusedNamingIt() + { + var path = Path.Combine(_directory, "wrong-format.cc"); + File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), "..", "v3-base.msgpack"))); + + Assert.That( + () => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true }), + Throws.TypeOf().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path)); + } + // A commit that lands onto the file as it is keeps the header the file carries: a store already v3 stays v3. [TestCase("../v3-base.msgpack", "cultcache.store.v3")] [TestCase("compatible-id-only-v3.bin", "cultcache.store.v3")] diff --git a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs index 579414ee..44d71051 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs @@ -61,6 +61,8 @@ public sealed class CultMeshVariantRefusalTests [TestCase("current-catalog-bad-canonical.msgpack")] [TestCase("current-catalog-bad-members.msgpack")] [TestCase("current-catalog-bad-compat.msgpack")] + [TestCase("current-catalog-bad-slot5-nil.msgpack")] + [TestCase("current-catalog-bad-slot5-int.msgpack")] public void ReadSingleFileDocument_RefusesAMalformedCurrentCatalogEntry(string vector) { Refusal(vector).Path.Should().Be(VectorPath(vector)); diff --git a/tests/vectors/document-variants-c0/current-catalog-bad-slot5-int.msgpack b/tests/vectors/document-variants-c0/current-catalog-bad-slot5-int.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..5f21bf61a4abde1bde4b002564a05eb25baff250 GIT binary patch literal 905 zcmcJN&uSDg9LFslD!zqttlQz2WM-0?n|Kf}9$fKKJY=aZ{f?m*HsD}>O@ zXah2*Tq?s#lCeAiEsRjyU`9*ENR}p4A?HM99F-^)Fk#X>;X?d0)sgQzbn&D^)q5IO z9-CkH-Ujl9tEDy!N|jR(NscIT)T!p66lXv=aL}5koT$_YE_6w9Aco0OC26h{sOzoC zr^tm+jeH{;jEd-9WHeegy~oZ~t^HSDPz$XMHf89GioLMAZhch1D6%)}2g)e&)5!`p zp1Ke-9{aAc`(d(jZ_!|fR)wmOwZpJY+Ue%tWw(!h{Fkup>pBctjYlu)N(Sos#@z(+ zU5~TTtq9qOyli{x1M;rPYwH<(7?B~)?89t(e){O^%I*7|^vmVq_u_QxZ1B2Rmj<0s zSuWK0?l?I-zDrFNehw`EQ=Fhl+x@uKml<1WPUb4A0J-~ Z1|`;Y`_VnL10e}dZXKS^zpb%5e*hvlElU6Z literal 0 HcmV?d00001 diff --git a/tests/vectors/document-variants-c0/current-catalog-bad-slot5-nil.msgpack b/tests/vectors/document-variants-c0/current-catalog-bad-slot5-nil.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..f6fc971f4bbdee2789633cbcbe6ef4ce77f8fccf GIT binary patch literal 905 zcmcJNO=}b}7{@JM#BbpobvrzHnMr1D;z7K4aK(#w$RtnNftk!Q$!zvQy;n>(YXPjyjf~Z`emX!u3ZB}q0<#$^jhoMKG&U(~CplKbj z{c+=MB=5LJ8Oxy31qI0qL|LH8i~y|#11dm(F(MO0XI2VnDpCNkOjVj^h1Q^d@6FyN zK89-5bJb!}CO0CJ$+qbg_P*}izxtwDY;CcvVqa3ej=P&KBqdA|ce#F~Op-94ZD7;6 zk1^9}=xcWnCu{!#E%xYitXkE0jN6o(?+!ur2N?b;-E8l{J5!Aq;7BgWuVXx zxR_jtke$erZg3$YubVt`fzjI$8RIP8ELO|YJD)agU$3QKuU9`;r+a6k*ZHcq-= zwLJN<`4OdZM|HD%{{Q`U7Y``Vd>@K^!d{ZBB)nvK%7IZz^G5(Bz^{ADvk`jt