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/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 15011491..c32773d9 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -14,6 +14,10 @@ only in git history. - `bool` index and lookup text renders `True` / `False`, as C# does, where it rendered `true` / `false`. - `DatabaseEntry::UNIQUE_INDEXES` replaces `cult_indexes` as the one index declaration and changes type (its members are the unique aliases); a hand implementation overriding `cult_indexes` no longer compiles. - `register_entry_type` over a type that already holds records re-judges them under the new declaration (unique indexes and the global rule) and refuses, changing nothing, when they violate it. A declaration with an index also refuses when a held record cannot be decoded as the new type; a declaration without one does not read the records, so it does not. +- A single-file store holds one record per key. The v1 and legacy readers refuse a store with two records under one key (`CultCacheStoreUnreadable`, the record `Undecodable`): the store does not open, and a write over it refuses and leaves the file. A consumer that holds such a file must repair it with the runtime that wrote it before opening it here. +- Every single-file writer (`push`, `push_all`, `insert_entry_if_absent`, the compare-exchange and snapshot-append family, and the cache's puts and batches) refuses to put a second type under a key the store holds, with `SchemaConflictError` naming the key and both types, writing nothing. Retyping a key is a delete, then an add (one batch may hold both). A write owns the held record when its schema id equals the id the held record is stored under, as C# `WrittenOwns` asks; a type renamed under a stable id therefore replaces its own record, and a write of another type name under another id is refused (`push` of the same type name under another id still replaces the record, as before; `compare_and_swap_entry`, and a `push_all` batch holding one type under two ids, refuse it). Registered types here declare no compatible ids, so equality is the whole test. Writers used to leave two records under the key. +- `compare_and_swap_entry` over a single file refuses an in-place retype (expected `A/k`, replacement `B/k` under another schema id) with the same `SchemaConflictError`, as `compare_exchange` does, where it used to retype the record and return `true`. A lost race still returns `false` first. +- A write of the same type under a held key replaces the record by key, and in one `push_all` the last given of several entries under a key and a schema id is the record, whatever their type names. The redb stores are unchanged and still key by type and key. ### Added diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 8984cd29..868589a1 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1187,6 +1187,10 @@ impl SingleFileMessagePackBackingStore { if entries[index] != *expected { return Ok(false); } + // An in-place retype is a retype: the replacement must own the schema id the held record is stored under. + if schema_identity(&entries[index]) != schema_identity(&replacement) { + return Err(retype_conflict(&entries[index], &replacement)); + } entries[index] = replacement; entries.sort_by_key(entry_id); self.write_all_unlocked(&entries)?; @@ -1479,7 +1483,6 @@ 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, @@ -1495,7 +1498,8 @@ impl SingleFileMessagePackBackingStore { Err(error) if io_not_found(&error) => DiskStore::absent(), other => other?, }; - let bytes = encode_store_snapshot(entries, &disk) + let entries = settle_one_record_per_key(entries, &disk)?; + let bytes = encode_store_snapshot(&entries, &disk) .and_then(|snapshot| rmp_serde::to_vec(&snapshot).context("failed to encode MessagePack")) .map_err(|error| error.context(failed(CultCacheStoreWriteFailedKind::Rejected)))?; self.replace_unlocked(&bytes) @@ -1773,11 +1777,11 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore { return Ok(()); } - // Of same-type entries sharing a key the last one given is the record; a second type under a key reaches the - // commit guard. + // Of entries sharing a key and a schema id the last one given is the record, whatever their type names (a type + // renamed under a stable id); a second schema id 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); + by_id.insert((schema_identity(entry).to_string(), entry.key.clone()), entry); } let mut entries: Vec = by_id.into_values().cloned().collect(); entries.sort_by_key(entry_id); @@ -3738,23 +3742,47 @@ fn require_unique_keys(envelopes: &[CultCacheEnvelope]) -> Result<()> { 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(); +/// What a record's stored schema id is: the id it carries, else its type (a registered type's id is its entry type). This +/// runtime's registered types declare no compatible ids, so a written record owns a held one exactly when the ids are equal: +/// the question C# `WrittenOwns` asks of the stored id, not a comparison of type names. +fn schema_identity(entry: &CultCacheEnvelope) -> &str { + entry.schema_id.as_deref().unwrap_or(&entry.r#type) +} + +/// The refusal of a write that would retype a key: the written record does not own the schema id the held one is stored under. +/// Retyping is remove-then-add (variants:ruling:cache-refuses-retype). +fn retype_conflict(held: &CultCacheEnvelope, written: &CultCacheEnvelope) -> anyhow::Error { + anyhow::Error::new(SchemaConflictError { + schema_id: schema_identity(written).to_string(), + schema_names: vec![held.r#type.clone(), written.r#type.clone()], + record_key: written.key.clone(), + }) +} + +/// The commit guard every single-file write passes: a store holds one record per key. Two entries under a key whose schema +/// ids differ are a retype and are refused with the key and both types, nothing written. Two under one schema id are one +/// record: a written entry replaces the held one it owns (a type renamed under a stable id), the held one being what the file +/// already carries. +fn settle_one_record_per_key(entries: &[CultCacheEnvelope], disk: &DiskStore) -> Result> { + let arrived: BTreeMap<(String, String), &CultCacheEnvelope> = disk.envelopes.iter().map(|entry| (entry_id(entry), entry)).collect(); + let held = |entry: &CultCacheEnvelope| arrived.get(&entry_id(entry)) == Some(&entry); + let mut settled = Vec::<&CultCacheEnvelope>::new(); + let mut at_key = HashMap::<&str, usize>::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); + match at_key.get(entry.key.as_str()) { + None => { + at_key.insert(entry.key.as_str(), settled.len()); + settled.push(entry); + } + Some(&slot) if schema_identity(settled[slot]) != schema_identity(entry) => return Err(retype_conflict(settled[slot], entry)), + Some(&slot) => { + if !held(entry) || held(settled[slot]) { + settled[slot] = entry; + } + } } - 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(()) + Ok(settled.into_iter().cloned().collect()) } /// Decodes a store whose header the caller has already read as a readable one: its envelopes and its catalog. @@ -4372,6 +4400,7 @@ mod tests { ("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_and_swap_entry", |s, e, _| s.compare_and_swap_entry(&e[0], 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()])), @@ -6624,6 +6653,93 @@ mod tests { Ok(()) } + // The retype refusal asks what C# WrittenOwns asks: does the written record own the schema id the held one is stored under. + // A type renamed under a stable id owns it, so its write replaces the record; another id does not, and is refused. + #[test] + fn a_write_owning_the_stored_schema_id_replaces_the_record_under_a_new_name() -> Result<()> { + let envelope = |r#type: &str, schema_id: &str, payload: &[u8]| CultCacheEnvelope { + key: "a".into(), + r#type: r#type.into(), + payload: payload.to_vec(), + stored_at: "2026-09-30T00:00:00Z".into(), + schema_id: Some(schema_id.into()), + }; + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + store_file(&path, &[("t.s", "t.old")], &[("a", "t.s", b"one".to_vec())])?; + let before = std::fs::read(&path)?; + let error = SingleFileMessagePackBackingStore::new(&path).push(&envelope("t.other", "t.other", b"x")).unwrap_err(); + assert_eq!(retype_refusal(&error).record_key, "a"); + assert_eq!(std::fs::read(&path)?, before); + + SingleFileMessagePackBackingStore::new(&path).push(&envelope("t.s", "t.s", b"two"))?; + let held = SingleFileMessagePackBackingStore::new(&path).pull_all()?; + assert_eq!(held.len(), 1); + assert_eq!((held[0].r#type.as_str(), held[0].payload.as_slice()), ("t.s", b"two".as_slice())); + Ok(()) + } + + // Where a held record and a written one share a schema id under different type names, the written one is the record + // whichever name sorts first: the writers hand settle_one_record_per_key their entries sorted by type name. + #[test] + fn a_renamed_type_replaces_the_held_record_whether_its_name_sorts_before_or_after() -> Result<()> { + for new_name in ["a.new", "z.new"] { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + store_file(&path, &[("t.s", "t.old")], &[("a", "t.s", b"one".to_vec())])?; + let written = CultCacheEnvelope { + key: "a".into(), + r#type: new_name.into(), + payload: b"two".to_vec(), + stored_at: "2026-09-30T00:00:00Z".into(), + schema_id: Some("t.s".into()), + }; + SingleFileMessagePackBackingStore::new(&path).push(&written)?; + let held = SingleFileMessagePackBackingStore::new(&path).pull_all()?; + assert_eq!(held.len(), 1, "{new_name}"); + assert_eq!(held[0].payload, b"two".to_vec(), "{new_name}"); + } + Ok(()) + } + + // In one push_all, entries under a key and a schema id with different type names resolve by the order given, not by + // the order of the names. + #[test] + fn push_all_resolves_same_id_entries_with_different_names_by_the_order_given() -> Result<()> { + let envelope = |r#type: &str, payload: &[u8]| CultCacheEnvelope { + key: "a".into(), + r#type: r#type.into(), + payload: payload.to_vec(), + stored_at: "2026-09-30T00:00:00Z".into(), + schema_id: Some("t.s".into()), + }; + for (first, second) in [("a.name", "b.name"), ("b.name", "a.name")] { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + let mut store = SingleFileMessagePackBackingStore::new(&path); + store.push_all(&[envelope(first, b"first"), envelope(second, b"second")], PushAllOptions::default())?; + let held = store.pull_all()?; + assert_eq!(held.len(), 1, "{first} then {second}"); + assert_eq!(held[0].payload, b"second".to_vec(), "{first} then {second}"); + } + Ok(()) + } + + // Of same-type entries sharing a key in one push_all the last one given is the record. + #[test] + fn push_all_keeps_the_last_of_two_same_type_entries_under_a_key() -> Result<()> { + let mut cache = CultCache::new(); + cache.register_registry(TestEntries)?; + let (first, _) = cache.prepare_entry("k", &settings_one())?; + let (last, _) = cache.prepare_entry("k", &Settings { theme: "red".to_string(), retries: 2 })?; + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.cc"); + let mut store = SingleFileMessagePackBackingStore::new(&path); + store.push_all(&[first, last.clone()], PushAllOptions::default())?; + assert_eq!(store.pull_all()?.into_iter().map(|e| e.payload).collect::>(), vec![last.payload]); + Ok(()) + } + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[cultcache(type = "tests.stable", schema = "tests.old_name")] struct StableId { diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 1d0e81a2..8a4dd9c4 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -14,6 +14,10 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following an empty string is no longer a value. `registerIndex` over held duplicates installs nothing. A new `roles` map names the many-row selection aliases (never indexed for lookup, same catalog `indexAlias`). The per-key migration rule is in the README; classify every key before the CultLib your tools load is updated. The refusal verdict is the contract, its text is informational. +- The single-file reader (v1 and the legacy envelope array) refuses a store that holds two records under one key as `StoreUnreadableError`; a store that opened before may now be refused. Repair it by removing the extra record with the runtime that wrote it. +- A `put`, `push` or `pushAll` that would land a second type under a held key (against the file, or within one batch) is refused with `SchemaConflictError`, and nothing is written. The written type may replace the record when its catalog entry carries the stored schema id as its own or lists it compatible (a type renamed under a stable id restamps the record, as C# does); otherwise retyping a key is a delete, then a put. The C# allowance for an undeclared older id of the same schema name that reads whole is not ported: a same-named type keeps passing, including one that drops persisted slots, which C# refuses. +- Same-type writes replace by key; in one `pushAll` batch the last entry for a key wins. +- A store whose canonical-schema catalog slot is not a string is refused as `StoreUnreadableError`. ### Changed 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 4fdb2fb6..3f484ad8 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -138,6 +138,14 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { } } +// As C# WrittenOwns' declared half: the written type owns the stored schema id when its catalog entry carries that id as its own +// or lists it compatible, so a type renamed under a stable id restamps the record it already holds. (The C# half that lets an +// undeclared older id of the same schema name through when it reads whole is not ported; a same-named type keeps passing.) +function writtenOwns(written: CultCacheEnvelope, storedId: string): boolean { + const entry = written.catalogEntry; + return entry !== undefined && (entry.schemaId === storedId || (entry.compatibleSchemaIds ?? []).includes(storedId)); +} + // 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. @@ -148,7 +156,7 @@ function requireOneTypePerKey(path: string, supplied: CultCacheEnvelope[], held: } for (const entry of supplied) { const prior = typeByKey.get(entry.key); - if (prior !== undefined && prior.type !== entry.type) { + if (prior !== undefined && prior.type !== entry.type && !writtenOwns(entry, schemaIdForEnvelope(prior))) { throw new SchemaConflictError( schemaIdForEnvelope(entry), [prior.type, entry.type], diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index d7780482..2675545e 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -14,7 +14,7 @@ import { z } from "zod"; import { CultCache } from "../src/cult-cache"; import { inspectCultCacheBytes } from "../src/cult-cache-inspector"; import { defineDocumentRegistry, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; -import { SingleFileMessagePackBackingStore, readStoreBytes } from "../src/single-file-messagepack-backing-store"; +import { SingleFileMessagePackBackingStore, readSingleFileStore, readStoreBytes } from "../src/single-file-messagepack-backing-store"; import { SchemaConflictError, StoreUnreadableError, refuseUnreadStoreBytes } from "../src/store-format"; import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types"; @@ -170,6 +170,36 @@ test("a put that would land a second type under a persisted key is refused, the assert.deepEqual(retyped.get(beta, "shared"), { v: 2 }); }); +test("a put by a type renamed under the stored id, which lists that id compatible, restamps the record; an unrelated type is refused", async () => { + const old = defineDocumentType({ type: "ren.old", schema: z.object({ v: z.number() }), schemaId: "ren.id.v1", schemaName: "ren.old" }); + const renamed = defineDocumentType({ + type: "ren.new", + schema: z.object({ v: z.number() }), + schemaId: "ren.id.v2", + schemaName: "ren.new", + compatibleSchemaIds: ["ren.id.v2", "ren.id.v1"], + }); + const other = defineDocumentType({ type: "ren.other", schema: z.object({ v: z.number() }), schemaId: "ren.id.other", schemaName: "ren.other" }); + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack"); + const open = (...types: (typeof old)[]) => + types + .reduce((builder, type) => builder.withDocumentType(type), CultCache.builder()) + .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) + .build(); + + await open(old).put(old, "k", { v: 1 }); + const cache = open(renamed, other as unknown as typeof old); + await cache.pullAllBackingStores(); + assert.deepEqual(cache.get(renamed, "k"), { v: 1 }); + await assert.rejects(() => cache.put(other, "k", { v: 9 }), SchemaConflictError); + await cache.put(renamed, "k", { v: 2 }); + + const reloaded = open(renamed); + await reloaded.pullAllBackingStores(); + assert.deepEqual(reloaded.get(renamed, "k"), { v: 2 }); + assert.equal(readSingleFileStore(storePath).envelopes.filter((envelope) => envelope.key === "k").length, 1); +}); + test("CultCache can register name and index lookups after entries already exist", async () => { const noteDocument = defineDocumentType({ type: "note", @@ -2609,17 +2639,22 @@ 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 () => { +// One key is held once: a push whose type does not own the stored schema id is refused, so the writer cannot emit a store its own +// reader refuses; one whose type owns it (its own id, or one it lists compatible, as C# WrittenOwns) restamps the record in place. +test("SingleFileMessagePackBackingStore push asks whether the written type owns the stored id, not what the types are called", 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"]]); + const idsOf = async () => ((decode(await readFile(file)) as unknown[][])[2] as unknown[][]).map((record) => [record[0], record[1]]); + + await store.push(seed.getRequiredEnvelope(listsId, "k")); + await assert.rejects(() => store.push(seed.getRequiredEnvelope(ownsId, "k")), SchemaConflictError); + assert.deepEqual(await idsOf(), [["k", "id.lister"]]); + + await store.pushAll([seed.getRequiredEnvelope(listsId, "k")]); + await store.push(seed.getRequiredEnvelope(listsId, "k")); assert.equal((await store.pullAll()).length, 1); }); diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index b5ff6419..225bb3ae 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. A directory store reads its manifest through the same reader and refuses a duplicate key the same way. Repair the duplicate with the runtime that wrote it before reading the store here. + ## [3.0.0] - 2026-11-11 ### Breaking