diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index d440ddca..e0026b8e 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -9,6 +9,10 @@ ### Breaking +- A single-file store holding two records under one key is unreadable: the v1 reader and the legacy envelope-array reader refuse it with `StoreUnreadableError` (a key is compared by its exact code points, no case folding or normalisation). A catalog slot whose canonical schema is not a string is refused the same way. A store that loaded before can now be refused: repair it with the runtime that wrote the duplicate. +- A `put` or `push_all` that would land a second type under a key the store holds is refused with `SchemaConflictError`, and the file is left as it was. Retype a key by deleting it, then putting the new type. A write replaces the record at a key only when the written type owns it: its catalog entry declares the stored schema id (its own, or one it lists compatible), so a renamed type that lists the old id compatible may replace the record, or it has the stored record's schema name. This runtime does not compare shapes, so a same-name type is never refused for dropping persisted slots as C# refuses it. +- Writes of the same type replace by key: the last record wins, in a batch as in the file. + - `indexes` are unique, as C# `[CultIndex]`: a write, load or `register_index` that leaves two records of a type holding one non-blank value under one alias is refused (`CultCacheError`) naming both keys and the alias, and writes nothing. Index text is the C# canonical text (`True`/`False`, integers as digits, floats as the exact expansion, blank values never indexed), so an empty or whitespace string is no longer a value. A new `roles` map names the many-row selection aliases (never indexed for lookup, same catalog `index_alias`). Classify every `indexes` key per the README before updating. The verdict is the contract, the text is informational. - A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index db24b3bd..e0cfdd7a 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -258,16 +258,26 @@ def _retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConfl ) +def _written_owns(held: CultCacheEnvelope, written: CultCacheEnvelope) -> bool: + """Whether a write of `written` may replace `held` at its key (C# WrittenOwns): the written type's entry declares the held + record's schema id (its own id or one it lists compatible), or it is the type of the same schema name. The same-name half + stands in for C#'s "reads the published entry whole": this runtime does not compare shapes, so it never refuses a + same-name type for dropping slots.""" + stored_id = _schema_id_for(held) + entry = written.catalog_entry if written.catalog_entry is not None else _default_catalog_entry(written) + return stored_id == entry.schema_id or stored_id in entry.compatible_schema_ids or held.type == written.type + + def _encode_snapshot( supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope], format_version: str ) -> list[Any]: # 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. + # type that owns it: retyping a key is a removal and an add, so a record another type owns 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: + if held is not None and not _written_owns(held, envelope): raise _retyped(held, envelope) landing[envelope.key] = envelope supplied = list(landing.values()) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 2be0a84b..80abbd4f 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -1644,6 +1644,25 @@ class CultCacheTests(unittest.TestCase): store.push(self._rec("second", "k", 1)) self.assertEqual([e.type for e in SingleFileMessagePackBackingStore(path).pull_all()], ["second"]) + def test_a_retype_is_judged_by_the_stored_schema_id_the_written_type_declares_not_by_name(self) -> None: + from cultcache_py import SchemaConflictError + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + store = SingleFileMessagePackBackingStore(path) + store.push(self._writer_record("k", "old", "id-old", self._writer_entry("id-old", "old", "h1", ("id-old",)))) + content = path.read_bytes() + # A renamed type that lists the stored id compatible owns the record, so its write replaces it. + renamed = self._writer_record("k", "new", "id-new", self._writer_entry("id-new", "new", "h2", ("id-new", "id-old"))) + # A type that lists nothing of the stored id and has another name does not. + stranger = self._writer_record("k", "stranger", "id-x", self._writer_entry("id-x", "stranger", "h3", ("id-x",))) + for run in (lambda: store.push(stranger), lambda: store.push_all([stranger])): + with self.assertRaises(SchemaConflictError): + run() + self.assertEqual(path.read_bytes(), content) + store.push(renamed) + self.assertEqual([(e.type, e.schema_id) for e in SingleFileMessagePackBackingStore(path).pull_all()], [("new", "id-new")]) + def test_a_batch_naming_one_key_under_two_types_is_refused_whole(self) -> None: from cultcache_py import SchemaConflictError diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index b5ff6419..1ed481a7 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this package are documented in this file. +## Unreleased + +### Breaking + +- A single-file store holding two records under one key is unreadable: the reader refuses it with `CultStoreUnreadableException` naming the key (keys compare by ordinal code units, no case folding or normalisation), so a cache, `ReadStoreSnapshot` and a flush refuse the file and leave it as it is. Before, the later record silently replaced the earlier. The directory store's manifest reader is not changed. Repair the duplicate with the runtime that wrote it before reading the store here. + ## [3.0.0] - 2026-11-11 ### Breaking