Py retype refusal asks schema-id ownership; CHANGELOGs record the duplicate-key refusals
The Python single-file writer refuses a retype when the written type's entry does not declare the stored schema id (own or compatible) and the schema names differ, as C# WrittenOwns' declared half asks, instead of comparing names alone. A renamed type listing the old id compatible may now replace the record. cultcache-py and org.gamecult.cultlib CHANGELOGs gain the #110 reader/writer refusals under Unreleased. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0ebc7d63e8
commit
4d6f950f87
4 changed files with 41 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue