Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
5f5364e4dc Lower System.Text.Json floor to 8.0.5 (#140) 2026-11-12 12:16:58 +00:00
f4fe424d41 CultCache interop: C# union and nested-bytes store read by Rust read-only, and back (#139) 2026-11-12 12:16:52 +00:00
a23ac7755a Rust mesh: pin that a snapshot spanning backing stores is refused whole (#137) 2026-11-12 10:31:13 +00:00
2fd80c274b Rust mesh: a raw snapshot pull is one cache commit (mesh-snapshot-atomic) (#134) 2026-11-12 09:47:38 +00:00
b098d5b7da TypeScript: pushAll writes onto the store instead of replacing it (ts-flush-replaces-store) (#136) 2026-11-12 09:47:33 +00:00
fbeb773d35 Rust: batch writes merge onto the store (rs-batch-merges-onto-store) (#135) 2026-11-12 08:45:52 +00:00
12f3801893 Rust: a cache must pull its stores before it writes (cache-must-pull-before-write) (#133) 2026-11-12 08:01:32 +00:00
66bea107d5 TypeScript: a payload is one MessagePack value (bytes-after-ts) (#130) 2026-11-12 06:02:51 +00:00
363a8d6fe5 Rust: a payload is one MessagePack value (bytes-after-rs) (#131) 2026-11-12 05:43:55 +00:00
13ce5ea50a Python: a payload is one MessagePack value (bytes-after-py) (#129) 2026-11-12 05:23:25 +00:00
676f611c1f TypeScript: a record with unread slots is held read-only (slot-readonly-ts) (#128) 2026-11-12 05:03:29 +00:00
09b825d689 Rust: a record with unread slots is held read-only (slot-readonly-rs) (#127) 2026-11-12 04:44:03 +00:00
892765ba97 Python: a record with unread slots is held read-only (slot-readonly-py) (#126) 2026-11-12 04:24:07 +00:00
5e10909fe9 Bytes after the value: a payload is one MessagePack value (C#) (#125) 2026-11-12 04:01:36 +00:00
a8c2825745 Slot tolerance: a record with unread slots is held read-only, not shed (#124) 2026-11-12 02:45:08 +00:00
c371832736 Rust cache: one record per key, foreign carry, key-identity vector walked (#123) 2026-11-12 01:05:46 +00:00
a0184785a2 Python cache: one record per key, foreign carry, key-identity vector walked (#122) 2026-11-12 00:45:11 +00:00
7a63290469 TypeScript cache: one record per key, foreign carry, key-identity vector walked (#121) 2026-11-12 00:03:19 +00:00
6d1684e1c2 Key-identity vector: retype refusal, foreign carry, key in two stores (C# rows, 12) (#120) 2026-11-11 23:42:09 +00:00
109 changed files with 8206 additions and 853 deletions

View file

@ -899,23 +899,23 @@
{
"label": "trailing_nil",
"inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdsA=",
"accepted": true,
"reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=",
"recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
"accepted": false,
"reencodedBase64": null,
"recordKey": null
},
{
"label": "trailing_reserved_code",
"inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdsE=",
"accepted": true,
"reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=",
"recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
"accepted": false,
"reencodedBase64": null,
"recordKey": null
},
{
"label": "trailing_truncated_array",
"inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdpI=",
"accepted": true,
"reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=",
"recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
"accepted": false,
"reencodedBase64": null,
"recordKey": null
},
{
"label": "top_nil",

View file

@ -259,7 +259,7 @@ landed in `ead5260c`, Python in `5618d8dd`, Rust in `c028c511`. TypeScript is no
| Runtime | Owner of the decision (constant and walker) | Doors routed through it |
|---|---|---|
| C# | `CultPayloadDepth.Check` (`src/GameCult.Caching.MessagePack/CultPayloadDepth.cs`), limit `CultMessagePackSecurity.MaximumPayloadDepth`. MessagePack's own graph counter is set to the same value as a backstop only. | Write: `CultDocumentMessagePackSerialization.SerializeUntyped`. Read: `DeserializeUntyped`. The cache store (`SerializePayload`/`DeserializePayload`), the directory store, `CultMesh` and single-file documents all call those two; C# has no separate raw-envelope or prepared-batch door. |
| Rust | `cultcache_rs::check_payload_depth` and `MAX_PAYLOAD_DEPTH` (`packages/cultcache-rs/src/lib.rs`). | Read: `decode_payload` (get, list, `validate_envelope`/`put_envelope`, `finish_prepared_entry`). Write: `put`/`prepare_entry`/`prepare_entry_named` reach it through `finish_prepared_entry`, which decodes what it just encoded. Raw envelope: `put_raw_envelope`, the door CultMesh network puts use (`cultmesh-rs` `lib.rs`, two call sites). Prepared batch: `put_prepared_batch` via `require_payload_depth`. cultnet-rs: every free-form or skippable wire payload decodes through `cultcache_rs::decode_payload` (message decode in `contracts.rs`, `provider_session`, `media_stream_wire`, `replication`, `service_identity`). |
| Rust | `check_record_payload` (private; depth, then no bytes after the first value) over `first_value_end`, and `MAX_PAYLOAD_DEPTH`; `cultcache_rs::check_payload_depth` is the depth-only probe for outside callers (`packages/cultcache-rs/src/lib.rs`). | Read: `decode_payload` (get, list, `validate_envelope`/`put_envelope`, `finish_prepared_entry`). Write: `put`/`prepare_entry`/`prepare_entry_named` reach it through `finish_prepared_entry`, which decodes what it just encoded. Raw envelope: `put_raw_envelope`, the door CultMesh network puts use (`cultmesh-rs` `lib.rs`, two call sites). Prepared batch: `put_prepared_batch` via `require_record_payload`. Load: `pull_all_backing_stores` and `load_soa` run `require_record_payload` on every record, so a store holding a payload past the bound (or with bytes after it) is refused at load, and the cache then refuses every write and removal (`CultCacheLoadRefused`) until a load succeeds. cultnet-rs: every free-form or skippable wire payload decodes through `cultcache_rs::decode_payload` (message decode in `contracts.rs`, `provider_session`, `media_stream_wire`, `replication`, `service_identity`). |
| Python | `_refuse_past_depth_limit` and `MAX_PAYLOAD_DEPTH` (`packages/cultcache-py/src/cultcache_py/documents.py`); a msgpack `StackError` on read maps to the same error. | `DocumentDefinition.encode_payload` (write) and `decode_payload` (read). `CultCache.put`, `put_envelope`, `put_envelopes` and load-time hydration go through them; there is no raw-envelope door. cultmesh-py round trips call the same pair. |
- **Doors deliberately not routed.** cultnet-rs `idunn_lifecycle_brake`, `provider_health_contracts` and

View file

@ -33,13 +33,16 @@ Probes: scratch tests in this worktree, removed. C# filtered runs on Starfire (o
| Probe | C# | TypeScript | Rust | Python |
|---|---|---|---|---|
| P1 cache {A,B}: B at k, then A at k | **C**: B's record replaced, k now A's id | C, and **both** records live at k | C, both live at k | C, both live at k |
| P2 cache {A} only, store holds B at k, write A at k | R `CultSchemaConflictException` (B is foreign, carried) | R at load: "No schema is registered for persisted schema id" | R at load, same | R at load: "Unknown persisted document type" |
| P1 cache {A,B}: B at k, then A at k | **C**: B's record replaced, k now A's id | R `SchemaConflictError` (cultcache-ts: one record per key) | R `SchemaConflictError` naming k, A not written (leaf cut) | R `SchemaConflictError`, A's write refused, B kept (was C, both live) |
| P2 cache {A} only, store holds B at k, write A at k | R `CultSchemaConflictException` (B is foreign, carried) | R `SchemaConflictError` at write (foreign loads, carried; cultcache-ts) | R `SchemaConflictError` (B loads as foreign, carried; leaf cut) | R `SchemaConflictError` (B is foreign, carried; the load admits it; was R at load) |
| P3 cacheless `WriteDocument<A>` over B, registry null / {A} / {A,B} | R / R / R | no cacheless write | no cacheless write | no cacheless write |
| P4 cache {A,B}: remove k and upsert A at k in one batch | C (k is A's) | n/a | n/a | n/a |
| P5 cache {A,B}: remove, then add in a second commit | C | n/a | n/a | n/a |
| P6 N2 over an N1 record (undeclared name drift), cache {N2} / cacheless reg {N2} | C restamped / C restamped | not probed | not probed | C (cache) |
The C#-written probe the leaves are held to is `tests/vectors/key-identity` (12 rows: retype, foreign carry, key in two stores), walked by
`KeyIdentityVectorTests`; each leaf cut corrects its own column above.
What this establishes:
- C#: one record per key. The only path that retypes is the cache arm (P1): `CacheBackingStore.RefuseUndeclared`
@ -52,12 +55,22 @@ What this establishes:
- Retype as remove plus add already works in the cache (P4, P5); the cut adds no new primitive.
- Directory store: its own judge (`DirectoryMessagePackBackingStore.cs:287-306`) asks `Registry.Owns` for every key,
upserts included. It must take the same per-key rule.
- TypeScript, Rust, Python: records are keyed by `(type, key)` (Rust `entries: BTreeMap<(String, String), _>`,
`lib.rs:2659`; TS `#entryId(definition.type, key)`, `cult-cache.ts:323`; Python sorts and stores by `(type, key)`).
A write of A at k never meets B's record: both live at k (P1). None carries a foreign record; a store holding an
unregistered type does not load (P2). So no leaf has a "write meets another type's record" path to fix. Bringing the
ruling to the leaves first needs C#'s key identity (one record per key) and C#'s foreign carry, which are parity changes
of the leaves' store model, not a write-ownership rule. Owned by the new follow-up `variants:follow_up:leaf-key-identity-parity`.
- TypeScript and Python: records are keyed by `(type, key)` (TS `#entryId(definition.type, key)`, `cult-cache.ts:323`;
Python sorts and stores by `(type, key)`). A write of A at k never meets B's record: both live at k (P1). Neither carries
a foreign record; a store holding an unregistered type does not load (P2). So neither has a "write meets another type's
record" path to fix: bringing the ruling to them first needs C#'s key identity (one record per key) and C#'s foreign
carry, parity changes of their store model, not a write-ownership rule (follow-up `variants:follow_up:leaf-key-identity-parity`).
- Rust is that follow-up's first leaf cut (`cut-leaf-key-identity-rs`). The cache keeps its `(type, key)` map; one judge
(`refuse_retype`) refuses another registered type at a key the cache holds, or any write at a key a store carries
foreign, on every write path, pull and SoA load, comparing types, not the ids envelopes claim. At a key the cache does not
hold (a fresh key, or one another writer filled after this cache's pull) the single-file store's commit guard judges by
the stored and claimed schema ids, so an envelope claiming another registered type's id is refused earlier, at every
envelope admission (`refuse_claimed_id`, the pull's own `resolve_registered_type` lookup); an id naming no registered type
passes, trusted as claimed: an envelope under such an id can replace another writer's record stored under it at a key
this cache does not hold, or be served as another type by a reader that registers the type the id names (Soul g8a, g8c,
open: which ids a Rust type owns is not declared to CultCache). Store-level writers called directly (`push`,
`insert_entry_if_absent`, compare-and-swap) trust the claimed id.
The cache carries an unregistered type's records as foreign and walks the vector's 12 rows.
- Interop (unproven): a Python store holding A and B at k fails C# decode first on payload encoding (Python's
lambda-defined document wrote JSON payloads), so what C# does with two records at one key was not reached.

View file

@ -2,6 +2,10 @@
## Unreleased
### Added
- `read_payload_tree(type_name, payload, decoder=None)`: the exported owner of the one-value, depth-first payload read that `decode_payload` uses, for payloads read outside a definition (`cultnet-py`'s witness bundle decode needs it, so `cultnet-py` now requires `cultcache-py>=0.4.0`). The manifest version is 0.4.0, the minor these Unreleased breaking changes imply, set in this change so the workspace install resolves the new bound; the `## Unreleased` heading becomes `## [0.4.0]` when it is tagged.
### Changed
- `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import.
@ -9,6 +13,11 @@
### Breaking
- A record whose payload carries a slot past its reader's declared slots, at any depth (the document, a nested record, a list element, a union arm, a unit arm), is read, held read-only, and every write or removal of its key (`put`, `put_global`, `update`, `put_envelope`, `put_envelopes`, `delete`, `delete_global`) is refused with `SchemaConflictError` before any store is touched (`cultlib-gaps:ruling:extra-trailing-slots`). Reading dropped those slots silently and the next write shed them. `SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` and the message names the type and both counts, never a slot value. A depth-0 mark is consented, and the record writable, only when the record is stored under an id the document lists in `compatible_schema_ids` and that id's catalog entry has slots past the document's, covering the mark's slot count (`cultlib-gaps:ruling:slot-mark-consent`); a nested mark, or a mark under the document's own id, keeps the record read-only whatever is declared. Missing trailing slots keep their declared defaults. An untouched marked record keeps its stored bytes through every other write. Added `CultCache.is_writable(document, key)` (advisory; the write still decides: it also answers False for a foreign-carried key, for a key held as a type the document does not own, and for a global document at a non-global key; `delete` asks the same judges, so removing a key held as another type is refused with `SchemaConflictError` unless the document owns that record's schema id (its own or one it lists compatible), in which case the removal removes that record, its file row and its lookups, as a write of the key would replace it; it returned silently and removed nothing), `DocumentDefinition.decode_payload_marked`, and `UnreadSlots`. A document with a custom `decode` (no slot codec) never marks. A store holding a variant record is still unreadable here. Implies a minor bump while the version is 0.x (0.3.0 to 0.4.0): code that wrote back a record from a newer writer now gets `SchemaConflictError`.
- A payload is one MessagePack value: `DocumentDefinition.decode_payload` refuses bytes after it, by count, with `ValueError` `<type> payload has N bytes after its first MessagePack value (refused on read).` (never the bytes, never the depth refusal; a payload that ends early stays the decoder's), as the C# reference does (`tests/vectors/union-parity/bytes-after-outcomes.msgpack`). It raised `msgpack.ExtraData` (a `ValueError`, so only a caller naming `msgpack.ExtraData` is affected); this is a minor-version bump while pre-1.0. The refused set is unchanged. Cache loads, `get` and every definition built on the MessagePack decoder go through it; a user `payload_decoder` that raises `ExtraData` is refused the same way. Depth is judged first, so a value nested past 256 with a tail is the depth refusal, as in C#.
- A cache holds one record per key whatever its type (`variants:ruling:cache-refuses-retype`). A `put`, `put_envelope`, `put_envelopes` or `put_global` of a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store, and changes nothing; retype a key by deleting it, then putting the new type. A write may replace another registered type's record only when the written type owns its schema id (declares it or lists it compatible) and both types share one home store. A load that reads one key from two stores, whatever the types, is refused with `CultCacheError` naming the key and both types, and admits nothing. A store that holds a record no registered type claims now loads and is carried: the cache does not serve it, the store keeps its bytes through every write, `delete` or `put` at its key is refused with `SchemaConflictError`, and registering a type that declares its schema id claims it at the next load. It raised `CultCacheError` "Unknown persisted document type". Two global types share the key `__global__`, so a second global type in one cache is now refused, and a store that already holds two global types at `__global__` (a JSONL store written by caches that never pulled could produce one) no longer loads: the pull raises `CultCacheError` and admits nothing. C# refuses a second persisted global on pull the same way (`cultcache-store-composition.md`, "Globals never invented"); repair such a file by deleting one of the two records with the runtime that wrote it.
- Added `foreign_records()` and the frozen `ForeignRecord(key, schema_id, schema_name, stored_at)`, sorted by key in UTF-16 code-unit order. `stores.retyped` and `stores.written_owns` lose their underscore: the cache judge and the single-file store's commit guard ask one predicate.
- 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.

View file

@ -19,7 +19,12 @@ discovery, sessions, local server) build on it, mirroring `cultcache-rs` /
`[format, schemaCatalog, records]`
- records store raw MessagePack payload bytes under schema-catalog identity
- payloads decode only through the registered document definition for that type
- unknown persisted types fail closed
- a payload is exactly one MessagePack value: bytes after it are refused on read, by count
- a cache holds one record per key whatever its type: a write of a second type at a held key is refused with
`SchemaConflictError` (in memory too), and two stores holding one key refuse the load
- a store record no registered type claims loads and is carried as foreign: `cache.foreign_records()` lists it, the store
keeps its bytes through every write, a write or removal at its key is refused, and registering a type that declares its
schema id claims it at the next load
- global documents are singleton-style per type
- one home store per document type: a store added with `add_backing_store`
owns its types, otherwise the generic store is the home; a type claimed by a
@ -93,6 +98,7 @@ store.
- `update_global(...)`
- `delete(...)`
- `delete_global(...)`
- `is_writable(document, key)` (advisory: would a write or removal of the key be refused; see below)
- `snapshot()`
## Indexes And Roles
@ -176,6 +182,16 @@ The payload is a MessagePack array. Field keys are durable slot indexes:
- newly added fields should use new keys
- fields with defaults tolerate missing or nil slots when older payloads are read
A payload carrying a slot past the slots a reader declares, at any depth (the document, a nested record, a list element, a
union arm, a unit arm), was written by a newer build. It is read, and the record is held read-only: `put`, `update`,
`put_envelope(s)`, `delete` and their global forms refuse its key with `SchemaConflictError` before any store is touched, and
`SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` naming the
first type read with extra slots (never a slot value). `CultCache.is_writable(document, key)` answers the same question before
the write: False for such a record, for a key a store carries foreign, and for a key held as another type's record; the write
still decides. `DocumentDefinition.decode_payload_marked(payload)` returns the value and its `UnreadSlots` (or None). Declaring the
stored id in `compatible_schema_ids` consents to the loss of depth-0 slots that id's catalog entry reports; a nested mark never is.
A store keeps a read-only record's bytes through every other write. A document with a custom `decode` never marks.
That matches the Rust `#[derive(DatabaseEntry)]` formatter shape and the C#
`[Key(n)]` intent: schema evolution comes from stable field slots, not source
member order.

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "cultcache-py"
version = "0.3.0"
version = "0.4.0"
description = "Python CultCache runtime for GameCult typed state."
readme = "README.md"
requires-python = ">=3.10"

View file

@ -1,7 +1,7 @@
from .backing_store import BackingStore, CultCacheEnvelope
from .backing_store import CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember
from . import leaves
from .cache import CultCache, CultCacheBuilder
from .cache import CultCache, CultCacheBuilder, ForeignRecord
from .documents import (
MAX_PAYLOAD_DEPTH,
MAX_SLOT_KEY,
@ -9,12 +9,14 @@ from .documents import (
DatabaseEntryField,
DocumentDefinition,
UnionArm,
UnreadSlots,
database_entry_field,
define_database_entry_type,
define_document_registry,
define_document_type,
define_record_codec,
define_union,
read_payload_tree,
)
from .leaves import SlotCodec
from .stores import JsonLinesBackingStore, SchemaConflictError, SingleFileMessagePackBackingStore, StoreUnreadableError
@ -29,6 +31,7 @@ __all__ = [
"CultCacheSchemaCatalogMember",
"DatabaseEntryField",
"DocumentDefinition",
"ForeignRecord",
"JsonLinesBackingStore",
"MAX_PAYLOAD_DEPTH",
"MAX_SLOT_KEY",
@ -37,6 +40,8 @@ __all__ = [
"SlotCodec",
"StoreUnreadableError",
"UnionArm",
"UnreadSlots",
"read_payload_tree",
"leaves",
"database_entry_field",
"define_database_entry_type",

View file

@ -8,8 +8,8 @@ from dataclasses import dataclass, field, replace
from typing import Any, Callable, Generic, Iterable, TypeVar
from .backing_store import BackingStore, CultCacheEnvelope
from .documents import DocumentDefinition, extract_value
from .stores import SchemaConflictError
from .documents import DocumentDefinition, UnreadSlots, extract_value
from .stores import SchemaConflictError, retyped, written_owns
T = TypeVar("T")
F = TypeVar("F", bound=Callable[..., Any])
@ -120,6 +120,24 @@ class _State:
# of that global removes it from the home store. Remove once no store holds a global under a key
# other than GLOBAL_KEY.
legacy_global_keys: dict[str, str] = field(default_factory=dict)
# The records the last pull read that no registered type claims, sorted by key. Derived from the pull and swapped with the
# rest of the state, written nowhere else; it decides only refusals at its keys. A store keeps their bytes.
foreign: list["ForeignRecord"] = field(default_factory=list)
# The records held read-only, by key (a key holds one record) with the type that holds it: the payload carried slots past the
# declared ones and nothing consents to the loss. Derived from the load or the write that admitted the record and swapped with
# the rest of the state; it decides only refusals at its keys, and any other key is absent.
unread: dict[str, tuple[str, UnreadSlots]] = field(default_factory=dict)
@dataclass(frozen=True)
class ForeignRecord:
"""A record a store holds that no registered type claims: the cache does not serve it, refuses a write or a removal at its
key, and a store keeps its bytes. Registering a type that declares its schema id claims it at the next load."""
key: str
schema_id: str
schema_name: str
stored_at: str
@dataclass(frozen=True)
@ -322,15 +340,24 @@ class CultCache:
loaded_legacy_global_keys: dict[str, str] = {}
routes, generic = self._state.stores_by_type, self._state.generic_store
seen_globals: set[str] = set()
foreign: list[ForeignRecord] = []
loaded_unread: dict[str, tuple[str, UnreadSlots]] = {}
type_at_key: dict[str, tuple[str, int]] = {} # one record per key across every store: its type (or schema name) and store
stores: list[BackingStore] = []
for candidate in [*self._state.stores_by_type.values(), self._state.generic_store]:
if candidate is not None and all(candidate is not store for store in stores):
stores.append(candidate)
for store in stores:
for position, store in enumerate(stores):
for envelope in store.pull_all():
document = self._resolve_document_for_envelope(envelope)
if document is None:
raise CultCacheError(f"Unknown persisted document type: {envelope.type}")
entry = envelope.catalog_entry
held_name = entry.schema_name if entry is not None else envelope.type
self._claim_key(type_at_key, envelope.key, held_name, position)
foreign.append(
ForeignRecord(envelope.key, envelope.schema_id or envelope.type, held_name, envelope.stored_at)
)
continue
if envelope.type != document.type:
envelope = CultCacheEnvelope(
key=envelope.key,
@ -353,8 +380,11 @@ class CultCache:
if envelope.key != GLOBAL_KEY:
loaded_legacy_global_keys[envelope.type] = envelope.key
envelope = replace(envelope, key=GLOBAL_KEY)
envelope = self._stamped(document, envelope)
value = document.decode_payload(envelope.payload)
self._claim_key(type_at_key, envelope.key, envelope.type, position)
arrived, envelope = envelope, self._stamped(document, envelope)
value, unread = self._decode_marked(document, arrived)
if unread is not None:
loaded_unread[envelope.key] = (envelope.type, unread)
loaded_values.setdefault(envelope.type, {})[envelope.key] = value
read_values[(envelope.type, envelope.key)] = value
loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope
@ -369,7 +399,30 @@ class CultCache:
self._state.indexes,
self._state.lookup_keys,
self._state.legacy_global_keys,
) = (loaded_values, loaded_envelopes, loaded_names, loaded_indexes, loaded_lookup_keys, loaded_legacy_global_keys)
self._state.foreign,
self._state.unread,
) = (
loaded_values,
loaded_envelopes,
loaded_names,
loaded_indexes,
loaded_lookup_keys,
loaded_legacy_global_keys,
sorted(foreign, key=lambda record: _utf16_order(record.key)),
loaded_unread,
)
@staticmethod
def _claim_key(type_at_key: dict[str, tuple[str, int]], key: str, type: str, store: int) -> None:
"""A key holds one record whatever its type, across every store a load reads. The refusal names the key and both types,
never a payload."""
held, held_store = type_at_key.setdefault(key, (type, store))
if held != type or held_store != store:
raise CultCacheError(f"Record {key!r} is held by both {held} and {type}: a key holds one record; the load admitted nothing.")
def foreign_records(self) -> list[ForeignRecord]:
"""The records the last load read that no registered type claims, sorted by key (UTF-16 code-unit order)."""
return list(self._state.foreign)
def get(self, document: DocumentDefinition[T], key: str) -> T | None:
self._assert_registered(document)
@ -447,9 +500,9 @@ class CultCache:
def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T:
self._assert_registered(document)
self._check_envelope(document, envelope)
envelope = self._stamped(document, envelope)
value = document.decode_payload(envelope.payload)
self._write(document, [(envelope, value)], batch=False)
arrived, envelope = envelope, self._stamped(document, envelope)
value, unread = self._decode_marked(document, envelope)
self._write(document, [(envelope, value)], batch=False, unread={envelope.key: unread})
return value
@_mutating
@ -457,13 +510,33 @@ class CultCache:
self._assert_registered(document)
values: list[T] = []
stamped: list[CultCacheEnvelope] = []
unread: dict[str, UnreadSlots | None] = {}
for envelope in envelopes:
self._check_envelope(document, envelope)
stamped.append(self._stamped(document, envelope))
values.append(document.decode_payload(envelope.payload))
self._write(document, list(zip(stamped, values)), batch=True)
value, unread[envelope.key] = self._decode_marked(document, stamped[-1])
values.append(value)
self._write(document, list(zip(stamped, values)), batch=True, unread=unread)
return values
@staticmethod
def _decode_marked(document: DocumentDefinition[Any], held_as: CultCacheEnvelope) -> tuple[Any, UnreadSlots | None]:
"""The payload decoded under the document, with the mark of any slot it carried past the declared ones, or no mark when the
loss is consented. `held_as` is the envelope under the id the record is stored under: a load's arrival, a write's stamped
envelope, so a key is writable in memory exactly when it is after a reload. A depth-0 mark is consented only when that id is
one the document lists compatible and its catalog entry has slots past the document's that cover the mark's slot count (the
loss a compatible-id declaration consents to, cultlib-gaps:ruling:slot-mark-consent). Any other mark keeps the record read-only."""
value, slots = document.decode_payload_marked(held_as.payload)
if slots is None:
return value, None
entry, stored = document.catalog_entry(), held_as.catalog_entry
if slots.depth0_only and stored is not None and held_as.schema_id in entry.compatible_schema_ids and held_as.schema_id != entry.schema_id:
declared = {member.slot for member in entry.members}
reported = [member.slot for member in stored.members if member.slot not in declared]
if reported and slots.slot_count <= max(reported) + 1:
return value, None
return value, slots
@staticmethod
def _stamped(document: DocumentDefinition[Any], envelope: CultCacheEnvelope) -> CultCacheEnvelope:
"""The record under its registered schema's id, whatever id the envelope arrived under, so the catalog entry a store
@ -483,7 +556,12 @@ class CultCache:
raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
def _write(
self, document: DocumentDefinition[Any], records: list[tuple[CultCacheEnvelope, Any]], *, batch: bool
self,
document: DocumentDefinition[Any],
records: list[tuple[CultCacheEnvelope, Any]],
*,
batch: bool,
unread: dict[str, UnreadSlots | None] | None = None,
) -> None:
"""Validates every record (home and name and index extractors included) before the store is
touched; once the store accepts, applying to the cache has no fallible step."""
@ -491,12 +569,14 @@ class CultCache:
# A batch naming a key twice lands the last record for it, as C# CultCacheBatch stages one operation per key. The
# judge, the store and the apply all read this one list.
records = list({envelope.key: (envelope, value) for envelope, value in records}.values())
self._refuse_unread([envelope.key for envelope, _ in records])
name_extractor = self._state.name_extractors.get(type)
index_extractors = self._state.index_extractors.get(type, {})
admitted = [
(envelope, value, self._lookup_keys_for(value, name_extractor, index_extractors))
for envelope, value in records
]
displaced = self._refuse_retype(document, [envelope for envelope, _ in records])
self._refuse_index_sharing(
[(type, envelope.key, keys) for envelope, _, keys in admitted],
self._state.indexes,
@ -515,16 +595,99 @@ class CultCache:
values = self._state.values.setdefault(type, {})
envelopes = self._state.envelopes.setdefault(type, {})
lookup_keys = self._state.lookup_keys.setdefault(type, {})
for other, key in displaced:
self._drop(other, key)
for envelope, value, keys in admitted:
self._remove_lookups(type, envelope.key)
values[envelope.key] = value
envelopes[envelope.key] = envelope
# A held mark refused this write above, so a key reaching here carries none to clear.
if (mark := (unread or {}).get(envelope.key)) is not None:
self._state.unread[envelope.key] = (type, mark)
lookup_keys[envelope.key] = keys
if keys.name is not None:
self._state.names[(type, keys.name)] = envelope.key
for index, index_value in keys.indexes.items():
self._state.indexes[(type, index, index_value)] = envelope.key
def _refuse_retype(self, document: DocumentDefinition[Any], written: list[CultCacheEnvelope]) -> list[tuple[str, str]]:
"""The one key-identity judge (variants:ruling:cache-refuses-retype): a key holds one record whatever its type, so a write
may not land at a key this cache holds another type's record at, unless the written type owns that record's schema id
(the single-file store's commit guard asks the same question of the file), and may not land at a key a store carries
foreign. Returns the (type, key) records an owning write replaces; refuses, changing nothing, otherwise."""
replaced: list[tuple[str, str]] = []
foreign = {record.key: record for record in self._state.foreign}
for envelope in written:
carried = foreign.get(envelope.key)
if carried is not None:
raise self._foreign_refusal(carried)
for other, held_envelopes in self._state.envelopes.items():
held = held_envelopes.get(envelope.key)
if other == document.type or held is None:
continue
if not written_owns(held, envelope) or self._store_for_type(other) is not self._store_for_type(document.type):
raise retyped(held, envelope)
replaced.append((other, envelope.key))
return replaced
@_locked
def is_writable(self, document: DocumentDefinition[Any], key: str) -> bool:
"""Whether a write or removal of the key by this document would pass the judges that run before any store is touched: False for
a record held read-only (its payload carried slots past the declared ones, at any depth, and nothing consents to the loss, so
writing it would drop data a newer writer stored), for a key a store carries foreign, and for a key held as another type's
record this document does not own. Advisory (the write still decides): it answers at call time, and a key nothing holds is
writable."""
self._assert_registered(document)
try:
self._refuse_at(document, key)
except (SchemaConflictError, CultCacheError):
return False
return True
def _refuse_at(self, document: DocumentDefinition[Any], key: str) -> list[tuple[str, str]]:
"""The judges a write or removal of the key by this document passes before any store is touched, asked by `is_writable` and
by `delete`: a global document writes only its global key, a record held read-only is never replaced, and a key held as
another type's record or carried foreign is refused (`_refuse_retype`)."""
if document.global_document and key != GLOBAL_KEY:
raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
self._refuse_unread([key])
entry = document.catalog_entry()
probe = CultCacheEnvelope.create(key=key, type=document.type, payload=b"", schema_id=entry.schema_id, catalog_entry=entry)
return self._refuse_retype(document, [probe])
def _refuse_unread(self, keys: list[str]) -> None:
"""The one check every write and removal asks before any store is touched."""
for key in keys:
held = self._state.unread.get(key)
if held is None:
continue
type, slots = held
raise SchemaConflictError(
self._state.envelopes[type][key].schema_id or type,
[self._state.documents[type].catalog_entry().schema_name],
key,
message=(
f"{self._state.documents[type].catalog_entry().schema_name} record {key} is read-only: {slots.type_name} was read with "
f"{slots.slot_count} slots where this build declares {slots.declared_count} ({slots.occurrences} object(s) in the "
"record). Writing it would drop data a newer writer stored; read it with types that declare those slots, or "
"write it from that build."
),
unread_slots=slots,
)
@staticmethod
def _foreign_refusal(record: ForeignRecord) -> SchemaConflictError:
return SchemaConflictError(
record.schema_id,
[record.schema_name],
record.key,
message=(
f"Record {record.key!r} is held by the store as a {record.schema_name} (schema id {record.schema_id!r}) that no "
"registered type claims; a write or removal there would replace what this cache cannot read. Register the type "
"that owns the schema id, or remove the record with the runtime that wrote it."
),
)
@staticmethod
def _retire_legacy_global(store: BackingStore, type: str, legacy_key: str) -> None:
"""The first write of a global loaded from a legacy key removes that record in the same write. If
@ -564,19 +727,29 @@ class CultCache:
def _delete(self, document: DocumentDefinition[Any], key: str) -> None:
self._assert_registered(document)
# A key held as another type's record this document owns is removed, as a write of the key would replace it (C# Remove is by key).
owned = self._refuse_at(document, key)
store = self._store_for_type(document.type)
legacy_key = self._state.legacy_global_keys.get(document.type) if key == GLOBAL_KEY else None
if store is not None:
store.delete(document.type, key if legacy_key is None else legacy_key)
# One record per key: the owned record when this document does not hold the key, else its own, in one store call.
holder = owned[0][0] if owned else document.type
store.delete(holder, key if legacy_key is None else legacy_key)
if legacy_key is not None:
del self._state.legacy_global_keys[document.type]
self._remove_lookups(document.type, key)
for other, _ in owned:
self._drop(other, key)
self._drop(document.type, key)
def _drop(self, type: str, key: str) -> None:
"""Removes the held record and its lookups from the cache's maps; the store is the caller's."""
self._remove_lookups(type, key)
for by_type in (self._state.values, self._state.envelopes, self._state.lookup_keys):
held = by_type.get(document.type)
held = by_type.get(type)
if held is not None:
held.pop(key, None)
if not held:
by_type.pop(document.type, None)
by_type.pop(type, None)
@_mutating
def delete_global(self, document: DocumentDefinition[Any]) -> None:

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import json
import threading
from dataclasses import MISSING, dataclass, field
from collections.abc import Mapping, Sequence, Set
from itertools import chain
@ -30,6 +31,49 @@ MAX_SLOT_KEY = 65535
_STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,)
@dataclass(frozen=True)
class UnreadSlots:
"""What a payload decode found past the slots this runtime declares, at any depth: the first type read with extra slots, how
many slots it carried against how many it declares, and how many objects in the payload did. It holds no slot value, so a
refusal can name it. `depth0_only` is True when every mark sat in the document's own record (the half of consent the payload
decides). A record carrying it is read-only unless the schema comparison explains every mark (see `CultCache.is_writable`)."""
type_name: str
slot_count: int
declared_count: int
occurrences: int
depth0_only: bool
class _SlotScope:
"""Opened by `DocumentDefinition.decode_payload_marked`; the record codec and the union codec note into it. It is thread-local,
so no codec signature carries it, and a codec decoded outside a payload decode notes nothing."""
def __init__(self) -> None:
self.depth = 0 # records entered: 0 while the document's own record is read
self.first: tuple[str, int, int] | None = None
self.occurrences = 0
self.depth0_only = True
def note(self, type_name: str, slot_count: int, declared_count: int, depth0: bool) -> None:
if self.first is None:
self.first = (type_name, slot_count, declared_count)
self.occurrences += 1
self.depth0_only = self.depth0_only and depth0
def result(self) -> UnreadSlots | None:
if self.first is None:
return None
return UnreadSlots(*self.first, self.occurrences, self.depth0_only)
_scope = threading.local()
def _current_scope() -> _SlotScope | None:
return getattr(_scope, "open", None)
_CONTAINERS = (Mapping, Sequence, Set)
_SCALAR_SEQUENCES = (str, bytes, bytearray, memoryview)
@ -114,6 +158,26 @@ def _messagepack_payload_decoder(payload: bytes) -> Any:
return msgpack.unpackb(payload, raw=False)
def read_payload_tree(type_name: str, payload: bytes, decoder: Callable[[bytes], Any] | None = None) -> Any:
"""The one read of a payload's tree: exactly one MessagePack value, within the nesting limit. Depth is judged first,
on the value read so far, so a tail never pre-empts it (C# throws at container 257 before it reaches the tail); then a
tail is refused by count and never echoed. Raised outside the handler so no `__context__` keeps the tail."""
after = -1
unpacked: Any = None
try:
tree = (decoder or _messagepack_payload_decoder)(payload)
except _STACK_FAULTS:
raise CultCachePayloadDepthError(type_name, "read") from None
except msgpack.ExtraData as error:
after = len(error.extra)
unpacked = error.unpacked
if after >= 0:
_refuse_past_depth_limit(unpacked, type_name, "read")
raise ValueError(f"{type_name} payload has {after} bytes after its first MessagePack value (refused on read).")
_refuse_past_depth_limit(tree, type_name, "read")
return tree
@dataclass(frozen=True)
class DatabaseEntryField:
name: str
@ -185,15 +249,21 @@ class DocumentDefinition(Generic[T]):
return self.payload_encoder(tree)
def decode_payload(self, payload: bytes) -> T:
return self.decode_payload_marked(payload)[0]
def decode_payload_marked(self, payload: bytes) -> tuple[T, UnreadSlots | None]:
"""The value and what the decode found past the declared slots, at any depth. The payload depth check runs first: a
payload past the limit is refused, never marked."""
tree = read_payload_tree(self.type, payload, self.payload_decoder)
outer, opened = _current_scope(), _SlotScope()
_scope.open = opened
try:
tree = self.payload_decoder(payload)
except _STACK_FAULTS:
raise CultCachePayloadDepthError(self.type, "read") from None
_refuse_past_depth_limit(tree, self.type, "read")
value = self.decode(tree)
value = self.decode(tree)
finally:
_scope.open = outer
if self.validate:
self.validate(value)
return value
return value, opened.result()
def __post_init__(self) -> None:
# Judged here so every constructor passes it: the factories, the dataclass constructor and dataclasses.replace.
@ -418,6 +488,9 @@ def define_union(
raise ValueError(f"Union {name!r} value is malformed: expected [armKey, [armSlots]]")
arm_name, codec = by_key[key]
if codec is None:
scope = _current_scope()
if scope is not None and raw[1]:
scope.note(arm_name, len(raw[1]), 0, False)
return UnionArm(arm_name, None)
try:
return UnionArm(arm_name, codec.decode(raw[1]))
@ -479,17 +552,26 @@ def _record_codec(
def decode(raw: Any) -> Any:
if not isinstance(raw, list):
raise ValueError(f"DatabaseEntry document type {type!r} expected a MessagePack array")
scope = _current_scope()
if scope is not None and len(raw) > max_key + 1:
scope.note(type, len(raw), max_key + 1, scope.depth == 0)
values: dict[str, Any] = {}
for field in normalized:
if field.key < len(raw):
try:
values[field.name] = field.codec.decode(raw[field.key])
except ValueError as error:
raise ValueError(f"{type} slot {field.key} ({field.name}): {error}") from None
elif field.default is MISSING:
values[field.name] = None
else:
values[field.name] = field.default() if callable(field.default) else field.default
if scope is not None:
scope.depth += 1
try:
for field in normalized:
if field.key < len(raw):
try:
values[field.name] = field.codec.decode(raw[field.key])
except ValueError as error:
raise ValueError(f"{type} slot {field.key} ({field.name}): {error}") from None
elif field.default is MISSING:
values[field.name] = None
else:
values[field.name] = field.default() if callable(field.default) else field.default
finally:
if scope is not None:
scope.depth -= 1
if cls is None:
return values
return cls(**values)

View file

@ -13,6 +13,7 @@ from typing import Any, Callable, Iterator, TypeVar
import msgpack
from .documents import UnreadSlots
from .backing_store import (
CultCacheEnvelope,
CultCacheSchemaCatalogEntry,
@ -46,9 +47,19 @@ class SchemaConflictError(ValueError):
"""A write the catalog cannot describe: records of different types under one schema id, two arrived schemas that share an id
and disagree on the schema name, or a record no chosen entry publishes. Nothing is written. It names the id, the names
involved (`schema_names`) and a record key (`record_key`). A cache raises it too, with an empty record key, when a
document registers a schema id another registered document already carries."""
document registers a schema id another registered document already carries. `unread_slots` is None except for the refusal to
write or remove a record held read-only because its payload carries slots past the declared ones; its message names the type
and both slot counts and never holds a slot value."""
def __init__(self, schema_id: str, schema_names: list[str], record_key: str, *, message: str | None = None) -> None:
def __init__(
self,
schema_id: str,
schema_names: list[str],
record_key: str,
*,
message: str | None = None,
unread_slots: UnreadSlots | None = None,
) -> None:
super().__init__(
message
or f"Schema id {schema_id!r} cannot describe record {record_key!r}: {', '.join(repr(name) for name in schema_names)}"
@ -56,6 +67,7 @@ class SchemaConflictError(ValueError):
self.schema_id = schema_id
self.schema_names = schema_names
self.record_key = record_key
self.unread_slots = unread_slots
class JsonLinesBackingStore:
@ -245,7 +257,7 @@ def _derive_catalog(records: list[tuple[CultCacheEnvelope, bool]]) -> list[CultC
return [entry for entry, _ in chosen.values()]
def _retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConflictError:
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),
@ -258,7 +270,7 @@ def _retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConfl
)
def _written_owns(held: CultCacheEnvelope, written: CultCacheEnvelope) -> bool:
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
@ -277,8 +289,8 @@ def _encode_snapshot(
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 not _written_owns(held, envelope):
raise _retyped(held, envelope)
if held is not None and not written_owns(held, envelope):
raise retyped(held, envelope)
landing[envelope.key] = envelope
supplied = list(landing.values())
replaced = set(landing)

View file

@ -0,0 +1,316 @@
from __future__ import annotations
import re
import shutil
import tempfile
import unittest
from pathlib import Path
import msgpack
from cultcache_py.cache import CultCacheError
from cultcache_py import (
CultCache,
JsonLinesBackingStore,
SchemaConflictError,
SingleFileMessagePackBackingStore,
database_entry_field,
define_database_entry_type,
leaves as t,
)
# variants:ruling:cache-refuses-retype, write-ownership-by-written-type, unheld-removal-refused-at-commit. The vector in
# tests/vectors/key-identity is written only by the C# reference; this walks every row against the Python cache.
VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "key-identity"
IDS = {
"alpha": "sha256:b20703a98f49c253898404ab50ba2c630720f0b8a5e1a41614d5650be22799fe",
"beta": "sha256:dee4b727cf2ee04e61ab635ff0c5f3dca7345a411dc5f1018e583ab16009cf94",
"gamma": "sha256:fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d",
}
DOCUMENTS = {
label: define_database_entry_type(
f"tests.ki_{label}",
[database_entry_field("text", 0, codec=t.string)],
schema_id=schema_id,
schema_name=f"tests.ki_{label}",
)
for label, schema_id in IDS.items()
}
LABEL_OF = {document.type: label for label, document in DOCUMENTS.items()}
def file_records(path: Path) -> list[list[str]]:
return sorted([envelope.key, envelope.type] for envelope in SingleFileMessagePackBackingStore(path).pull_all())
def stored(path: Path, key: str):
for envelope in SingleFileMessagePackBackingStore(path).pull_all():
if envelope.key == key:
return envelope.schema_id, envelope.stored_at, envelope.payload
return None
class KeyIdentityVectorTests(unittest.TestCase):
def test_rows(self) -> None:
rows = msgpack.unpackb((VECTORS / "rows.msgpack").read_bytes(), raw=False)
walked = 0
for label, registered, ops, expect in rows:
with self.subTest(row=label), tempfile.TemporaryDirectory() as tmp:
self.walk(Path(tmp), registered, ops, expect)
walked += 1
self.assertEqual(walked, 12)
def walk(self, tmp: Path, registered: list[str], ops: list, expect: list) -> None:
refusal, held, file, foreign, kept = expect
state: dict = {"cache": None, "path": None, "before": None, "attached": []}
def builder() -> CultCache.builder:
made = CultCache.builder()
for name in registered:
made.register_document_type(DOCUMENTS[name])
return made
def open_cache(target) -> None:
if target == "memory":
state["cache"], state["path"] = builder().build(), None
return
path = tmp / "store.cc"
if target is not None and target != path:
shutil.copyfile(VECTORS / target, path)
state["before"] = path.read_bytes()
state["path"] = path
made = builder().add_generic_store(SingleFileMessagePackBackingStore(path))
state["cache"] = made.build()
state["cache"].pull_all_backing_stores()
def perform(op: list) -> None:
cache = state["cache"]
if op[0] == "open":
open_cache(op[1])
elif op[0] == "attach":
path = tmp / f"attached-{len(state['attached'])}.cc"
shutil.copyfile(VECTORS / op[1], path)
state["attached"].append((path, path.read_bytes()))
cache.add_backing_store(
SingleFileMessagePackBackingStore(path), [DOCUMENTS[name].type for name in op[2]]
)
cache.pull_all_backing_stores()
elif op[0] == "reopen":
open_cache(state["path"]) # the same file, no fresh copy
elif op[0] == "put":
cache.put(DOCUMENTS[op[1]], op[2], {"text": op[3]})
elif op[0] == "delete":
cache.delete(DOCUMENTS[op[1]], op[2])
else:
self.fail(f"unknown op {op[0]}")
for op in ops[:-1]:
perform(op)
if refusal is None:
perform(ops[-1])
else:
key = refusal[0]
with self.assertRaises((SchemaConflictError, ValueError, RuntimeError)) as caught:
perform(ops[-1])
if ops[-1][0] == "attach":
# The key is read out of the refusal's own text (it opens "Record 'key' is held by") and occurs only as a
# delimited token, never inside a schema name or other word.
message = str(caught.exception)
named = re.match(r"Record '([^']*)' is held by", message)
self.assertIsNotNone(named, message)
self.assertEqual(named.group(1), key)
self.assertEqual(len(re.findall(rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])", message)), 1)
self.assertEqual(message.count(key), 1)
for path, before in state["attached"]:
self.assertEqual(path.read_bytes(), before)
else:
self.assertIsInstance(caught.exception, SchemaConflictError)
self.assertEqual(caught.exception.record_key, key)
self.assertIn(key, str(caught.exception))
cache = state["cache"]
got_held = sorted(
[LABEL_OF[type], key, value["text"]]
for type, values in cache.snapshot().items()
for key, value in values.items()
)
self.assertEqual(got_held, held)
path = state["path"]
if file is None:
self.assertIsNone(path)
else:
self.assertEqual(file_records(path), file)
self.assertEqual([record.key for record in cache.foreign_records()], foreign)
if kept:
vector = tmp / "vector-before.cc"
vector.write_bytes(state["before"])
for key in kept:
self.assertIsNotNone(stored(vector, key))
self.assertEqual(stored(path, key), stored(vector, key))
def alpha_beta(*names: str, store=None) -> CultCache:
made = CultCache.builder()
for name in names:
made.register_document_type(DOCUMENTS[name])
if store is not None:
made.add_generic_store(store)
return made.build()
class KeyIdentityTests(unittest.TestCase):
def test_two_global_types_are_refused_in_one_cache_and_a_store_holding_both_does_not_load(self) -> None:
# C# refuses a second persisted global on pull (cultcache-store-composition.md, "Globals never invented"); the Python
# JSONL store and a cache with no store used to let two global types share __global__, so such a file can exist.
ga = define_database_entry_type(
"tests.ki_ga", [database_entry_field("text", 0, codec=t.string)], schema_id="sha256:" + "1" * 64, global_document=True
)
gb = define_database_entry_type(
"tests.ki_gb", [database_entry_field("text", 0, codec=t.string)], schema_id="sha256:" + "2" * 64, global_document=True
)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.jsonl"
for document in (ga, gb): # one cache per type that never pulled: how a file got two globals
writer = CultCache.builder().register_document_type(document).add_generic_store(JsonLinesBackingStore(path)).build()
writer.put_global(document, {"text": document.type})
both = CultCache.builder().register_document_type(ga).register_document_type(gb).add_generic_store(JsonLinesBackingStore(path)).build()
with self.assertRaises(CultCacheError) as caught:
both.pull_all_backing_stores()
self.assertIn("'__global__' is held by both", str(caught.exception))
self.assertEqual(both.snapshot(), {})
memory = CultCache.builder().register_document_type(ga).register_document_type(gb).build()
memory.put_global(ga, {"text": "a"})
with self.assertRaises(SchemaConflictError):
memory.put_global(gb, {"text": "b"})
self.assertEqual(memory.get_required_global(ga), {"text": "a"})
def test_every_write_path_refuses_a_second_type_and_never_echoes_the_payload(self) -> None:
alpha, beta = DOCUMENTS["alpha"], DOCUMENTS["beta"]
for name in ("put", "put_envelope", "put_envelopes"):
with self.subTest(path=name):
cache = alpha_beta("alpha", "beta")
cache.put(alpha, "k", {"text": "held"})
envelope = cache.get_envelope(alpha, "k")
other = type(envelope).create(
key="k", type=beta.type, payload=beta.encode_payload({"text": "CANARY"}), schema_id=IDS["beta"]
)
writes = {
"put": lambda: cache.put(beta, "k", {"text": "CANARY"}),
"put_envelope": lambda: cache.put_envelope(beta, other),
"put_envelopes": lambda: cache.put_envelopes(beta, [other]),
}
with self.assertRaises(SchemaConflictError) as caught:
writes[name]()
self.assertEqual(caught.exception.record_key, "k")
self.assertNotIn("CANARY", str(caught.exception))
self.assertEqual(cache.get(alpha, "k"), {"text": "held"})
self.assertIsNone(cache.get(beta, "k"))
def test_two_routed_stores_holding_one_key_refuse_the_pull_and_keep_the_earlier_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
one, two = Path(tmp) / "one.cc", Path(tmp) / "two.cc"
shutil.copyfile(VECTORS / "catalog-store.cc", one)
shutil.copyfile(VECTORS / "run-store.cc", two)
cache = alpha_beta("alpha", "beta")
cache.add_backing_store(SingleFileMessagePackBackingStore(one), [DOCUMENTS["alpha"].type])
cache.pull_all_backing_stores()
cache.add_backing_store(SingleFileMessagePackBackingStore(two), [DOCUMENTS["beta"].type])
with self.assertRaises(Exception) as caught:
cache.pull_all_backing_stores()
self.assertIn("'qx'", str(caught.exception))
self.assertEqual(cache.get(DOCUMENTS["alpha"], "qx"), {"text": "a"})
self.assertIsNone(cache.get(DOCUMENTS["beta"], "qx"))
def test_a_foreign_record_is_listed_and_its_bytes_survive_writes_and_removals_elsewhere(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.cc"
shutil.copyfile(VECTORS / "foreign-store.cc", path)
before = stored(path, "k")
cache = alpha_beta("alpha", store=SingleFileMessagePackBackingStore(path))
cache.pull_all_backing_stores()
[record] = cache.foreign_records()
self.assertEqual((record.key, record.schema_id, record.schema_name), ("k", IDS["gamma"], "tests.ki_gamma"))
self.assertEqual(record.stored_at, before[1])
cache.put(DOCUMENTS["alpha"], "m", {"text": "m"})
cache.delete(DOCUMENTS["alpha"], "j")
self.assertEqual(stored(path, "k"), before)
self.assertEqual(file_records(path), [["k", "tests.ki_gamma"], ["m", "tests.ki_alpha"]])
claimed = alpha_beta("alpha", "gamma", store=SingleFileMessagePackBackingStore(path))
claimed.pull_all_backing_stores()
self.assertEqual(claimed.foreign_records(), [])
self.assertEqual(claimed.get(DOCUMENTS["gamma"], "k"), {"text": "g"})
def test_the_foreign_list_is_sorted_by_utf16_code_unit_and_replaced_by_the_next_pull(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.cc"
store = SingleFileMessagePackBackingStore(path)
gamma = DOCUMENTS["gamma"]
writer = alpha_beta("gamma", store=store)
for key in ("\U00010400", "A", "b", "B"):
writer.put(gamma, key, {"text": "x"})
cache = alpha_beta("alpha", store=SingleFileMessagePackBackingStore(path))
cache.pull_all_backing_stores()
self.assertEqual([record.key for record in cache.foreign_records()], ["B", "b", "\U00010400", "A"])
for key in ("B", "b", "\U00010400", "A"):
writer.delete(gamma, key)
cache.pull_all_backing_stores()
self.assertEqual(cache.foreign_records(), [])
class OwnershipAndStoreKindTests(unittest.TestCase):
"""The cache judge answers on its own where the store has no commit guard (JSONL) or the records live in two stores."""
def test_a_jsonl_store_gets_no_help_from_a_disk_judge(self) -> None:
from cultcache_py import JsonLinesBackingStore
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.jsonl"
gamma = DOCUMENTS["gamma"]
alpha_beta("gamma", store=JsonLinesBackingStore(path)).put(gamma, "k", {"text": "g"})
before = path.read_bytes()
cache = alpha_beta("alpha", store=JsonLinesBackingStore(path))
cache.pull_all_backing_stores()
self.assertEqual([record.key for record in cache.foreign_records()], ["k"])
with self.assertRaises(SchemaConflictError):
cache.put(DOCUMENTS["alpha"], "k", {"text": "x"})
with self.assertRaises(SchemaConflictError):
cache.delete(DOCUMENTS["alpha"], "k")
self.assertEqual(path.read_bytes(), before)
cache.put(DOCUMENTS["alpha"], "j", {"text": "a"}) # the foreign line is carried through a write elsewhere
self.assertIn(b"tests.ki_gamma", path.read_bytes())
def test_a_foreign_key_in_two_stores_refuses_the_pull(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
one, two = Path(tmp) / "one.cc", Path(tmp) / "two.cc"
for path in (one, two):
alpha_beta("gamma", store=SingleFileMessagePackBackingStore(path)).put(DOCUMENTS["gamma"], "k", {"text": "g"})
cache = alpha_beta("alpha")
cache.add_backing_store(SingleFileMessagePackBackingStore(one), [DOCUMENTS["alpha"].type])
cache.add_backing_store(SingleFileMessagePackBackingStore(two), [DOCUMENTS["beta"].type])
with self.assertRaises(Exception) as caught:
cache.pull_all_backing_stores()
self.assertIn("'k'", str(caught.exception))
def test_a_type_that_owns_the_held_schema_id_replaces_the_record_and_the_key_keeps_one_type(self) -> None:
successor = define_database_entry_type(
"tests.ki_alpha2",
[database_entry_field("text", 0, codec=t.string)],
schema_id="sha256:" + "a" * 64,
schema_name="tests.ki_alpha2",
compatible_schema_ids=[IDS["alpha"]],
)
for routed in (False, True):
with self.subTest(routed=routed), tempfile.TemporaryDirectory() as tmp:
one, two = Path(tmp) / "one.cc", Path(tmp) / "two.cc"
made = CultCache.builder().register_document_type(DOCUMENTS["alpha"]).register_document_type(successor)
if routed:
made.add_backing_store(SingleFileMessagePackBackingStore(one), [DOCUMENTS["alpha"].type])
made.add_backing_store(SingleFileMessagePackBackingStore(two), [successor.type])
else:
made.add_generic_store(SingleFileMessagePackBackingStore(one))
cache = made.build()
cache.put(DOCUMENTS["alpha"], "k", {"text": "old"})
if routed:
with self.assertRaises(SchemaConflictError): # one key would live in two stores
cache.put(successor, "k", {"text": "new"})
self.assertEqual(cache.get(DOCUMENTS["alpha"], "k"), {"text": "old"})
else:
cache.put(successor, "k", {"text": "new"})
self.assertIsNone(cache.get(DOCUMENTS["alpha"], "k"))
self.assertEqual(cache.get(successor, "k"), {"text": "new"})
self.assertEqual(file_records(one), [["k", "tests.ki_alpha2"]])

View file

@ -0,0 +1,492 @@
from __future__ import annotations
import dataclasses
import shutil
import threading
import tempfile
import unittest
from pathlib import Path
import msgpack
from cultcache_py.cache import CultCacheError
from cultcache_py import (
CultCache,
CultCachePayloadDepthError,
SlotCodec,
SchemaConflictError,
SingleFileMessagePackBackingStore,
UnionArm,
UnreadSlots,
database_entry_field,
define_database_entry_type,
define_record_codec,
leaves as t,
)
import test_union_parity as _parity
from test_union_parity import (
VECTORS,
_surface_value,
_vector,
surface_document,
unit_outcome,
)
SLOT = "extra-slot" # the canary: the value of every extra slot in the vectors; no refusal may carry it
# The vectors name the C# types; this runtime names a type by its codec's name, a document by its document type.
_NAMES = {"UnionSurface": surface_document.type, "SurfaceDeclaring": surface_document.type, "Denied2": "Denied"}
slot_unit_document = define_database_entry_type(
"tests.slot_unit",
[database_entry_field("name", 0, codec=t.string), database_entry_field("outcome", 1, codec=unit_outcome)],
name="name",
)
# The reader of extra-slot-consent.cc: the surface document's slots under a new schema id that lists the old one compatible.
declaring_document = define_database_entry_type(
"tests.union_surface",
[
database_entry_field("id", 0, codec=t.string),
database_entry_field("version", 1, codec=t.int32),
database_entry_field("root", 2, codec=_parity._node),
database_entry_field("outcomes", 3, codec=t.list(_parity.outcome)),
database_entry_field("part", 4, codec=t.nullable(_parity.item)),
database_entry_field("crafted", 5, codec=t.nullable(_parity.crafted)),
database_entry_field("missing", 6, codec=_parity._node),
],
name="id",
schema_id="tests.union_surface.v2",
schema_name="tests.union_surface",
compatible_schema_ids=("tests.union_surface.wide",),
)
# file -> (type, slots carried, slots declared, every mark at depth 0): the README table.
_PAYLOADS = {
"extra-slot-record.msgpack": ("UnionSurface", 8, 7, True),
"extra-slot-nested-record.msgpack": ("Composition", 3, 2, False),
"extra-slot-arm.msgpack": ("Box", 3, 2, False),
"extra-slot-list-element.msgpack": ("Text", 3, 2, False),
"depth-skipped-at-limit.msgpack": ("UnionSurface", 8, 7, True),
}
def _copy_store_without_variants(source: Path, target: Path) -> None:
"""This runtime holds no variant records (a five-slot record makes a store unreadable here), so the store vector is
walked with its variant row removed; every other byte is the C# writer's."""
[snapshot] = list(msgpack.Unpacker(open(source, "rb"), raw=False))
snapshot[2] = [record for record in snapshot[2] if len(record) <= 4]
target.write_bytes(msgpack.packb(snapshot, use_bin_type=True))
def _open(path: Path, *documents) -> CultCache:
builder = CultCache.builder()
for document in documents:
builder.register_document_type(document)
cache = builder.add_generic_store(SingleFileMessagePackBackingStore(path)).build()
cache.pull_all_backing_stores()
return cache
def _rows():
return msgpack.unpackb(_vector("slot-outcomes.msgpack"), raw=False)
class PayloadMarkTests(unittest.TestCase):
def test_every_read_only_payload_decodes_and_names_its_mark(self) -> None:
for file, (type_name, carried, declared, depth0) in _PAYLOADS.items():
with self.subTest(file=file):
value, mark = surface_document.decode_payload_marked(_vector(file))
self.assertEqual(value, _surface_value())
self.assertEqual(mark, UnreadSlots(_NAMES.get(type_name, type_name), carried, declared, 1, depth0))
self.assertEqual(surface_document.decode_payload(_vector(file)), _surface_value())
def test_the_outcomes_manifest_still_counts_nine_rows_and_four_read_only(self) -> None:
rows = msgpack.unpackb(_vector("outcomes.msgpack"), raw=False)
self.assertEqual(len(rows), 9)
self.assertEqual([row[0] for row in rows if row[1] == "read-only"], [f for f in _PAYLOADS if f != "depth-skipped-at-limit.msgpack"])
def test_a_clean_payload_and_a_short_one_carry_no_mark(self) -> None:
self.assertEqual(surface_document.decode_payload_marked(_vector("union-surface.msgpack")), (_surface_value(), None))
slots = msgpack.unpackb(_vector("union-surface.msgpack"), raw=False)
slots[2] = [1, ["only-id"]] # a Text arm with a missing trailing slot: defaults, no mark
_, mark = surface_document.decode_payload_marked(msgpack.packb(slots, use_bin_type=True))
self.assertIsNone(mark)
def test_a_retired_slot_below_the_highest_key_does_not_mark(self) -> None:
slots = msgpack.unpackb(_vector("union-surface.msgpack"), raw=False)
# Image's declared slots are 0, 1, 3 (2 retired): four carried, four declared.
image = slots[2][1][1][2]
self.assertEqual(image[0], 5)
image[1][2] = "retired-slot-value"
_, mark = surface_document.decode_payload_marked(msgpack.packb(slots, use_bin_type=True))
self.assertIsNone(mark)
def test_the_depth_check_runs_before_the_mark(self) -> None:
with self.assertRaises(CultCachePayloadDepthError):
surface_document.decode_payload_marked(_vector("depth-skipped-past-limit.msgpack"))
def test_the_unit_arm_with_extra_slots_is_read_past_and_marked(self) -> None:
payload = msgpack.packb(["unit-arm", [2, [SLOT]]], use_bin_type=True)
value, mark = slot_unit_document.decode_payload_marked(payload)
self.assertEqual(value, {"name": "unit-arm", "outcome": UnionArm("Denied")})
self.assertEqual(mark, UnreadSlots("Denied", 1, 0, 1, False))
self.assertEqual(slot_unit_document.decode_payload_marked(msgpack.packb(["ok", [2, []]]))[1], None)
def test_a_codec_decoded_outside_a_payload_decode_notes_nothing(self) -> None:
self.assertEqual(unit_outcome.decode([2, [SLOT]]), UnionArm("Denied"))
def test_the_scope_belongs_to_its_thread(self) -> None:
def decode_elsewhere(raw):
thread = threading.Thread(target=lambda: unit_outcome.decode([2, [SLOT]]))
thread.start()
thread.join()
return raw
outer = define_database_entry_type("tests.threaded", [database_entry_field("a", 0, codec=SlotCodec(lambda v: v, decode_elsewhere))])
self.assertIsNone(outer.decode_payload_marked(msgpack.packb(["x"]))[1])
def test_a_nested_payload_decode_restores_the_outer_scope(self) -> None:
def decode_clean_inside(raw):
surface_document.decode_payload_marked(_vector("union-surface.msgpack"))
return raw
inner = define_record_codec("Inner", [database_entry_field("x", 0, codec=t.string)])
outer = define_database_entry_type(
"tests.nested",
[
database_entry_field("a", 0, codec=SlotCodec(lambda v: v, decode_clean_inside)),
database_entry_field("b", 1, codec=inner),
],
)
_, mark = outer.decode_payload_marked(msgpack.packb(["x", ["v", SLOT]]))
self.assertEqual(mark, UnreadSlots("Inner", 2, 1, 1, False))
class StoreRowTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.tmp, True)
def _store(self, name: str) -> tuple[Path, CultCache]:
path = self.tmp / name
_copy_store_without_variants(VECTORS / name, path)
documents = (declaring_document,) if name == "extra-slot-consent.cc" else (surface_document, slot_unit_document)
return path, _open(path, *documents)
def _document(self, store: str, key: str):
if key == "unit-arm":
return slot_unit_document
return declaring_document if store == "extra-slot-consent.cc" else surface_document
def test_every_row_is_writable_exactly_where_the_vector_says_and_refuses_with_its_mark(self) -> None:
walked = 0
for store, key, writable, explained, type_name, carried, declared, occurrences, flatten in _rows():
if flatten is not None:
continue # a variant: this runtime holds none (deviation)
walked += 1
with self.subTest(store=store, key=key):
path, cache = self._store(store)
document = self._document(store, key)
before = path.read_bytes()
self.assertEqual(cache.is_writable(document, key), writable)
held = cache.get(document, key)
self.assertIsNotNone(held)
if writable:
continue
self.assertEqual(explained, False)
for attempt in (
lambda: cache.put(document, key, held),
lambda: cache.delete(document, key),
lambda: cache.put_envelope(document, cache.get_required_envelope(document, key)),
lambda: cache.put_envelopes(document, [cache.get_required_envelope(document, key)]),
lambda: cache.update(document, key, lambda value: value),
):
with self.assertRaises(SchemaConflictError) as caught:
attempt()
refusal = caught.exception
self.assertEqual(
refusal.unread_slots,
UnreadSlots(_NAMES.get(type_name, type_name), carried, declared, occurrences, refusal.unread_slots.depth0_only),
)
self.assertEqual(refusal.record_key, key)
message = str(refusal)
self.assertNotIn(SLOT, message)
self.assertIn(refusal.unread_slots.type_name, message)
self.assertIn(f"{carried} slots", message)
self.assertIn(f"declares {declared}", message)
self.assertEqual(path.read_bytes(), before)
self.assertTrue(cache.get(document, key) is held)
self.assertEqual(walked, 7) # eight rows, the variant not walked
def test_a_key_no_store_holds_and_another_key_are_writable_while_a_marked_one_is_held(self) -> None:
path, cache = self._store("extra-slot-store.cc")
self.assertFalse(cache.is_writable(surface_document, "marked"))
self.assertTrue(cache.is_writable(surface_document, "nothing-here"))
cache.put(surface_document, "other", cache.get_required(surface_document, "clean"))
self.assertEqual(cache.get(surface_document, "other"), _surface_value())
def test_consent_lands_an_explained_mark_and_refuses_the_others(self) -> None:
path, cache = self._store("extra-slot-consent.cc")
self.assertTrue(cache.is_writable(declaring_document, "consented"))
cache.put(declaring_document, "consented", cache.get_required(declaring_document, "consented"))
[stored] = [e for e in SingleFileMessagePackBackingStore(path).pull_all() if e.key == "consented"]
self.assertEqual(stored.schema_id, "tests.union_surface.v2")
self.assertEqual(len(msgpack.unpackb(stored.payload, raw=False)), 7) # the consented slot is gone
self.assertTrue(cache.is_writable(declaring_document, "consented"))
for key in ("over-consent", "nested"):
self.assertFalse(cache.is_writable(declaring_document, key), key)
with self.assertRaises(SchemaConflictError):
cache.put(declaring_document, key, cache.get_required(declaring_document, key))
def test_a_reload_marks_again_and_a_write_of_a_clean_envelope_over_a_marked_key_is_refused(self) -> None:
path, cache = self._store("extra-slot-store.cc")
envelope = cache.get_required_envelope(surface_document, "clean")
with self.assertRaises(SchemaConflictError):
cache.put_envelope(surface_document, type(envelope)(**{**envelope.__dict__, "key": "marked"}))
cache.pull_all_backing_stores()
self.assertFalse(cache.is_writable(surface_document, "marked"))
def test_a_written_envelope_that_carries_extra_slots_is_held_read_only(self) -> None:
path, cache = self._store("extra-slot-store.cc")
envelope = cache.get_required_envelope(surface_document, "clean")
carrying = type(envelope)(**{**envelope.__dict__, "key": "arrived", "payload": _vector("extra-slot-arm.msgpack")})
cache.put_envelope(surface_document, carrying)
self.assertFalse(cache.is_writable(surface_document, "arrived"))
with self.assertRaises(SchemaConflictError):
cache.delete(surface_document, "arrived")
cache.pull_all_backing_stores()
self.assertFalse(cache.is_writable(surface_document, "arrived"))
def test_a_batch_of_envelopes_that_carry_extra_slots_is_held_read_only(self) -> None:
path, cache = self._store("extra-slot-store.cc")
envelope = cache.get_required_envelope(surface_document, "clean")
batch = [
type(envelope)(**{**envelope.__dict__, "key": key, "payload": _vector(file)})
for key, file in (("b1", "extra-slot-arm.msgpack"), ("b2", "union-surface.msgpack"))
]
cache.put_envelopes(surface_document, batch)
self.assertEqual([cache.is_writable(surface_document, key) for key in ("b1", "b2")], [False, True])
def test_a_mark_under_an_id_the_reader_does_not_list_compatible_is_not_consented(self) -> None:
unlisted = define_database_entry_type(
"tests.union_surface",
[database_entry_field("id", 0, codec=t.string)] + [database_entry_field(n, i, codec=c) for n, i, c in (
("version", 1, t.int32), ("root", 2, _parity._node), ("outcomes", 3, t.list(_parity.outcome)),
("part", 4, t.nullable(_parity.item)), ("crafted", 5, t.nullable(_parity.crafted)), ("missing", 6, _parity._node))],
name="id",
schema_id="tests.union_surface.v2",
schema_name="tests.union_surface",
)
path = self.tmp / "unlisted.cc"
_copy_store_without_variants(VECTORS / "extra-slot-consent.cc", path)
cache = _open(path, unlisted)
self.assertFalse(cache.is_writable(unlisted, "consented"))
def test_a_written_envelope_is_judged_on_the_id_it_is_stored_under_so_a_reload_agrees(self) -> None:
path, cache = self._store("extra-slot-consent.cc")
arrived = next(e for e in SingleFileMessagePackBackingStore(path).pull_all() if e.key == "consented")
self.assertEqual(arrived.schema_id, "tests.union_surface.wide")
cache.put_envelope(declaring_document, dataclasses.replace(arrived, key="via-envelope"))
cache.put_envelopes(declaring_document, [dataclasses.replace(arrived, key="via-batch")])
in_memory = [cache.is_writable(declaring_document, k) for k in ("via-envelope", "via-batch")]
cache.pull_all_backing_stores()
self.assertEqual([cache.is_writable(declaring_document, k) for k in ("via-envelope", "via-batch")], in_memory)
self.assertEqual(in_memory, [False, False])
def test_is_writable_agrees_with_the_write_for_a_key_a_store_carries_foreign(self) -> None:
path = self.tmp / "foreign.cc"
_copy_store_without_variants(VECTORS / "extra-slot-store.cc", path)
cache = _open(path, surface_document) # tests.slot_unit is not registered: its record is carried foreign
self.assertEqual([r.key for r in cache.foreign_records()], ["unit-arm"])
self.assertFalse(cache.is_writable(surface_document, "unit-arm"))
with self.assertRaises(SchemaConflictError):
cache.put(surface_document, "unit-arm", _surface_value())
with self.assertRaises(SchemaConflictError):
cache.delete(surface_document, "unit-arm")
def test_every_key_of_a_batch_is_checked_wherever_the_marked_one_stands(self) -> None:
path, cache = self._store("extra-slot-store.cc")
before = path.read_bytes()
envelope = cache.get_required_envelope(surface_document, "clean")
fresh = dataclasses.replace(envelope, key="fresh")
marked = dataclasses.replace(envelope, key="marked")
for batch in ([fresh, marked], [marked, fresh], [fresh, dataclasses.replace(envelope, key="f2"), marked]):
with self.assertRaises(SchemaConflictError) as caught:
cache.put_envelopes(surface_document, batch)
self.assertEqual(caught.exception.record_key, "marked")
self.assertEqual(path.read_bytes(), before)
self.assertIsNone(cache.get(surface_document, "fresh"))
def test_only_the_marks_that_hold_a_record_read_only_are_kept_by_key(self) -> None:
_, store = self._store("extra-slot-store.cc")
self.assertEqual({k: v[0] for k, v in store._state.unread.items()}, {"marked": surface_document.type, "unit-arm": "tests.slot_unit"})
_, consent = self._store("extra-slot-consent.cc")
self.assertEqual(sorted(consent._state.unread), ["nested", "over-consent"]) # the consented mark is no state
def test_an_arrival_with_no_catalog_entry_under_a_listed_id_is_not_consented(self) -> None:
wide = next(e for e in SingleFileMessagePackBackingStore(VECTORS / "extra-slot-consent.cc").pull_all() if e.key == "consented")
bare = dataclasses.replace(wide, catalog_entry=None)
class Held:
def pull_all(self):
return [bare]
def push(self, envelope): ...
def push_all(self, envelopes): ...
def delete(self, type, key): ...
cache = CultCache.builder().register_document_type(declaring_document).add_generic_store(Held()).build()
cache.pull_all_backing_stores()
self.assertFalse(cache.is_writable(declaring_document, "consented"))
def test_a_removal_is_refused_where_is_writable_says_no(self) -> None:
path, cache = self._store("extra-slot-store.cc")
before = path.read_bytes()
other = define_database_entry_type("tests.other", [database_entry_field("a", 0, codec=t.string)], name="a")
cache2 = _open(self.tmp / "extra-slot-store.cc", surface_document, slot_unit_document, other)
self.assertFalse(cache2.is_writable(other, "clean"))
with self.assertRaises(SchemaConflictError):
cache2.delete(other, "clean")
self.assertEqual(path.read_bytes(), before)
self.assertIsNotNone(cache2.get(surface_document, "clean"))
self.assertTrue(cache2.is_writable(other, "nothing-here"))
def test_a_global_document_is_not_writable_at_a_key_other_than_the_global_one(self) -> None:
solo = define_database_entry_type("tests.solo", [database_entry_field("a", 0, codec=t.string)], global_document=True)
cache = CultCache.builder().register_document_type(solo).build()
self.assertFalse(cache.is_writable(solo, "elsewhere"))
with self.assertRaises(CultCacheError):
cache.put(solo, "elsewhere", {"a": "x"})
with self.assertRaises(CultCacheError):
cache.delete(solo, "elsewhere")
self.assertTrue(cache.is_writable(solo, CultCache.GLOBAL_KEY))
def test_a_reload_replaces_the_marks_wholesale(self) -> None:
path, cache = self._store("extra-slot-store.cc")
self.assertFalse(cache.is_writable(surface_document, "marked"))
# The store is repaired by its owner: the marked record is rewritten clean. The reload must forget the old mark.
[snapshot] = list(msgpack.Unpacker(open(path, "rb"), raw=False))
clean = next(r for r in snapshot[2] if r[0] == "clean")
for record in snapshot[2]:
if record[0] in ("marked", "unit-arm"):
record[3] = clean[3] if record[0] == "marked" else record[3]
snapshot[2] = [r for r in snapshot[2] if r[0] != "unit-arm"]
path.write_bytes(msgpack.packb(snapshot, use_bin_type=True))
cache.pull_all_backing_stores()
self.assertTrue(cache.is_writable(surface_document, "marked"))
self.assertEqual(cache._state.unread, {})
def test_a_listed_id_whose_catalog_reports_no_extra_slot_does_not_consent(self) -> None:
wide = next(e for e in SingleFileMessagePackBackingStore(VECTORS / "extra-slot-consent.cc").pull_all() if e.key == "consented")
same = dataclasses.replace(wide, catalog_entry=declaring_document.catalog_entry()) # the stored entry has no slot past the reader's
class Held:
def pull_all(self):
return [same]
def push(self, envelope): ...
def push_all(self, envelopes): ...
def delete(self, type, key): ...
cache = CultCache.builder().register_document_type(declaring_document).add_generic_store(Held()).build()
cache.pull_all_backing_stores()
self.assertFalse(cache.is_writable(declaring_document, "consented"))
def test_a_record_stored_under_the_readers_own_id_is_never_consented(self) -> None:
own = dataclasses.replace(
next(e for e in SingleFileMessagePackBackingStore(VECTORS / "extra-slot-consent.cc").pull_all() if e.key == "consented"),
schema_id="tests.union_surface.v2",
)
wider = dataclasses.replace(own.catalog_entry, schema_id="tests.union_surface.v2", compatible_schema_ids=("tests.union_surface.v2",))
listing = dataclasses.replace(
declaring_document, schema_id="tests.union_surface.v2", compatible_schema_ids=("tests.union_surface.wide", "tests.union_surface.v2")
)
class Held:
def pull_all(self):
return [dataclasses.replace(own, catalog_entry=wider)]
def push(self, envelope): ...
def push_all(self, envelopes): ...
def delete(self, type, key): ...
cache = CultCache.builder().register_document_type(listing).add_generic_store(Held()).build()
cache.pull_all_backing_stores()
self.assertFalse(cache.is_writable(listing, "consented"))
def test_a_removal_by_the_owner_of_a_key_held_as_another_type_removes_it_as_a_write_would_replace_it(self) -> None:
a = define_database_entry_type(
"tests.owned_a", [database_entry_field("a", 0, codec=t.string)], name="a", indexes={"by_a": "a"}
)
b = define_database_entry_type(
"tests.owned_b",
[database_entry_field("a", 0, codec=t.string)],
compatible_schema_ids=(a.catalog_entry().schema_id,),
)
path = self.tmp / "owned.cc"
writer = _open(path, a)
writer.put(a, "k", {"a": "x"})
writer.put(a, "other", {"a": "y"})
cache = _open(path, a, b)
self.assertTrue(cache.is_writable(b, "k"))
cache.delete(b, "k")
self.assertIsNone(cache.get(a, "k"))
self.assertIsNone(cache.get_by_index(a, "by_a", "x"))
self.assertEqual([e.key for e in SingleFileMessagePackBackingStore(path).pull_all()], ["other"])
self.assertTrue(cache.is_writable(b, "k"))
def test_an_owners_removal_is_one_store_call_so_the_cache_and_the_store_agree_when_it_fails(self) -> None:
a = define_database_entry_type("tests.one_a", [database_entry_field("a", 0, codec=t.string)], name="a")
b = define_database_entry_type(
"tests.one_b", [database_entry_field("a", 0, codec=t.string)], compatible_schema_ids=(a.catalog_entry().schema_id,)
)
path = self.tmp / "one.cc"
_open(path, a).put(a, "k", {"a": "x"})
class SecondDeleteFails(SingleFileMessagePackBackingStore):
calls = 0
def delete(self, type, key):
self.calls += 1
if self.calls > 1:
raise OSError("second delete")
super().delete(type, key)
store = SecondDeleteFails(path)
cache = CultCache.builder().register_document_type(a).register_document_type(b).add_generic_store(store).build()
cache.pull_all_backing_stores()
cache.delete(b, "k")
self.assertEqual(store.calls, 1)
self.assertIsNone(cache.get(a, "k"))
self.assertEqual(SingleFileMessagePackBackingStore(path).pull_all(), [])
def test_writing_another_record_and_flushing_carries_the_marked_one_unchanged(self) -> None:
path, cache = self._store("extra-slot-store.cc")
original = {e.key: e for e in SingleFileMessagePackBackingStore(path).pull_all()}
cache.put(surface_document, "clean", {**_surface_value(), "version": 9})
after = {e.key: e for e in SingleFileMessagePackBackingStore(path).pull_all()}
for key in ("marked", "unit-arm"):
self.assertEqual(after[key].payload, original[key].payload, key)
self.assertEqual(after[key].schema_id, original[key].schema_id, key)
self.assertEqual(after[key].stored_at, original[key].stored_at, key)
self.assertNotEqual(after["clean"].payload, original["clean"].payload)
self.assertEqual(after["marked"].catalog_entry, original["marked"].catalog_entry)
def test_the_vector_store_loads_under_the_reference_documents_and_marks_nothing_else(self) -> None:
path, cache = self._store("extra-slot-store.cc")
self.assertTrue(cache.is_writable(surface_document, "clean"))
self.assertFalse(cache.is_writable(surface_document, "marked"))
self.assertFalse(cache.is_writable(slot_unit_document, "unit-arm"))
self.assertEqual(
cache.get(slot_unit_document, "unit-arm"), {"name": "unit-arm", "outcome": UnionArm("Denied")}
)
if __name__ == "__main__":
unittest.main()

View file

@ -261,7 +261,9 @@ class UnionSurfaceVectorTests(unittest.TestCase):
else:
read_only += 1
self.assertEqual(outcome_kind, "read-only", file)
self.assertEqual(surface_document.decode_payload(payload), _surface_value(), file)
value, mark = surface_document.decode_payload_marked(payload)
self.assertEqual(value, _surface_value(), file)
self.assertIsNotNone(mark, file) # read past and marked: tests/test_slot_readonly.py pins what the mark holds
self.assertEqual((refused, read_only), (5, 4))
@ -526,7 +528,9 @@ class BoundsVectorTests(unittest.TestCase):
surface_document.encode_payload(surface_document.decode_payload(payload)), _vector(detail), file
)
elif outcome_kind == "read-only":
self.assertEqual(surface_document.decode_payload(payload), _surface_value(), file)
value, mark = surface_document.decode_payload_marked(payload)
self.assertEqual(value, _surface_value(), file)
self.assertIsNotNone(mark, file)
else:
with self.assertRaises(ValueError, msg=file) as caught:
surface_document.decode_payload(payload)
@ -657,6 +661,78 @@ def _as_tuple(tree: object) -> object:
return tuple(_as_tuple(child) for child in tree) if isinstance(tree, list) else tree
class BytesAfterVectorTests(unittest.TestCase):
CLAUSE = "bytes after its first MessagePack value (refused on read)"
def _assert_chain_clean(self, error: BaseException, file: str) -> None:
link: BaseException | None = error
while link is not None:
self.assertNotIn(CANARY, str(link), file)
self.assertNotIsInstance(link, msgpack.ExtraData, file)
link = link.__cause__ or link.__context__
def test_every_bytes_after_vector_is_refused(self) -> None:
base = _vector("union-surface.msgpack")
entries = msgpack.unpackb(_vector("bytes-after-outcomes.msgpack"), raw=False)
self.assertEqual(len(entries), 4)
for file, outcome_kind, union, key, detail in entries:
self.assertEqual((outcome_kind, union, key, detail), ("refused", "UnionSurface", None, "bytes-after"), file)
payload = _vector(file)
self.assertTrue(payload.startswith(base), file)
count = len(payload) - len(base)
with self.assertRaises(ValueError, msg=file) as caught:
surface_document.decode_payload(payload)
error = caught.exception
self.assertIs(type(error), ValueError, file)
self.assertNotIsInstance(error, CultCachePayloadDepthError, file)
self.assertEqual(
str(error), f"{surface_document.type} payload has {count} {self.CLAUSE}.", file
)
self.assertNotIn("the limit is", str(error), file)
self._assert_chain_clean(error, file)
def test_a_single_file_store_holding_a_tail_as_a_record_payload_refuses_on_load(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "tail.cc"
shutil.copyfile(VECTORS / "union-surface.cc", path)
store = SingleFileMessagePackBackingStore(path)
[envelope] = store.pull_all()
store.push(dataclasses.replace(envelope, payload=_vector("bytes-after-nil.msgpack")))
cache = (
CultCache.builder()
.register_document_type(surface_document)
.add_generic_store(SingleFileMessagePackBackingStore(path))
.build()
)
with self.assertRaises(ValueError) as caught:
cache.pull_all_backing_stores()
self.assertIn(f"1 {self.CLAUSE}", str(caught.exception))
self.assertIn(surface_document.type, str(caught.exception))
self.assertIsNone(cache.get(surface_document, "surface"))
def test_depth_wins_over_a_tail_whatever_the_unpackers_own_stack_limit(self) -> None:
# Vector-independent: a value nested past 256 is the depth refusal even with a tail, as C# throws at container 257
# before it reaches the tail; 2000 is past msgpack's own stack, so the winner is not a library constant.
for depth in (257, 300, 900, 2000, 100000):
for tail in (b"\xc0", b"\xc1\xc1"):
with self.subTest(depth=depth, tail=len(tail)):
with self.assertRaises(CultCachePayloadDepthError) as caught:
surface_document.decode_payload(b"\x91" * depth + b"\xc0" + tail)
self.assertIn("refused on read", str(caught.exception))
for depth in (1, 255, 256):
with self.subTest(at=depth):
with self.assertRaises(ValueError) as caught:
surface_document.decode_payload(b"\x91" * depth + b"\xc0\xc0")
self.assertNotIsInstance(caught.exception, CultCachePayloadDepthError)
self.assertIn(f"1 {self.CLAUSE}", str(caught.exception))
def test_a_payload_that_ends_early_is_the_decoders_not_bytes_after(self) -> None:
truncated = _vector("union-surface.msgpack")[:-1]
with self.assertRaises(Exception) as caught:
surface_document.decode_payload(truncated)
self.assertNotIn("bytes after", str(caught.exception))
class PayloadDepthRuleTests(unittest.TestCase):
"""The nesting rule at its seam, on definitions that are not unions: the same routine serves any payload codec."""

View file

@ -8,6 +8,17 @@ only in git history.
### Breaking
Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0).
- A record whose payload carries unread slots is read-only. A payload with a slot past its type's declared slots, at any depth (record, nested record, list element, union arm, unit arm), still decodes, and every write or removal of its key is now refused before any store is touched: `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` (the whole batch) and `delete`, so `update` too. The refusal is `SchemaConflictError` with the new `unread_slots`; its message names the type and both slot counts and never a slot value. Holders of such a record used to overwrite it and drop the extra slots silently. The payload nesting refusal still runs first. A held payload this build cannot read at all (an arm it lacks from a newer writer, or a corrupt record) is read-only too: every write or removal is refused with an error naming the type and key (a newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, as no cache call removes it), and `is_writable` answers false. `load_soa` is a write of every held key it omits or changes, so it refuses to drop or replace a read-only record. `is_writable` also answers false for a key a store carries as a foreign record and for a type other than the one holding the key, as the writes do. Each write over a held key decodes the held payload once to ask (about 40% of a `put` of a 200,000-element record, measured in release); keys nothing holds pay nothing.
- cultmesh-rs `pull_rudp_catalog_snapshot` applies a snapshot as one cache commit (`put_prepared_batch`), as C# does: a snapshot document for a key the node holds read-only, or any other refusal, applies nothing. It used to apply document by document, so a refusal ended the pull with the earlier documents kept. A snapshot whose types route to different backing stores is now refused whole, as a batch cannot span stores. C# `CultCache.Commit` refuses a cross-store commit the same way (`The batch spans A and B; a commit lands in one home store`), so the two agree; a test pins it.
- `SchemaConflictError` gains the public field `unread_slots: Option<UnreadSlots>`; code that builds it with a struct literal must set it (`None`).
- A unit arm with slots (`[N, [extra...]]`) decodes as the unit arm and marks the record, where the derive refused it.
- The registration map's value changes from the schema name to a private struct holding the schema name and the unread-slots check.
- A cache whose last `pull_all_backing_stores` was refused (a tail, a payload past the nesting bound, a duplicate unique-index value, a retype conflict, a record from the wrong store) refuses every `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch`, `update` and `delete` with `CultCacheLoadRefused` until a pull succeeds. Before, it stayed writable over an empty image, and the next batch write replaced the store with only the new records.
- `CacheBackingStore::push_all` writes the given entries onto the store as it is when the write lands, under the store's lock or transaction: a record under the same identity is replaced and no other record is removed. It used to replace everything the store held, so a cache whose image was stale (another writer had added a record since its pull) wiped that record with `put_prepared_batch`. `put_prepared_batch` now hands the store its staged records alone, as C# `CommitBatch` and Python `push_all` do; the lay-back of foreign records is gone because they are never rewritten. A custom `CacheBackingStore` must give `push_all` merge semantics. `push_all` reads the store, so through a dangling link it is refused like `push` and no longer replaces the link; a soft flush is unchanged.
- A cache with a backing store also refuses every write and removal, with `CultCacheLoadRefused`, until it has pulled: a cache that never pulled, or that gained a store since its last pull, holds no image of its stores, and a batch from it replaced a populated store with only the new records. `is_writable` answers false meanwhile. A cache with no store needs no pull. Callers that attach a store and write (cultmesh nodes created with `pull_on_start: false`) must call `pull_all_backing_stores` first.
- `decode_payload`, `put_raw_envelope` and prepared batches refuse a payload with bytes after its first MessagePack value (`payload has {n} bytes after its first MessagePack value`), by count; the tail is never walked or quoted. They used to read the first value and keep or drop the tail, so a record with a stray tail was served and rewritten without it. A store holding such a record (single-file, redb, `load_soa`) is refused at load, naming the type, the key and the count, and stays unreadable until the record is repaired; the same check now runs on every record at load, as the C# and Python runtimes do at open. The nesting bound is part of that check, so a stored payload nested past it is refused at load too, where it used to load and refuse on read.
- 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.
- A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. This reverses 0.3.1, where `push_all` replaced a file with no readable stamp (healing it); the stamp rule now has one owner, the reader every write asks under the lock, and it accepts v1 and v3 (0.3.1 refused v3).
- `#[cultcache(index)]` means unique, as in C# `RefuseIndexSharing`: within one type and one alias no two records hold one non-blank value. A put, batch, push or load that would make two records hold a value is refused, naming both keys and the alias, and a store that already holds a duplicate does not open. A grouping index is no longer expressible; a consumer that relied on shared values must drop the `index` and look up by another way.
@ -17,10 +28,13 @@ only in git history.
- 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 cache holds one record per key. `put`, `put_envelope`, `put_raw_envelope` and a batch of a second type under a key the cache holds, or a key a store carries foreign, are refused with `SchemaConflictError` even with no store (a memory-only cache used to hold both), whatever schema id the envelope claims: another registered type's record is never owned. `put_envelope`, `put_raw_envelope`, `put_prepared_batch` and `load_soa` also refuse, at any key, an envelope whose schema id names another type registered in this cache (the id a pull would resolve the record by). An id this cache does not register is trusted as the envelope claims it: at a key this cache does not hold, the single-file store decides ownership by that id, so such an envelope can replace another writer's record stored under it, and a reader registering the type the id names serves the record as that type. Store-level writers called directly (`push`, `insert_entry_if_absent`, compare-and-swap) still trust the claimed id. A refused write or pull changes nothing. Two stores holding one key refuse the pull, naming the key and both types, and so does one store holding two records at a key that resolve to one type. A store holding a record no registered type claims now loads (it used to refuse with "No schema is registered for persisted entry type") and the record is carried, through `put_prepared_batch` too, which lays foreign records back byte for byte; `delete` at a foreign key is refused. `load_soa` refuses a second type at a key or a record at a foreign key, and still refuses an unregistered type. The redb stores still key by `(type, key)`, and their own writers (`push`, `insert_entry_if_absent`, compare-and-swap) still put a second type at a held key: a cache now refuses to pull such a redb store with `SchemaConflictError`, where it used to load both records.
- 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
- `UnreadSlots { type_name, slot_count, declared_count, occurrences }`, `decode_payload_marked` (the decode that returns the mark) and `CultCache::is_writable(type, key)`. The derives' slot visitors note into a thread-local scope the decode entry opens (`__enter_slots`, `__note_unread_slots`, `__UnitArmSlots`, doc-hidden); a decode outside that entry notes nothing.
- `CultCache::foreign_records()` and `ForeignRecord { key, schema_id, schema_name, stored_at }`: the records the last pull found that no registered type claims, sorted by key.
- Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name find the one with the smallest key (an index value is unique, below). A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`).
- `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::<SchemaConflictError>()`.

View file

@ -21,8 +21,14 @@ CultCache is a domain cache with persistence adapters.
- `CultCache` is the query and mutation surface.
- Domain structs implement `DatabaseEntry`.
- Entries are stored behind a `type::key` identity, so multiple entry types
can share a logical key without colliding.
- A cache holds one record per key whatever its type. Records are held per
`(type, key)`, but a write of a second type under a key the cache holds, or a
key a store carries foreign, is refused with `SchemaConflictError` naming the
key (no store needed); retyping a key is a delete, then an add. A store
record no registered type claims loads: the cache does not serve it, lists it
in `foreign_records()`, lays it back byte for byte through every write
including `put_prepared_batch`, and refuses a write or delete at its key.
Registering a type that claims its schema id picks it up at the next pull.
- Each payload is encoded directly from the known `DatabaseEntry` type into
MessagePack bytes.
- Backing stores are adapters, not the public data model.
@ -230,6 +236,17 @@ assert_eq!(rmp_serde::from_slice::<Node>(&bytes)?, tree);
# Ok::<(), Box<dyn std::error::Error>>(())
```
## Records written by a newer build
A payload that carries a slot past the slots its type declares, in a record, a nested record, a list element or a union
arm (a unit arm declares none), still decodes: the extra slots are skipped. The cache then holds that record read-only,
because writing it would drop what the newer writer stored. `put`, `put_envelope`, `put_raw_envelope`,
`put_prepared_batch` and `delete` of its key are refused with `SchemaConflictError`, whose `unread_slots` names the type
and the slot counts and never a value; `CultCache::is_writable(type, key)` answers the same check. An untouched
read-only record keeps its bytes through every other write. A short payload (missing trailing slots) is not marked, and
a payload nested past `MAX_PAYLOAD_DEPTH` is refused before anything is read. `decode_payload_marked` is the decode that
returns the `UnreadSlots` mark. This runtime has no compatible schema ids, so no mark is consented.
## Structure Of Arrays
The C# CultCache hot path exposes CPU-local SoA scans as:
@ -367,10 +384,9 @@ an empty store. Any other failure to reach it, such as a symlink loop, a file
where a parent directory should be, or a missing permission, is an I/O error.
A store file that exists and holds zero bytes is `Undecodable`: no CultCache
writer leaves one. A symbolic link whose target is gone is not a missing store:
reading it is an I/O error, so a read-then-write (`push`, `delete`, the exchanges)
fails before writing. Writes do not yet follow links: a write through a live link,
and a `push_all` through a dangling one, replace the link with a regular file and
leave its target alone. A refusal names no
reading it is an I/O error, so a read-then-write (`push`, `push_all`, `delete`, the exchanges)
fails before writing. Writes do not yet follow links: a write through a live link
replaces the link with a regular file and leaves its target alone. A refusal names no
value read from the file; it names a record only by its key and schema id, and
a header only when it has the shape `cultcache.store.v<digits>`.
@ -458,7 +474,7 @@ That is the Rust version of the original trick:
- C# refuses to store objects outside the `DatabaseEntry` root type.
- Runtime-visible `DatabaseEntry` subclasses get dedicated formatters.
- Unknown or unregistered types are rejected.
- A write of an unregistered type is rejected; a stored record of an unregistered type is carried as foreign, not decoded.
- MessagePack payload bytes deserialize through known concrete formatters, not
an open-world type loader or generic structured-value bridge.

View file

@ -381,6 +381,7 @@ fn slot_codec(ident: &syn::Ident, slots: &[CultCacheSlot]) -> proc_macro2::Token
where
__A: ::serde::de::SeqAccess<'de>,
{
let __ticket = ::cultcache_rs::__enter_slots();
#(#option_declarations)*
let mut index = 0usize;
while index <= #max_slot {
@ -390,7 +391,15 @@ fn slot_codec(ident: &syn::Ident, slots: &[CultCacheSlot]) -> proc_macro2::Token
}
index += 1;
}
while let ::std::option::Option::Some(_) = seq.next_element::<::serde::de::IgnoredAny>()? {}
// The slots past the declared ones are skipped and counted: the decode entry marks the record
// that carried them read-only.
let mut __extra = 0usize;
while seq.next_element::<::serde::de::IgnoredAny>()?.is_some() {
__extra += 1;
}
if __extra > 0 {
::cultcache_rs::__note_unread_slots(__ticket, stringify!(#ident), #max_slot + 1 + __extra, #max_slot + 1);
}
Ok(#ident {
#(#build_fields),*
})
@ -567,10 +576,13 @@ fn union_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
quote! {
#key => {
match seq
.next_element::<::std::option::Option<[(); 0]>>()
.next_element::<::std::option::Option<::cultcache_rs::__UnitArmSlots>>()
.map_err(|_| ::serde::de::Error::custom(#arity_message))?
{
::std::option::Option::Some(::std::option::Option::Some(_)) => #ident::#variant,
::std::option::Option::Some(::std::option::Option::Some(slots)) => {
slots.note(stringify!(#variant));
#ident::#variant
}
::std::option::Option::Some(::std::option::Option::None) => {
return ::std::result::Result::Err(::serde::de::Error::custom(
format!(#nil_slots_message, #key),

View file

@ -1,6 +1,8 @@
use anyhow::Result;
use anyhow::anyhow;
use cultcache_rs::CultCache;
use cultcache_rs::CultSlots;
use cultcache_rs::CultUnion;
use cultcache_rs::DatabaseEntry;
use cultcache_rs::SingleFileMessagePackBackingStore;
use serde_json::json;
@ -39,6 +41,100 @@ struct CultCacheInteropDeck {
marks: Vec<CultCacheInteropMark>,
}
// A union field and nested bytes: the shapes a Bifrost-style store holds beyond plain strings.
#[derive(Clone, Debug, PartialEq, Eq, CultUnion)]
enum InteropArm {
#[cultcache(key = 0)]
Text(InteropTextArm),
#[cultcache(key = 1)]
Pack(InteropPackArm),
}
#[derive(Clone, Debug, PartialEq, Eq, CultSlots)]
struct InteropTextArm {
#[cultcache(key = 0)]
text: String,
}
#[derive(Clone, Debug, PartialEq, Eq, CultSlots)]
struct InteropPackArm {
#[cultcache(key = 0)]
label: String,
#[cultcache(key = 1)]
chunks: Vec<serde_bytes::ByteBuf>,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "cultcache.interop-shape", schema = "CultCacheInteropShape")]
struct CultCacheInteropShape {
#[cultcache(key = 0)]
document_id: String,
#[cultcache(key = 1)]
arm: InteropArm,
#[cultcache(key = 2)]
raw: serde_bytes::ByteBuf,
}
fn shapes() -> Vec<CultCacheInteropShape> {
vec![
CultCacheInteropShape {
document_id: "shape:text".into(),
arm: InteropArm::Text(InteropTextArm { text: "union arm 0".into() }),
raw: (0..=255u8).collect::<Vec<_>>().into(),
},
CultCacheInteropShape {
document_id: "shape:pack".into(),
arm: InteropArm::Pack(InteropPackArm {
label: "union arm 1".into(),
chunks: vec![vec![0xc0, 0xc1, 0x00].into(), vec![].into(), vec![0x92, 0x01, 0x02].into()],
}),
raw: vec![0xde, 0xad, 0xbe, 0xef].into(),
},
]
}
fn write_shapes(file: &str) -> Result<()> {
let mut cache = CultCache::new();
cache.register_entry_type::<CultCacheInteropShape>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?;
cache.pull_all_backing_stores()?;
for shape in shapes() {
cache.put(&shape.document_id, &shape)?;
}
print_shapes(shapes())
}
/// Opens the store through the read-only snapshot door: no lock file, no staging file, no rewrite.
fn read_shapes(file: &str) -> Result<()> {
let envelopes = SingleFileMessagePackBackingStore::new(file).pull_all_read_only_snapshot()?;
let mut found = Vec::new();
for envelope in envelopes.iter().filter(|envelope| envelope.r#type == CultCacheInteropShape::TYPE) {
found.push(cultcache_rs::decode_payload::<CultCacheInteropShape>(&envelope.payload)?);
}
print_shapes(found)
}
fn print_shapes(mut found: Vec<CultCacheInteropShape>) -> Result<()> {
let hex = |bytes: &[u8]| bytes.iter().map(|byte| format!("{byte:02X}")).collect::<String>();
found.sort_by(|a, b| a.document_id.cmp(&b.document_id));
let rows: Vec<_> = found
.iter()
.map(|shape| {
let arm = match &shape.arm {
InteropArm::Text(text) => json!({ "kind": "text", "text": text.text }),
InteropArm::Pack(pack) => json!({
"kind": "pack",
"label": pack.label,
"chunks": pack.chunks.iter().map(|chunk| hex(chunk)).collect::<Vec<_>>(),
}),
};
json!({ "documentId": shape.document_id, "arm": arm, "raw": hex(&shape.raw) })
})
.collect();
println!("{}", serde_json::to_string(&rows)?);
Ok(())
}
fn read_deck(file: &str) -> Result<()> {
let mut cache = CultCache::new();
cache.register_entry_type::<CultCacheInteropDeck>()?;
@ -70,6 +166,8 @@ fn main() -> Result<()> {
"write" => write_note(file, require_arg(&options, "runtime-id")?)?,
"read" => read_note(file)?,
"read-deck" => read_deck(file)?,
"write-shapes" => write_shapes(file)?,
"read-shapes" => read_shapes(file)?,
"hold-lock" => hold_lock(file, require_arg(&options, "ms")?.parse()?)?,
_ => return Err(anyhow!("unknown mode {mode}")),
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,235 @@
//! The union-parity vectors' types and constructed value, shared by the test files that walk them.
#![allow(dead_code)]
use std::path::PathBuf;
use cultcache_rs::CultSlots;
use cultcache_rs::CultUnion;
use cultcache_rs::DatabaseEntry;
#[derive(Clone, Debug, PartialEq, CultUnion)]
pub enum Node {
#[cultcache(key = 0)]
Box(BoxArm),
#[cultcache(key = 1)]
Text(TextArm),
#[cultcache(key = 2)]
Use(UseArm),
#[cultcache(key = 5)]
Image(ImageArm),
#[cultcache(key = 6)]
Wrap(WrapArm),
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct WrapArm {
#[cultcache(key = 0)]
pub inner: Option<std::boxed::Box<Node>>,
#[cultcache(key = 1)]
pub id: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct BoxArm {
#[cultcache(key = 0)]
pub id: String,
#[cultcache(key = 1)]
pub children: Vec<Node>,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct TextArm {
#[cultcache(key = 0)]
pub id: String,
#[cultcache(key = 1)]
pub content: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct UseArm {
#[cultcache(key = 0)]
pub id: String,
#[cultcache(key = 1)]
pub composition: Composition,
#[cultcache(key = 2)]
pub slots: Vec<Option<Node>>,
}
// Slot 2 is retired: a gap written nil.
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct ImageArm {
#[cultcache(key = 0)]
pub id: String,
#[cultcache(key = 1)]
pub uri: String,
#[cultcache(key = 3)]
pub alt: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct Composition {
#[cultcache(key = 0)]
pub name: String,
#[cultcache(key = 1)]
pub body: Option<std::boxed::Box<Node>>,
}
#[derive(Clone, Debug, PartialEq, CultUnion)]
pub enum Outcome {
#[cultcache(key = 0)]
Pending(PendingArm),
#[cultcache(key = 1)]
Completed(CompletedArm),
#[cultcache(key = 2)]
Denied(IdArm),
#[cultcache(key = -2147483648)]
Floor(IdArm),
#[cultcache(key = 2147483647)]
Ceiling(IdArm),
}
// The codec's unit arm (no members, written [key, []]), kept apart from the golden: a document list's arms carry ids.
#[derive(Clone, Debug, PartialEq, CultUnion)]
pub enum UnitOutcome {
#[cultcache(key = 2)]
Denied,
#[cultcache(key = -2147483648)]
Floor,
#[cultcache(key = 2147483647)]
Ceiling,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct PendingArm {
#[cultcache(key = 0)]
pub message: String,
#[cultcache(key = 1)]
pub id: String,
}
// An arm with no members of its own: just its element id.
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct IdArm {
#[cultcache(key = 0)]
pub id: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct CompletedArm {
#[cultcache(key = 0)]
pub source_version: i32,
#[cultcache(key = 1)]
pub id: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct NamedArm {
#[cultcache(key = 0)]
pub name: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct UsesArm {
#[cultcache(key = 0)]
pub name: String,
#[cultcache(key = 1)]
pub uses: i32,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct CountArm {
#[cultcache(key = 0)]
pub name: String,
#[cultcache(key = 1)]
pub count: i32,
}
#[derive(Clone, Debug, PartialEq, CultUnion)]
pub enum Item {
#[cultcache(key = 0)]
Gem(NamedArm),
#[cultcache(key = 1)]
Tool(UsesArm),
#[cultcache(key = 2)]
Part(CountArm),
}
// The same arms as Item under its own keys: key 2 is unknown to it.
#[derive(Clone, Debug, PartialEq, CultUnion)]
pub enum CraftedItem {
#[cultcache(key = 0)]
Tool(UsesArm),
#[cultcache(key = 1)]
Part(CountArm),
}
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(type = "tests.union_surface", schema = "tests.union_surface.v1")]
pub struct UnionSurface {
#[cultcache(key = 0)]
pub id: String,
#[cultcache(key = 1)]
pub version: i32,
#[cultcache(key = 2)]
pub root: Option<Node>,
#[cultcache(key = 3)]
pub outcomes: Vec<Outcome>,
#[cultcache(key = 4)]
pub part: Option<Item>,
#[cultcache(key = 5)]
pub crafted: Option<CraftedItem>,
#[cultcache(key = 6)]
pub missing: Option<Node>,
}
pub fn text(id: &str, content: &str) -> Node {
Node::Text(TextArm { id: id.into(), content: content.into() })
}
pub fn image(id: &str, uri: &str, alt: &str) -> Node {
Node::Image(ImageArm { id: id.into(), uri: uri.into(), alt: alt.into() })
}
pub fn boxed(id: &str, children: Vec<Node>) -> Node {
Node::Box(BoxArm { id: id.into(), children })
}
pub fn constructed() -> UnionSurface {
let inner = boxed("0000000000d0", vec![text("0000000000d1", "x"), image("0000000000d2", "uri://a", "alt")]);
UnionSurface {
id: "surface".into(),
version: 3,
root: Some(boxed(
"0000000000c0",
vec![
text("0000000000c1", "hello"),
Node::Use(UseArm {
id: "0000000000c2".into(),
composition: Composition { name: "comp".into(), body: Some(std::boxed::Box::new(inner)) },
slots: vec![Some(text("0000000000e1", "slot")), None, Some(boxed("0000000000e2", vec![]))],
}),
image("0000000000c3", "uri://b", ""),
],
)),
outcomes: vec![
Outcome::Pending(PendingArm { message: "wait".into(), id: "0000000000a1".into() }),
Outcome::Completed(CompletedArm { source_version: 7, id: "0000000000a2".into() }),
Outcome::Denied(id_arm("0000000000a3")),
],
part: Some(Item::Tool(UsesArm { name: "hammer".into(), uses: 12 })),
crafted: Some(CraftedItem::Part(CountArm { name: "gear".into(), count: 4 })),
missing: None,
}
}
pub fn id_arm(id: &str) -> IdArm {
IdArm { id: id.into() }
}
pub fn vector(name: &str) -> Vec<u8> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/vectors/union-parity")
.join(name);
std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()))
}

View file

@ -0,0 +1,283 @@
//! `tests/vectors/key-identity`, written only by the C# reference: a cache holds one record per key whatever its type, and a
//! store holding a record no registered type claims loads, lists it as foreign and keeps its bytes
//! (`variants:ruling:cache-refuses-retype`, `write-ownership-by-written-type`, `unheld-removal-refused-at-commit`).
//! Every row is walked against this cache: each put row twice, once as `put` and once as a one-entry `put_prepared_batch`.
use std::path::{Path, PathBuf};
use cultcache_rs::{
CacheBackingStore, CultCache, DatabaseEntry, SchemaConflictError, SingleFileMessagePackBackingStore,
};
use serde_json::Value;
// The ids C# writes in the store catalogs and on every record (the vector's README).
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "sha256:b20703a98f49c253898404ab50ba2c630720f0b8a5e1a41614d5650be22799fe", schema = "tests.ki_alpha")]
struct Alpha {
#[cultcache(key = 0)]
text: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "sha256:dee4b727cf2ee04e61ab635ff0c5f3dca7345a411dc5f1018e583ab16009cf94", schema = "tests.ki_beta")]
struct Beta {
#[cultcache(key = 0)]
text: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "sha256:fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d", schema = "tests.ki_gamma")]
struct Gamma {
#[cultcache(key = 0)]
text: String,
}
fn vectors() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/key-identity")
}
/// `[refusal key or nil, held [type, key, text], file [key, schema name] or nil, foreign keys, kept foreign keys]`.
type Expect = (Option<Vec<String>>, Vec<(String, String, String)>, Option<Vec<(String, String)>>, Vec<String>, Vec<String>);
type Row = (String, Vec<String>, Vec<Vec<Value>>, Expect);
fn text(value: &Value) -> &str {
value.as_str().unwrap()
}
fn register(cache: &mut CultCache, names: &[String]) {
for name in names {
match name.as_str() {
"alpha" => drop(cache.register_entry_type::<Alpha>().unwrap()),
"beta" => drop(cache.register_entry_type::<Beta>().unwrap()),
"gamma" => drop(cache.register_entry_type::<Gamma>().unwrap()),
other => panic!("unknown type {other}"),
}
}
}
fn short(entry_type: &str) -> &'static str {
match entry_type {
t if t == Alpha::TYPE => "alpha",
t if t == Beta::TYPE => "beta",
t if t == Gamma::TYPE => "gamma",
other => panic!("unknown entry type {other}"),
}
}
fn put(cache: &mut CultCache, as_batch: bool, kind: &str, key: &str, body: &str) -> anyhow::Result<()> {
macro_rules! write {
($t:ident) => {{
let value = $t { text: body.to_string() };
if as_batch {
let (entry, _) = cache.prepare_entry(key, &value)?;
cache.put_prepared_batch(vec![entry])
} else {
cache.put(key, &value).map(drop)
}
}};
}
match kind {
"alpha" => write!(Alpha),
"beta" => write!(Beta),
"gamma" => write!(Gamma),
other => panic!("unknown type {other}"),
}
}
fn delete(cache: &mut CultCache, kind: &str, key: &str) -> anyhow::Result<bool> {
match kind {
"alpha" => cache.delete::<Alpha>(key),
"beta" => cache.delete::<Beta>(key),
"gamma" => cache.delete::<Gamma>(key),
other => panic!("unknown type {other}"),
}
}
fn open_cache(registered: &[String], path: Option<&Path>) -> anyhow::Result<CultCache> {
let mut cache = CultCache::new();
register(&mut cache, registered);
if let Some(path) = path {
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?;
cache.pull_all_backing_stores()?;
}
Ok(cache)
}
/// The records a store file holds, read by a fresh store: (key, schema name, schema id, stored-at, payload).
fn file_records(path: &Path) -> Vec<(String, String, Option<String>, String, Vec<u8>)> {
let mut records: Vec<_> = SingleFileMessagePackBackingStore::new(path)
.pull_all()
.unwrap()
.into_iter()
.map(|entry| (entry.key, entry.r#type, entry.schema_id, entry.stored_at, entry.payload))
.collect();
records.sort_by(|left, right| left.0.encode_utf16().cmp(right.0.encode_utf16()));
records
}
fn walk(row: &Row, as_batch: bool) -> Result<(), String> {
let (label, registered, ops, expect) = row;
let dir = tempfile::tempdir().unwrap();
let mut path: Option<PathBuf> = None;
let mut before = Vec::new();
let mut cache: Option<CultCache> = None;
let mut refused: Option<anyhow::Error> = None;
let mut refused_key: Option<String> = None;
let mut shared_key: Option<String> = None;
let mut wrote = false;
for (index, op) in ops.iter().enumerate() {
let last = index == ops.len() - 1;
let result: anyhow::Result<()> = match text(&op[0]) {
"open" => {
drop(cache.take());
match &op[1] {
Value::String(file) if file == "memory" => open_cache(registered, None).map(|opened| cache = Some(opened)),
file => {
let fresh = dir.path().join("store.cc");
if let Value::String(file) = file {
std::fs::copy(vectors().join(file), &fresh).unwrap();
}
before = if fresh.exists() { file_records(&fresh) } else { Vec::new() };
let opened = open_cache(registered, Some(&fresh));
path = Some(fresh);
opened.map(|opened| cache = Some(opened))
}
}
}
"reopen" => {
drop(cache.take());
open_cache(registered, path.as_deref()).map(|opened| cache = Some(opened))
}
"attach" => {
let file = text(&op[1]);
let attached = dir.path().join(file);
std::fs::copy(vectors().join(file), &attached).unwrap();
let held: Vec<String> = cache.as_ref().unwrap().snapshot().into_iter().map(|entry| entry.key).collect();
let shared: Vec<String> = file_records(&attached).into_iter().map(|record| record.0).filter(|key| held.contains(key)).collect();
let home: Vec<String> = op[2].as_array().unwrap().iter().map(|name| short_to_type(text(name)).to_string()).collect();
let cache = cache.as_mut().unwrap();
let attach = cache
.add_backing_store(SingleFileMessagePackBackingStore::new(&attached), home)
.and_then(|()| cache.pull_all_backing_stores());
if attach.is_err() && last && shared.len() == 1 {
refused_key = Some(shared[0].clone());
shared_key = refused_key.clone();
assert_eq!(std::fs::read(&attached).unwrap(), std::fs::read(vectors().join(file)).unwrap(), "{label}: a refused attach leaves the store as it was");
}
attach
}
"put" => {
wrote = true;
put(cache.as_mut().unwrap(), as_batch, text(&op[1]), text(&op[2]), text(&op[3]))
}
"delete" => delete(cache.as_mut().unwrap(), text(&op[1]), text(&op[2])).map(drop),
other => panic!("{label}: unknown op {other}"),
};
match result {
Ok(()) => {}
Err(error) if last => refused = Some(error),
Err(error) => return Err(format!("{label}: op {index} failed: {error:#}")),
}
}
if !wrote && as_batch {
return Ok(()); // not a write row: the batch variant is the same walk
}
let Some(cache) = cache.as_ref() else {
return Err(format!("{label} (batch {as_batch}): the cache did not open: {:#}", refused.unwrap()));
};
let last = ops.last().unwrap();
if let Some(error) = &refused {
if let Some(conflict) = error.downcast_ref::<SchemaConflictError>() {
refused_key = Some(conflict.record_key.clone());
let named = if text(&last[0]) == "attach" { shared_key.clone().unwrap() } else { text(&last[2]).to_string() };
assert_eq!(named, conflict.record_key, "{label}: the refusal names the key written, removed or shared");
}
let key = refused_key.clone().unwrap_or_default();
let message = format!("{error:#}");
let (all, delimited) = occurrences(&message, &key);
assert!(!key.is_empty() && delimited > 0, "{label}: the refusal names the key as a token: {message}");
if text(&last[0]) == "attach" {
assert_eq!(all, delimited, "{label}: the key occurs in the refusal only as a delimited token: {message}");
}
}
let mut held: Vec<(String, String, String)> = cache
.snapshot()
.into_iter()
.map(|entry| {
let body = match short(&entry.r#type) {
"alpha" => cache.get::<Alpha>(&entry.key).unwrap().unwrap().text,
"beta" => cache.get::<Beta>(&entry.key).unwrap().unwrap().text,
_ => cache.get::<Gamma>(&entry.key).unwrap().unwrap().text,
};
(short(&entry.r#type).to_string(), entry.key, body)
})
.collect();
held.sort_by(|a, b| (a.0.as_str(), a.1.encode_utf16().collect::<Vec<_>>()).cmp(&(b.0.as_str(), b.1.encode_utf16().collect::<Vec<_>>())));
let foreign: Vec<String> = cache.foreign_records().into_iter().map(|record| record.key).collect();
let (file, kept) = match &path {
None => (None, Vec::new()),
Some(path) => {
let after = file_records(path);
let kept = foreign
.iter()
.filter(|key| {
let original = before.iter().find(|record| &record.0 == *key);
let now = after.iter().find(|record| &record.0 == *key);
original.is_some() && original.map(|r| (&r.2, &r.3, &r.4)) == now.map(|r| (&r.2, &r.3, &r.4))
})
.cloned()
.collect();
// This writer names a catalog entry by the record's type, which for these types is the id; C# names it by the
// schema name. Which type the record at a key is, is the verdict, so the id reads as its schema name.
let name = |described: String| match described.as_str() {
t if t == Alpha::TYPE => Alpha::SCHEMA_NAME.to_string(),
t if t == Beta::TYPE => Beta::SCHEMA_NAME.to_string(),
t if t == Gamma::TYPE => Gamma::SCHEMA_NAME.to_string(),
_ => described,
};
(Some(after.into_iter().map(|record| (record.0, name(record.1))).collect::<Vec<_>>()), kept)
}
};
let observed: Expect = (refused_key.map(|key| vec![key]), held, file, foreign, kept);
if &observed == expect { Ok(()) } else { Err(format!("{label} (batch {as_batch}): committed {expect:?} but this cache answers {observed:?}")) }
}
/// How often `key` occurs in `message`, and how often as a delimited token (not a piece of a longer word, schema name or id).
fn occurrences(message: &str, key: &str) -> (usize, usize) {
let word = |c: Option<char>| c.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
let found: Vec<usize> = message.match_indices(key).map(|(at, _)| at).collect();
let delimited = found
.iter()
.filter(|at| !word(message[..**at].chars().next_back()) && !word(message[**at + key.len()..].chars().next()))
.count();
(found.len(), delimited)
}
fn short_to_type(name: &str) -> &'static str {
match name {
"alpha" => Alpha::TYPE,
"beta" => Beta::TYPE,
"gamma" => Gamma::TYPE,
other => panic!("unknown type {other}"),
}
}
#[test]
fn key_identity_rows_hold_their_verdicts() {
let bytes = std::fs::read(vectors().join("rows.msgpack")).unwrap();
let rows: Vec<Row> = rmp_serde::from_slice(&bytes).unwrap();
let mut walked = 0;
let mut failed = Vec::new();
for row in &rows {
let mut ok = true;
for as_batch in [false, true] {
if let Err(message) = walk(row, as_batch) {
failed.push(message);
ok = false;
}
}
walked += usize::from(ok);
}
assert!(failed.is_empty(), "{}", failed.join("\n"));
assert_eq!(walked, 12, "rows walked");
}

View file

@ -0,0 +1,541 @@
//! A record whose payload carries unread slots at any depth is read-only: the C# reference's union-parity vectors
//! (README "Extra trailing slot" and "Slot stores") walked through the cache. This runtime has no compatible schema
//! ids, so every mark is unconsented and no `explained` outcome can arise.
mod common;
use std::path::Path;
use common::*;
use cultcache_rs::CultCache;
use cultcache_rs::CultCacheEnvelope;
use cultcache_rs::DatabaseEntry;
use cultcache_rs::SchemaConflictError;
use cultcache_rs::SingleFileMessagePackBackingStore;
use cultcache_rs::UnreadSlots;
use cultcache_rs::decode_payload_marked;
/// A row of `slot-outcomes.msgpack`: store, key, writable, explained, type, slots, declared, occurrences, flattenRefusedBy.
type SlotRow = (String, String, bool, Option<bool>, Option<String>, Option<usize>, Option<usize>, Option<usize>, Option<String>);
const SLOT_VALUE: &str = "extra-slot";
/// `tests.slot_unit`, a document `[Name, Outcome: UnitOutcome]`.
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(type = "tests.slot_unit", schema = "tests.slot_unit.v1")]
struct SlotUnit {
#[cultcache(key = 0)]
name: String,
#[cultcache(key = 1)]
outcome: UnitOutcome,
}
/// Two declared slots, the second defaulted: a payload of one slot is short, not marked.
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(type = "tests.shortened", schema = "tests.shortened.v1")]
struct Shortened {
#[cultcache(key = 0)]
id: String,
#[cultcache(key = 1, default)]
note: String,
}
fn registered() -> CultCache {
let mut cache = CultCache::new();
cache.register_entry_type::<UnionSurface>().unwrap();
cache.register_entry_type::<SlotUnit>().unwrap();
cache
}
/// Any MessagePack value a store holds, enough to drop one record from a store and write the rest back.
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
enum Packed {
Nil(()),
Bool(bool),
Int(i64),
Text(String),
Bin(#[serde(with = "serde_bytes")] Vec<u8>),
Array(Vec<Packed>),
}
/// The committed store without its `marked-variant` record. A variant is a five-slot record, which this runtime's
/// store reader refuses whole (`the_variant_row_...`), so the other rows are walked from a copy without it.
fn without_variant_row(store: &[u8]) -> Vec<u8> {
let Packed::Array(mut parts) = rmp_serde::from_slice(store).unwrap() else { panic!("a store is an array") };
let Packed::Array(records) = parts.last_mut().unwrap() else { panic!("a store ends in its records") };
let before = records.len();
records.retain(|record| !matches!(record, Packed::Array(fields) if matches!(fields.first(), Some(Packed::Text(key)) if key == "marked-variant")));
assert_eq!(records.len(), before - 1);
rmp_serde::to_vec(&parts).unwrap()
}
/// A copy of a committed store, opened with both types registered; the vector itself is never written. The slot store
/// is read without its variant row.
fn open_copy(file: &str, dir: &Path) -> (CultCache, std::path::PathBuf) {
let path = dir.join(file);
let bytes = if file == "extra-slot-store.cc" { without_variant_row(&vector(file)) } else { vector(file) };
std::fs::write(&path, bytes).unwrap();
let mut cache = registered();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
cache.pull_all_backing_stores().unwrap();
(cache, path)
}
fn envelope(key: &str, payload: Vec<u8>) -> CultCacheEnvelope {
CultCacheEnvelope {
key: key.into(),
r#type: UnionSurface::TYPE.into(),
payload,
stored_at: "2026-01-01T00:00:00Z".into(),
schema_id: None,
}
}
fn conflict(error: &anyhow::Error) -> &SchemaConflictError {
error.downcast_ref::<SchemaConflictError>().unwrap_or_else(|| panic!("not a schema conflict: {error:#}"))
}
fn mark(type_name: &str, slot_count: usize, declared_count: usize, occurrences: usize) -> UnreadSlots {
UnreadSlots { type_name: type_name.into(), slot_count, declared_count, occurrences }
}
/// The marks the README table pins, by `detail`. The reference names an arm by its class; here an arm is the
/// `...Arm` struct the union wraps.
fn expected_mark(detail: &str) -> UnreadSlots {
match detail {
"record" | "depth" => mark("UnionSurface", 8, 7, 1),
"nested-record" => mark("Composition", 3, 2, 1),
"arm" => mark("BoxArm", 3, 2, 1),
"list-element" => mark("TextArm", 3, 2, 1),
other => panic!("unknown detail {other}"),
}
}
fn assert_refused_with(error: &anyhow::Error, key: &str, expected: &UnreadSlots) {
let refusal = conflict(error);
assert_eq!(refusal.unread_slots.as_ref(), Some(expected));
assert_eq!(refusal.record_key, key);
assert_eq!(refusal.schema_names.len(), 1);
let message = error.to_string();
assert!(message.contains(&expected.type_name), "{message}");
assert!(message.contains(&expected.slot_count.to_string()) && message.contains(&expected.declared_count.to_string()), "{message}");
assert!(!message.contains(SLOT_VALUE), "a refusal echoed the slot value: {message}");
}
#[test]
fn every_read_only_vector_decodes_marked_and_is_not_writable() {
let rows: Vec<(String, String, String, Option<i64>, String)> = rmp_serde::from_slice(&vector("outcomes.msgpack")).unwrap();
assert_eq!(rows.len(), 9);
let bounds: Vec<serde_json::Value> = rmp_serde::from_slice(&vector("bounds-outcomes.msgpack")).unwrap();
let mut cases: Vec<(String, String)> = rows
.into_iter()
.filter(|(_, outcome, ..)| outcome == "read-only")
.map(|(file, _, _, _, detail)| (file, detail))
.collect();
for entry in &bounds[1..] {
let fields: Vec<&str> = entry.as_array().unwrap().iter().filter_map(|field| field.as_str()).collect();
if fields[1] == "read-only" {
cases.push((fields[0].to_string(), fields[3].to_string()));
}
}
assert_eq!(cases.len(), 5);
for (file, detail) in cases {
let payload = vector(&file);
let (value, found) = decode_payload_marked::<UnionSurface>(&payload).unwrap_or_else(|error| panic!("{file}: {error:#}"));
assert_eq!(value, constructed(), "{file}");
assert_eq!(found, Some(expected_mark(&detail)), "{file}");
let mut cache = registered();
let dir = tempfile::tempdir().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("s.cc"))).unwrap();
cache.pull_all_backing_stores().unwrap();
cache.put_raw_envelope(envelope("k", payload.clone())).unwrap();
assert_eq!(cache.get::<UnionSurface>("k").unwrap(), Some(constructed()), "{file}");
assert!(!cache.is_writable(UnionSurface::TYPE, "k").unwrap(), "{file}");
assert!(cache.is_writable(UnionSurface::TYPE, "unheld").unwrap(), "{file}");
let error = cache.put("k", &constructed()).unwrap_err();
assert_refused_with(&error, "k", &expected_mark(&detail));
}
}
#[test]
fn clean_and_short_payloads_are_writable_and_unmarked() {
let (value, found) = decode_payload_marked::<UnionSurface>(&vector("union-surface.msgpack")).unwrap();
assert_eq!((value, found), (constructed(), None));
// A payload short of its declared slots keeps its defaults and does not mark.
let (value, found) = decode_payload_marked::<Shortened>(&rmp_serde::to_vec(&("only",)).unwrap()).unwrap();
assert_eq!((value.id.as_str(), value.note.as_str(), found), ("only", "", None));
}
#[test]
fn a_payload_past_the_nesting_limit_is_refused_never_marked() {
for file in ["depth-skipped-past-limit.msgpack", "depth-past-limit.msgpack"] {
let error = decode_payload_marked::<UnionSurface>(&vector(file)).unwrap_err();
assert!(error.to_string().contains("the limit is 256"), "{file}: {error:#}");
}
}
#[test]
fn a_plain_serde_decode_marks_nothing_and_does_not_leak_a_scope() {
// No scope is open: the visitors note into nothing.
let value: UnionSurface = rmp_serde::from_slice(&vector("extra-slot-record.msgpack")).unwrap();
assert_eq!(value, constructed());
// A marked decode followed by a clean one: the first mark does not survive into the second.
assert!(decode_payload_marked::<UnionSurface>(&vector("extra-slot-record.msgpack")).unwrap().1.is_some());
assert_eq!(decode_payload_marked::<UnionSurface>(&vector("union-surface.msgpack")).unwrap().1, None);
}
#[test]
fn the_slot_store_rows_walk_through_the_cache() {
let rows: Vec<SlotRow> =
rmp_serde::from_slice(&vector("slot-outcomes.msgpack")).unwrap();
assert_eq!(rows.len(), 8);
let dir = tempfile::tempdir().unwrap();
let mut walked = 0;
for (store, key, writable, _explained, type_name, slots, declared, occurrences, flatten_refused_by) in rows {
// The variant row is a record this runtime cannot hold: see the variant test. The consent store's `writable` and
// `explained` answer the compatible-id declaration, which this runtime has none of; its marks are walked below.
if flatten_refused_by.is_some() || store == "extra-slot-consent.cc" {
continue;
}
let case = dir.path().join(&key);
std::fs::create_dir_all(&case).unwrap();
let (mut cache, _) = open_copy(&store, &case);
let type_id = if key == "unit-arm" { SlotUnit::TYPE } else { UnionSurface::TYPE };
assert_eq!(cache.is_writable(type_id, &key).unwrap(), writable, "{store} {key}");
let removal = if key == "unit-arm" { cache.delete::<SlotUnit>(&key) } else { cache.delete::<UnionSurface>(&key) };
match writable {
true => assert!(removal.unwrap()),
false => {
let found = conflict(&removal.unwrap_err()).unread_slots.clone().unwrap();
// The reference names the unit arm's class, Denied2; here the variant.
let expected_type = match type_name.as_deref().unwrap() {
"Denied2" => "Denied",
other => other,
};
assert_eq!((found.type_name.as_str(), Some(found.slot_count), Some(found.declared_count), Some(found.occurrences)), (expected_type, slots, declared, occurrences));
assert!(cache.snapshot_envelopes().iter().any(|held| held.key == key), "a refused removal removed {key}");
}
}
walked += 1;
}
assert_eq!(walked, 3);
}
#[test]
fn the_consent_store_marks_are_unconsented_here() {
let rows: Vec<SlotRow> =
rmp_serde::from_slice(&vector("slot-outcomes.msgpack")).unwrap();
let dir = tempfile::tempdir().unwrap();
let mut found = 0;
for (store, key, _writable, _explained, _type_name, slots, declared, occurrences, _) in rows {
if store != "extra-slot-consent.cc" {
continue;
}
let (cache, _) = open_copy(&store, dir.path());
let held = cache.snapshot_envelopes().into_iter().find(|held| held.key == key).unwrap();
let (_, mark) = decode_payload_marked::<UnionSurface>(&held.payload).unwrap();
match slots {
// The reference's `consented` row is writable there (a listed compatible id covers it); here every mark refuses.
None => assert_eq!(mark, None, "{key}"),
Some(slots) => {
let mark = mark.unwrap_or_else(|| panic!("{key} carried no mark"));
// The first type read is the document, whose reference name is SurfaceDeclaring.
assert_eq!(mark.type_name, "UnionSurface", "{key}");
assert_eq!((Some(mark.slot_count), Some(mark.declared_count), Some(mark.occurrences)), (Some(slots), declared, occurrences), "{key}");
assert!(!cache.is_writable(UnionSurface::TYPE, &key).unwrap(), "{key}");
}
}
found += 1;
}
assert_eq!(found, 4);
}
#[test]
fn every_write_entry_refuses_a_marked_key_before_any_store_is_touched() {
let dir = tempfile::tempdir().unwrap();
let (mut cache, path) = open_copy("extra-slot-store.cc", dir.path());
let expected = mark("Composition", 3, 2, 1);
let before = std::fs::read(&path).unwrap();
let held = cache.snapshot_envelopes().into_iter().find(|held| held.key == "marked").unwrap();
let clean = cache.snapshot_envelopes().into_iter().find(|held| held.key == "clean").unwrap();
assert!(!cache.is_writable(UnionSurface::TYPE, "marked").unwrap());
assert!(cache.is_writable(UnionSurface::TYPE, "clean").unwrap());
let replacing = envelope("marked", rmp_serde::to_vec(&constructed()).unwrap());
assert_refused_with(&cache.put("marked", &constructed()).unwrap_err(), "marked", &expected);
assert_refused_with(&cache.put_envelope::<UnionSurface>(replacing.clone()).unwrap_err(), "marked", &expected);
assert_refused_with(&cache.put_raw_envelope(replacing.clone()).unwrap_err(), "marked", &expected);
assert_refused_with(&cache.put_prepared_batch(vec![replacing]).unwrap_err(), "marked", &expected);
assert_refused_with(&cache.delete::<UnionSurface>("marked").unwrap_err(), "marked", &expected);
assert_refused_with(
&cache.update::<UnionSurface, _>("marked", |current| current.unwrap()).unwrap_err(),
"marked",
&expected,
);
// A batch with a marked key refuses whole: its clean member is not written either.
let mut rewritten = clean.clone();
rewritten.payload = rmp_serde::to_vec(&UnionSurface { version: 9, ..constructed() }).unwrap();
let batch = vec![rewritten, envelope("marked", rmp_serde::to_vec(&constructed()).unwrap())];
assert_refused_with(&cache.put_prepared_batch(batch).unwrap_err(), "marked", &expected);
assert_eq!(std::fs::read(&path).unwrap(), before, "a refused write touched the store");
assert_eq!(cache.snapshot_envelopes().into_iter().find(|entry| entry.key == "marked").unwrap(), held);
assert_eq!(cache.snapshot_envelopes().into_iter().find(|entry| entry.key == "clean").unwrap(), clean);
// A write to another key succeeds, and the marked record's bytes, schema id and stored-at are carried unchanged.
cache.put("clean", &UnionSurface { version: 9, ..constructed() }).unwrap();
cache.put("other", &constructed()).unwrap();
assert!(cache.delete::<UnionSurface>("other").unwrap());
let mut reopened = registered();
reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
reopened.pull_all_backing_stores().unwrap();
let carried = reopened.snapshot_envelopes().into_iter().find(|entry| entry.key == "marked").unwrap();
assert_eq!(carried, held);
assert_eq!(carried.payload, vector("extra-slot-nested-record.msgpack"));
assert_eq!(reopened.get::<UnionSurface>("clean").unwrap().unwrap().version, 9);
}
#[test]
fn a_unit_arm_with_slots_decodes_as_the_unit_arm_and_marks() {
let (document, found) = decode_payload_marked::<SlotUnit>(&unit_arm_payload()).unwrap();
assert_eq!(document, SlotUnit { name: "unit-arm".into(), outcome: UnitOutcome::Denied });
assert_eq!(found, Some(mark("Denied", 1, 0, 1)));
let dir = tempfile::tempdir().unwrap();
let (mut cache, _) = open_copy("extra-slot-store.cc", dir.path());
assert_eq!(cache.get::<SlotUnit>("unit-arm").unwrap(), Some(document.clone()));
assert!(!cache.is_writable(SlotUnit::TYPE, "unit-arm").unwrap());
let error = cache.put("unit-arm", &document).unwrap_err();
assert_refused_with(&error, "unit-arm", &mark("Denied", 1, 0, 1));
assert_refused_with(&cache.delete::<SlotUnit>("unit-arm").unwrap_err(), "unit-arm", &mark("Denied", 1, 0, 1));
// Several elements are still one marked object, and a clean unit arm is unmarked.
let (_, found) = decode_payload_marked::<UnitOutcome>(&rmp_serde::to_vec(&(2u64, ("a", "b", "c"))).unwrap()).unwrap();
assert_eq!(found, Some(mark("Denied", 3, 0, 1)));
let (_, found) = decode_payload_marked::<UnitOutcome>(&vector("unit-arm-denied.msgpack")).unwrap();
assert_eq!(found, None);
}
fn unit_arm_payload() -> Vec<u8> {
let dir = tempfile::tempdir().unwrap();
let (cache, _) = open_copy("extra-slot-store.cc", dir.path());
cache.snapshot_envelopes().into_iter().find(|held| held.key == "unit-arm").unwrap().payload
}
#[test]
fn the_variant_row_of_the_slot_store_is_refused_whole_by_the_store_reader() {
// `marked-variant` is a five-slot record (a variant): this runtime holds no variants, and its v1 snapshot reader
// refuses such a record, so the store as written cannot load here and the other rows are walked without it.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("extra-slot-store.cc");
std::fs::write(&path, vector("extra-slot-store.cc")).unwrap();
let mut cache = registered();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
let error = cache.pull_all_backing_stores().unwrap_err();
assert!(format!("{error:#}").contains("is not decodable"), "{error:#}");
}
fn slot_unit_envelope(key: &str, payload: Vec<u8>) -> CultCacheEnvelope {
CultCacheEnvelope { r#type: SlotUnit::TYPE.into(), ..envelope(key, payload) }
}
#[test]
fn a_held_payload_the_type_cannot_read_is_read_only_and_refused_by_name() {
let mut cache = registered();
// A newer writer's record: an arm this build lacks and an extra root slot. get cannot read it, and it is the data
// read-only exists to protect.
let newer = rmp_serde::to_vec(&("CANARY-name", (99u64, ("x",)), "CANARY-extra")).unwrap();
cache.put_raw_envelope(slot_unit_envelope("n", newer)).unwrap();
assert!(cache.get::<SlotUnit>("n").is_err());
assert!(!cache.is_writable(SlotUnit::TYPE, "n").unwrap());
let replacement = SlotUnit { name: "n".into(), outcome: UnitOutcome::Denied };
let held = cache.snapshot_envelopes();
for error in [
cache.put("n", &replacement).unwrap_err(),
cache.put_raw_envelope(slot_unit_envelope("n", rmp_serde::to_vec(&replacement).unwrap())).unwrap_err(),
cache.delete::<SlotUnit>("n").unwrap_err(),
] {
let message = format!("{error:#}");
assert!(message.contains("\"n\"") && message.contains("tests.slot_unit") && message.contains("read-only"), "{message}");
assert!(!message.contains("CANARY"), "{message}");
}
assert_eq!(cache.snapshot_envelopes(), held);
}
#[test]
fn a_corrupt_held_payload_is_refused_naming_the_key_never_its_bytes() {
let mut cache = registered();
cache.put_raw_envelope(envelope("broken", rmp_serde::to_vec(&("CANARY-id", "CANARY-version")).unwrap())).unwrap();
assert!(!cache.is_writable(UnionSurface::TYPE, "broken").unwrap());
let error = cache.delete::<UnionSurface>("broken").unwrap_err();
let message = format!("{error:#}");
assert!(message.contains("\"broken\"") && message.contains("backup") && message.contains("store"), "{message}");
assert!(!message.contains("build that wrote it"), "{message}");
assert!(!message.contains("CANARY"), "{message}");
assert!(cache.is_writable("tests.unregistered", "broken").is_err());
}
fn held_marked_and_clean() -> (CultCache, tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let (cache, path) = open_copy("extra-slot-store.cc", dir.path());
(cache, dir, path)
}
#[test]
fn load_soa_does_not_replace_or_drop_a_marked_record() {
use cultcache_rs::CultCacheSoaTable;
let (mut cache, _dir, path) = held_marked_and_clean();
let all = cache.snapshot_envelopes();
let held = all.iter().find(|held| held.key == "marked").unwrap().clone();
let without: Vec<_> = all.iter().filter(|held| held.key != "marked").cloned().collect();
let mut replaced = all.clone();
replaced.iter_mut().find(|held| held.key == "marked").unwrap().payload = rmp_serde::to_vec(&constructed()).unwrap();
for table in [without, replaced] {
let error = cache.load_soa(CultCacheSoaTable::from_envelopes(table)).unwrap_err();
assert_refused_with(&error, "marked", &mark("Composition", 3, 2, 1));
assert_eq!(cache.snapshot_envelopes(), all, "a refused load changed the image");
}
// The same record carried unchanged, with another key added, loads.
let mut kept = all.clone();
kept.push(envelope("added", rmp_serde::to_vec(&constructed()).unwrap()));
cache.load_soa(CultCacheSoaTable::from_envelopes(kept)).unwrap();
assert_eq!(cache.snapshot_envelopes().iter().find(|held| held.key == "marked"), Some(&held));
// A clean record may be dropped by a load.
let no_clean: Vec<_> = cache.snapshot_envelopes().into_iter().filter(|held| held.key != "clean").collect();
cache.load_soa(CultCacheSoaTable::from_envelopes(no_clean)).unwrap();
// The marked record is still on disk after the next batch.
let rewrite = envelope("added", rmp_serde::to_vec(&UnionSurface { version: 5, ..constructed() }).unwrap());
cache.put_prepared_batch(vec![rewrite]).unwrap();
let mut reopened = registered();
reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
reopened.pull_all_backing_stores().unwrap();
assert_eq!(reopened.snapshot_envelopes().iter().find(|held| held.key == "marked"), Some(&held));
}
#[test]
fn is_writable_agrees_with_the_writes_for_foreign_keys_and_other_types() {
let dir = tempfile::tempdir().unwrap();
// Only UnionSurface registered: the store's unit-arm record is foreign here.
let path = dir.path().join("extra-slot-store.cc");
std::fs::write(&path, without_variant_row(&vector("extra-slot-store.cc"))).unwrap();
let mut cache = CultCache::new();
cache.register_entry_type::<UnionSurface>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
cache.pull_all_backing_stores().unwrap();
assert!(cache.put("unit-arm", &constructed()).is_err());
assert!(!cache.is_writable(UnionSurface::TYPE, "unit-arm").unwrap());
assert!(cache.is_writable(UnionSurface::TYPE, "unheld").unwrap());
// Another registered type at a key a record of this one holds.
let (mut cache, _dir, _) = held_marked_and_clean();
let other = SlotUnit { name: "clean".into(), outcome: UnitOutcome::Denied };
assert!(cache.put("clean", &other).is_err());
assert!(!cache.is_writable(SlotUnit::TYPE, "clean").unwrap());
assert!(!cache.is_writable(SlotUnit::TYPE, "marked").unwrap());
assert!(cache.is_writable(UnionSurface::TYPE, "clean").unwrap());
}
#[test]
fn a_marked_key_refuses_a_batch_wherever_it_sits() {
let (mut cache, _dir, path) = held_marked_and_clean();
let before = std::fs::read(&path).unwrap();
let marked = envelope("marked", rmp_serde::to_vec(&constructed()).unwrap());
let clean = |key: &str| envelope(key, rmp_serde::to_vec(&constructed()).unwrap());
for batch in [
vec![marked.clone(), clean("a"), clean("b")],
vec![clean("a"), marked.clone(), clean("b")],
vec![clean("a"), clean("b"), marked.clone()],
] {
let error = cache.put_prepared_batch(batch).unwrap_err();
assert_refused_with(&error, "marked", &mark("Composition", 3, 2, 1));
assert_eq!(std::fs::read(&path).unwrap(), before);
assert!(cache.snapshot_envelopes().iter().all(|held| held.key != "a" && held.key != "b"));
}
}
#[test]
fn a_marked_decode_leaves_no_scope_open() {
assert_eq!(cultcache_rs::__enter_slots(), 0);
decode_payload_marked::<UnionSurface>(&vector("extra-slot-record.msgpack")).unwrap();
assert_eq!(cultcache_rs::__enter_slots(), 0, "a marked decode left its scope open on this thread");
let _ = decode_payload_marked::<UnionSurface>(&vector("depth-skipped-past-limit.msgpack"));
assert_eq!(cultcache_rs::__enter_slots(), 0);
}
#[test]
fn is_writable_is_false_for_a_type_with_no_home_store() {
let dir = tempfile::tempdir().unwrap();
let mut cache = registered();
// One store, routed to SlotUnit only: UnionSurface has no home.
cache.add_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("s.cc")), [SlotUnit::TYPE]).unwrap();
cache.pull_all_backing_stores().unwrap();
assert!(cache.put("k", &constructed()).is_err());
assert!(!cache.is_writable(UnionSurface::TYPE, "k").unwrap());
assert!(cache.is_writable(SlotUnit::TYPE, "k").unwrap());
}
#[test]
fn load_soa_refuses_a_same_length_replacement_of_a_marked_record() {
use cultcache_rs::CultCacheSoaTable;
let (mut cache, _dir, _) = held_marked_and_clean();
let all = cache.snapshot_envelopes();
let marked = all.iter().find(|held| held.key == "marked").unwrap();
// One value (a bin16) of the marked record's length: same length, different bytes. A value followed by padding
// would be refused at admission as bytes after the value, before the read-only check is reached.
let length = marked.payload.len();
let mut same_length = vec![0u8; length];
same_length[..3].copy_from_slice(&[0xc5, ((length - 3) >> 8) as u8, (length - 3) as u8]);
assert_eq!(same_length.len(), marked.payload.len());
let mut replaced = all.clone();
replaced.iter_mut().find(|held| held.key == "marked").unwrap().payload = same_length;
let error = cache.load_soa(CultCacheSoaTable::from_envelopes(replaced)).unwrap_err();
assert_refused_with(&error, "marked", &mark("Composition", 3, 2, 1));
assert_eq!(cache.snapshot_envelopes(), all);
}
// The payload check runs before the mark scope opens: a tail on a slot-bearing payload is refused by count, never marked.
#[test]
fn a_tail_on_a_slot_payload_is_refused_before_any_mark() {
let mut tailed = vector("extra-slot-record.msgpack");
tailed.push(0xc0);
let error = decode_payload_marked::<UnionSurface>(&tailed).unwrap_err();
assert!(format!("{error:#}").contains("payload has 1 bytes after its first MessagePack value"), "{error:#}");
// Nothing leaked: the next marked decode finds exactly its own mark.
let found = decode_payload_marked::<UnionSurface>(&vector("extra-slot-record.msgpack")).unwrap().1.unwrap();
assert_eq!(found.occurrences, 1);
}
// A store holding marked records and one with a tail is refused at load; the cache then refuses every write with the load
// refusal (not a per-key mark conflict), and the store file is untouched.
#[test]
fn a_store_with_marked_records_and_a_tail_is_refused_and_its_cache_writes_nothing() {
use cultcache_rs::CacheBackingStore;
use cultcache_rs::CultCacheLoadRefused;
let (mut held, _dir, path) = held_marked_and_clean();
let mut tailed = vector("union-surface.msgpack");
tailed.push(0xc0);
SingleFileMessagePackBackingStore::new(&path).push(&envelope("tailed", tailed)).unwrap();
let before = std::fs::read(&path).unwrap();
// The cache that held the store re-reads it and is refused.
assert!(format!("{:#}", held.pull_all_backing_stores().unwrap_err()).contains("bytes after"));
let fresh_value = constructed();
let mut fresh = registered();
fresh.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
assert!(fresh.pull_all_backing_stores().is_err());
for cache in [&mut held, &mut fresh] {
let staged = cache.prepare_entry("new", &fresh_value).unwrap().0;
let errors = [
cache.put("new", &fresh_value).unwrap_err(),
cache.put_prepared_batch(vec![staged.clone()]).unwrap_err(),
cache.put_raw_envelope(staged).unwrap_err(),
cache.delete::<UnionSurface>("new").unwrap_err(),
];
for error in errors {
assert!(error.downcast_ref::<CultCacheLoadRefused>().is_some(), "{error:#}");
}
}
assert_eq!(std::fs::read(&path).unwrap(), before);
}

View file

@ -1,244 +1,21 @@
//! The Rust runtime held to tests/vectors/union-parity, written by the C# reference.
//! Bytes and outcomes are read from the committed files; only the constructed value is spelled here.
use std::path::PathBuf;
use cultcache_rs::CultCache;
use cultcache_rs::CacheBackingStore;
use cultcache_rs::CultCacheEnvelope;
use cultcache_rs::CultCacheLoadRefused;
use cultcache_rs::CultCacheSoaTable;
use cultcache_rs::MAX_PAYLOAD_DEPTH;
use cultcache_rs::PayloadTooDeep;
use cultcache_rs::check_payload_depth;
use cultcache_rs::CultSlots;
use cultcache_rs::CultUnion;
use cultcache_rs::decode_payload;
use cultcache_rs::RedbMessagePackBackingStore;
use cultcache_rs::DatabaseEntry;
use cultcache_rs::SingleFileMessagePackBackingStore;
#[derive(Clone, Debug, PartialEq, CultUnion)]
enum Node {
#[cultcache(key = 0)]
Box(BoxArm),
#[cultcache(key = 1)]
Text(TextArm),
#[cultcache(key = 2)]
Use(UseArm),
#[cultcache(key = 5)]
Image(ImageArm),
#[cultcache(key = 6)]
Wrap(WrapArm),
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct WrapArm {
#[cultcache(key = 0)]
inner: Option<std::boxed::Box<Node>>,
#[cultcache(key = 1)]
id: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct BoxArm {
#[cultcache(key = 0)]
id: String,
#[cultcache(key = 1)]
children: Vec<Node>,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct TextArm {
#[cultcache(key = 0)]
id: String,
#[cultcache(key = 1)]
content: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct UseArm {
#[cultcache(key = 0)]
id: String,
#[cultcache(key = 1)]
composition: Composition,
#[cultcache(key = 2)]
slots: Vec<Option<Node>>,
}
// Slot 2 is retired: a gap written nil.
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct ImageArm {
#[cultcache(key = 0)]
id: String,
#[cultcache(key = 1)]
uri: String,
#[cultcache(key = 3)]
alt: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct Composition {
#[cultcache(key = 0)]
name: String,
#[cultcache(key = 1)]
body: Option<std::boxed::Box<Node>>,
}
#[derive(Clone, Debug, PartialEq, CultUnion)]
enum Outcome {
#[cultcache(key = 0)]
Pending(PendingArm),
#[cultcache(key = 1)]
Completed(CompletedArm),
#[cultcache(key = 2)]
Denied(IdArm),
#[cultcache(key = -2147483648)]
Floor(IdArm),
#[cultcache(key = 2147483647)]
Ceiling(IdArm),
}
// The codec's unit arm (no members, written [key, []]), kept apart from the golden: a document list's arms carry ids.
#[derive(Clone, Debug, PartialEq, CultUnion)]
enum UnitOutcome {
#[cultcache(key = 2)]
Denied,
#[cultcache(key = -2147483648)]
Floor,
#[cultcache(key = 2147483647)]
Ceiling,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct PendingArm {
#[cultcache(key = 0)]
message: String,
#[cultcache(key = 1)]
id: String,
}
// An arm with no members of its own: just its element id.
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct IdArm {
#[cultcache(key = 0)]
id: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct CompletedArm {
#[cultcache(key = 0)]
source_version: i32,
#[cultcache(key = 1)]
id: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct NamedArm {
#[cultcache(key = 0)]
name: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct UsesArm {
#[cultcache(key = 0)]
name: String,
#[cultcache(key = 1)]
uses: i32,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
struct CountArm {
#[cultcache(key = 0)]
name: String,
#[cultcache(key = 1)]
count: i32,
}
#[derive(Clone, Debug, PartialEq, CultUnion)]
enum Item {
#[cultcache(key = 0)]
Gem(NamedArm),
#[cultcache(key = 1)]
Tool(UsesArm),
#[cultcache(key = 2)]
Part(CountArm),
}
// The same arms as Item under its own keys: key 2 is unknown to it.
#[derive(Clone, Debug, PartialEq, CultUnion)]
enum CraftedItem {
#[cultcache(key = 0)]
Tool(UsesArm),
#[cultcache(key = 1)]
Part(CountArm),
}
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(type = "tests.union_surface", schema = "tests.union_surface.v1")]
struct UnionSurface {
#[cultcache(key = 0)]
id: String,
#[cultcache(key = 1)]
version: i32,
#[cultcache(key = 2)]
root: Option<Node>,
#[cultcache(key = 3)]
outcomes: Vec<Outcome>,
#[cultcache(key = 4)]
part: Option<Item>,
#[cultcache(key = 5)]
crafted: Option<CraftedItem>,
#[cultcache(key = 6)]
missing: Option<Node>,
}
fn text(id: &str, content: &str) -> Node {
Node::Text(TextArm { id: id.into(), content: content.into() })
}
fn image(id: &str, uri: &str, alt: &str) -> Node {
Node::Image(ImageArm { id: id.into(), uri: uri.into(), alt: alt.into() })
}
fn boxed(id: &str, children: Vec<Node>) -> Node {
Node::Box(BoxArm { id: id.into(), children })
}
fn constructed() -> UnionSurface {
let inner = boxed("0000000000d0", vec![text("0000000000d1", "x"), image("0000000000d2", "uri://a", "alt")]);
UnionSurface {
id: "surface".into(),
version: 3,
root: Some(boxed(
"0000000000c0",
vec![
text("0000000000c1", "hello"),
Node::Use(UseArm {
id: "0000000000c2".into(),
composition: Composition { name: "comp".into(), body: Some(std::boxed::Box::new(inner)) },
slots: vec![Some(text("0000000000e1", "slot")), None, Some(boxed("0000000000e2", vec![]))],
}),
image("0000000000c3", "uri://b", ""),
],
)),
outcomes: vec![
Outcome::Pending(PendingArm { message: "wait".into(), id: "0000000000a1".into() }),
Outcome::Completed(CompletedArm { source_version: 7, id: "0000000000a2".into() }),
Outcome::Denied(id_arm("0000000000a3")),
],
part: Some(Item::Tool(UsesArm { name: "hammer".into(), uses: 12 })),
crafted: Some(CraftedItem::Part(CountArm { name: "gear".into(), count: 4 })),
missing: None,
}
}
fn id_arm(id: &str) -> IdArm {
IdArm { id: id.into() }
}
fn vector(name: &str) -> Vec<u8> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/vectors/union-parity")
.join(name);
std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()))
}
mod common;
use common::*;
fn decode(bytes: &[u8]) -> Result<UnionSurface, String> {
rmp_serde::from_slice(bytes).map_err(|error| error.to_string())
@ -325,11 +102,10 @@ fn an_unknown_arm_lists_the_declared_keys_and_never_echoes_the_payload() {
#[test]
fn a_union_that_is_not_a_key_and_slots_pair_is_refused_naming_the_enum() {
let cases: [(&str, Vec<u8>); 4] = [
let cases: [(&str, Vec<u8>); 3] = [
("an empty array", vec![0x90]),
("a trailing element", rmp_serde::to_vec(&(1u64, (7u8,), 0u8)).unwrap()),
("a string key", rmp_serde::to_vec(&("CANARY-key", (7u8,))).unwrap()),
("a unit arm with slots", rmp_serde::to_vec(&(2u64, ("CANARY-slot",))).unwrap()),
];
for (label, bytes) in cases {
let message = rmp_serde::from_slice::<UnitOutcome>(&bytes).unwrap_err().to_string();
@ -692,7 +468,7 @@ fn envelope(key: &str, payload: &[u8]) -> CultCacheEnvelope {
}
// A payload past the bound cannot be admitted through the cache, so a store file written behind its
// back stands in for one a peer or an older runtime left.
// back stands in for one a peer or an older runtime left; load or read refuses it.
fn through_cache(payload: &[u8]) -> Result<UnionSurface, String> {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("foreign.cc");
@ -700,7 +476,10 @@ fn through_cache(payload: &[u8]) -> Result<UnionSurface, String> {
let mut cache = CultCache::new();
cache.register_entry_type::<UnionSurface>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
cache.pull_all_backing_stores().unwrap();
// The payload check runs at load, so a payload past the bound is refused there, before any read.
if let Err(error) = cache.pull_all_backing_stores() {
return Err(format!("{error:#}"));
}
match cache.get::<UnionSurface>("k") {
Ok(value) => Ok(value.expect("the envelope was stored")),
Err(error) => Err(format!("{error:#}")),
@ -757,6 +536,157 @@ fn bounds_vectors() {
assert_eq!((accepted, refused, read_only), (4, 7, 1));
}
// A payload is one MessagePack value (bytes-after-outcomes.msgpack): every door refuses a tail by
// its count, names the clause, never the depth limit (the tail is not walked) and never a value.
#[test]
fn bytes_after_vectors() {
let rows: Vec<serde_json::Value> = rmp_serde::from_slice(&vector("bytes-after-outcomes.msgpack")).unwrap();
assert_eq!(rows.len(), 4);
let surface = vector("union-surface.msgpack");
for row in &rows {
let fields = row.as_array().unwrap();
let (file, outcome, union, detail) = (fields[0].as_str().unwrap(), fields[1].as_str().unwrap(), fields[2].as_str().unwrap(), fields[4].as_str().unwrap());
assert_eq!((outcome, union, detail), ("refused", "UnionSurface", "bytes-after"), "{file}");
let bytes = vector(file);
assert_eq!(&bytes[..surface.len()], &surface[..], "{file}");
let clause = format!("payload has {} bytes after its first MessagePack value", bytes.len() - surface.len());
let check = |door: &str, message: &str| {
assert!(message.contains(&clause), "{file} {door}: {message}");
assert!(!message.contains("the limit is"), "{file} {door} walked the tail: {message}");
assert!(!message.contains("CANARY-bytes-after-7d1e"), "{file} {door} echoed the tail: {message}");
assert!(!message.contains(CANARY), "{file} {door} echoed a value: {message}");
};
let error = decode_payload::<UnionSurface>(&bytes).unwrap_err();
assert!(error.downcast_ref::<PayloadTooDeep>().is_none(), "{file}");
check("decode_payload", &format!("{error:#}"));
// A store written behind the cache's back is refused at load, naming the type, the key and the count.
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("foreign.cc");
SingleFileMessagePackBackingStore::new(&path).push(&envelope("k", &bytes)).unwrap();
let mut single = CultCache::new();
single.register_entry_type::<UnionSurface>().unwrap();
single.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
let message = format!("{:#}", single.pull_all_backing_stores().unwrap_err());
check("single-file load", &message);
assert!(message.contains("\"k\"") && message.contains(UnionSurface::TYPE), "{file}: {message}");
let redb_path = temp.path().join("foreign.redb");
RedbMessagePackBackingStore::new(&redb_path).unwrap().push(&envelope("k", &bytes)).unwrap();
let mut redb = CultCache::new();
redb.register_entry_type::<UnionSurface>().unwrap();
redb.add_generic_backing_store(RedbMessagePackBackingStore::new(&redb_path).unwrap()).unwrap();
let message = format!("{:#}", redb.pull_all_backing_stores().unwrap_err());
check("redb load", &message);
assert!(message.contains("\"k\"") && message.contains(UnionSurface::TYPE), "{file}: {message}");
let mut soa = CultCache::new();
soa.register_entry_type::<UnionSurface>().unwrap();
let message = format!("{:#}", soa.load_soa(CultCacheSoaTable::from_envelopes(vec![envelope("k", &bytes)])).unwrap_err());
check("load_soa", &message);
assert!(message.contains("\"k\"") && message.contains(UnionSurface::TYPE), "{file}: {message}");
assert!(soa.snapshot().is_empty(), "{file}");
// Raw admission refuses and stages nothing.
let mut raw = CultCache::new();
raw.register_entry_type::<UnionSurface>().unwrap();
let message = format!("{:#}", raw.put_raw_envelope(envelope("k", &bytes)).unwrap_err());
check("put_raw_envelope", &message);
assert!(raw.snapshot().is_empty(), "{file}");
}
}
// Depth is found inside the first value, so a tail never hides it: a payload nested past the bound
// with a tail after it is the depth refusal, not bytes after.
#[test]
fn depth_beats_a_tail() {
let mut deep = vec![0x91u8; MAX_PAYLOAD_DEPTH + 1];
deep.push(0xc0);
deep.push(0xc0);
let error = decode_payload::<UnionSurface>(&deep).unwrap_err();
assert_eq!(error.downcast_ref::<PayloadTooDeep>(), Some(&TOO_DEEP), "{error:#}");
}
// Depth is refused where the payload is loaded: a store holding one nested past the bound is not read.
#[test]
fn depth_is_refused_at_load() {
let mut deep = vec![0x91u8; MAX_PAYLOAD_DEPTH + 1];
deep.push(0xc0);
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("deep.cc");
SingleFileMessagePackBackingStore::new(&path).push(&envelope("k", &deep)).unwrap();
let mut cache = CultCache::new();
cache.register_entry_type::<UnionSurface>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
let error = cache.pull_all_backing_stores().unwrap_err();
assert_eq!(error.downcast_ref::<PayloadTooDeep>(), Some(&TOO_DEEP), "{error:#}");
assert!(cache.snapshot().is_empty());
}
// A cache whose load was refused refuses every write and removal and leaves the store as it was: a
// later write would otherwise persist the empty image over the records it never read.
#[test]
fn a_refused_load_refuses_every_write() {
let tail = {
let mut bytes = vector("union-surface.msgpack");
bytes.push(0xc0);
bytes
};
let good = envelope("good", &vector("union-surface.msgpack"));
let bad = envelope("bad", &tail);
for redb in [false, true] {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(if redb { "seed.redb" } else { "seed.cc" });
let mut cache = CultCache::new();
cache.register_entry_type::<UnionSurface>().unwrap();
if redb {
let mut store = RedbMessagePackBackingStore::new(&path).unwrap();
store.push(&good).unwrap();
store.push(&bad).unwrap();
drop(store);
cache.add_generic_backing_store(RedbMessagePackBackingStore::new(&path).unwrap()).unwrap();
} else {
let mut store = SingleFileMessagePackBackingStore::new(&path);
store.push(&good).unwrap();
store.push(&bad).unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
}
let load = cache.pull_all_backing_stores().unwrap_err();
assert!(format!("{load:#}").contains("bytes after"), "{load:#}");
let before = std::fs::read(&path).unwrap();
let value = constructed();
let staged = cache.prepare_entry("new", &value).unwrap().0;
let results: Vec<(&str, anyhow::Error)> = vec![
("put", cache.put("new", &value).unwrap_err()),
("put_prepared_batch", cache.put_prepared_batch(vec![staged.clone()]).unwrap_err()),
("put_envelope", cache.put_envelope::<UnionSurface>(staged.clone()).unwrap_err()),
("put_raw_envelope", cache.put_raw_envelope(staged.clone()).unwrap_err()),
("update", cache.update::<UnionSurface, _>("good", |current| current.unwrap_or_else(constructed)).unwrap_err()),
("delete", cache.delete::<UnionSurface>("good").unwrap_err()),
("delete a key the image lacks", cache.delete::<UnionSurface>("absent").unwrap_err()),
];
for (door, error) in results {
let refusal = error.downcast_ref::<CultCacheLoadRefused>().unwrap_or_else(|| panic!("{door} redb={redb}: {error:#}"));
assert!(refusal.to_string().contains("bytes after"), "{door}: {refusal}");
}
assert_eq!(std::fs::read(&path).unwrap(), before, "redb={redb}: the store changed");
assert!(cache.snapshot().is_empty());
// Repairing the store and loading again lifts it.
if redb {
RedbMessagePackBackingStore::new(&path).unwrap().delete(&bad).unwrap();
} else {
SingleFileMessagePackBackingStore::new(&path).delete(&bad).unwrap();
}
cache.pull_all_backing_stores().unwrap();
cache.put("new", &value).unwrap();
}
}
// A payload that ends early is not bytes after the value: the decoder refuses it, not the check.
#[test]
fn a_truncated_payload_is_the_decoders_not_bytes_after() {
let mut truncated = vec![0x91, 0xd9, 0x40];
truncated.extend_from_slice(b"short");
let message = format!("{:#}", decode_payload::<UnionSurface>(&truncated).unwrap_err());
assert!(!message.contains("bytes after"), "{message}");
}
fn wrap_chain(wraps: usize, leaf: Node) -> UnionSurface {
let mut node = leaf;
for _ in 0..wraps {
@ -788,6 +718,7 @@ fn write_refused_past_limit() {
let mut cache = CultCache::new();
cache.register_entry_type::<UnionSurface>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
cache.pull_all_backing_stores().unwrap();
let at_limit = wrap_chain(126, boxed("0000000000f0", vec![]));
assert_eq!(cache.put("at", &at_limit).unwrap(), at_limit);
@ -871,6 +802,7 @@ fn the_raw_doors_hold_the_bound_for_arms_maps_and_arrays() {
cache.register_entry_type::<UnionSurface>().unwrap();
cache.register_entry_type::<MapDoc>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
cache.pull_all_backing_stores().unwrap();
cache.put_raw_envelope(raw(type_name, "at-raw", at.clone())).unwrap();
cache.put_prepared_batch(vec![raw(type_name, "at-batch", at.clone())]).unwrap();
let stored = cache.snapshot_envelopes();

View file

@ -245,9 +245,9 @@ fn delete_releases_a_value() {
fn types_sharing_an_alias_and_a_value_do_not_conflict() {
let mut cache = memory_cache();
cache.put("a", &unique("S")).unwrap();
cache.put("a", &Other { sku: "S".into() }).unwrap();
cache.put("b", &Other { sku: "T".into() }).unwrap();
assert!(cache.put("b", &Other { sku: "S".into() }).is_err());
cache.put("oa", &Other { sku: "S".into() }).unwrap();
cache.put("ob", &Other { sku: "T".into() }).unwrap();
assert!(cache.put("ob", &Other { sku: "S".into() }).is_err());
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap().unwrap(), unique("S"));
assert_eq!(cache.get_by_index::<Other>("Sku", "S").unwrap().unwrap(), Other { sku: "S".into() });
}

View file

@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
### Breaking
- `SingleFileMessagePackBackingStore.pushAll` writes the given entries onto the store as it is under the lock, as `push` does: a record under the same key is replaced and no other is removed. It used to replace the whole file with the entries, so `CultMesh.flush()` from a cache that held less than the store (a node created with `pullOnStart: false`, or one whose image was stale) wiped the records it never read. A caller that relied on `pushAll` to reset a store removes the file first. A soft flush and the dangling-link case are unchanged. `CultCache.storedSnapshot()` is the image as the stores hold it (a global loaded from a legacy key under that key) and is what `CultMesh.flush` persists, so a flush no longer leaves such a global under two keys; `snapshot()` is unchanged.
- A single-file store marked `cultcache.store.v3` (it holds an element id, written by the C# runtime) is read, and a rewrite keeps the mark and the ids; this runtime does not decide when a document carries ids. A runtime from before this change refuses a v3 store by name, so 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.
- `indexes` are unique, as C# `[CultIndex]`: a write or a load that leaves two records of a type holding one non-blank
value under one alias is refused naming both keys and the alias, and writes nothing. Index text is the C# canonical
@ -18,9 +19,18 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
- 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`.
- The cache holds one record per key (variants:ruling:cache-refuses-retype). A `put` or `putEnvelope` that lands a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store (a memory-only cache used to hold both types at the key). A type that owns the held record's schema id (as for the store) replaces it, unless the held record is in a store other than the written type's home: that rename is refused with `SchemaConflictError` (remove the record first). Two stores holding one key, registered or foreign, refuse the load and admit nothing. A store's `delete` removes the record whose stored schema id the removal's type owns, whatever the arrived record's `type` string.
- A store holding a record no registered type claims now loads (it used to throw "No schema is registered..."): the cache does not serve the record, lists it in `foreignRecords()`, keeps its bytes through every write, and refuses a `put` or a `delete` at its key with `SchemaConflictError` (variants:ruling:unheld-removal-refused-at-commit). Registering a type that claims its schema id serves it at the next `pullAllBackingStores`.
- A record whose payload carries a slot past its reader's declared slots, at any depth (the record, a nested record, a list element, a union arm, a unit arm), is now held read-only: it loads and reads, and every `put`, `putEnvelope`, `update` or `delete` of its key is refused with `SchemaConflictError` before a store is touched (cultlib-gaps:ruling:extra-trailing-slots, as the C# reference). A load used to skip the extra slots silently and a later write shed them. The refusal names the schema, the key, the type that was read with extra slots and both slot counts, never a slot value, and carries them as `SchemaConflictError.unreadSlots`. An untouched record keeps its bytes through writes of other keys. Missing trailing slots keep their declared defaults and mark nothing. A `put` never marks (the value is encoded by this runtime); a `putEnvelope` of a payload carrying extra slots lands its bytes and holds the record read-only. A mark at depth 0 is consented to only when the record arrived under a schema id other than the reader's own that the reader lists compatible, and that id's catalog entry lists those slots (a writer that adds a slot and keeps the reader's id is never consented to) (`unreadSlots.explained`); every other mark refuses whatever is declared. New: `CultCache.isWritable(definition, key)` (live, advisory), `CultCache.unreadSlots(definition, key)` and the exported `CultCacheUnreadSlots`. Documents with no slot codec (a plain map, a user formatter) never mark. A `delete` is by key: a type that owns the schema id of the record held at the key as another type removes that one record (one store call, the cache dropped once the store has removed it), and a type that does not own it is refused with `SchemaConflictError`, as a `put` is; `isWritable` answers both. Implied semver: a minor bump (0.16.0) while the package is 0.x; a major one at 1.0 and after.
### Added
- `checkPayloadDepth` returns the offset where the first value ends, or `undefined` when the payload ends early (a header, a container or a str/bin/scalar body cut short; it was `void`); it still refuses only depth. The bytes-after refusal is internal (`checkPayloadIsOneValue`, not exported); no new public name.
- `CultCache.foreignRecords()`: the `{key, schemaId, schemaName, storedAt}` of every record the last pull carried and no registered type claims, sorted by key ordinal.
### Changed
- A document payload is exactly one MessagePack value (cultlib-gaps:ruling:trailing-bytes-refuse). `decodeDocumentPayload`, the one reader (store loads, `put`/`putEnvelope`, CultNet replication, the provider command), and the inspector refuse bytes after the first value with `document type "T" payload has N bytes after its first MessagePack value.`, counted from the walk's end and never walked or echoed; a payload that ends early stays the decoder's to refuse. The refused set is unchanged; only the text changed (it was `@msgpack`'s `RangeError` "Extra N of M byte(s)" for a plain document and the codec reader's "bytes after its value" for a codec document, and the inspector's preview no longer shows a tail), so code matching either text must match this one. `readWire` reads one value and no longer checks what follows. Stated limit: a definition with its own `formatter` is not walked, so whether its payload is one value is the formatter's. Implied bump: 0.16.0 (pre-1.0 minor, with the Breaking entries above).
- Opening a legacy (pre-v1) store is a pure read: payloads that were not bytes are normalized in memory and the
file is left as it was, so a codec that refuses the content cannot lose the old bytes. The next push, delete or
flush rewrites the file as v1, merging what the open read.

View file

@ -49,7 +49,9 @@ through `decodeDocumentPayload`, and when you need the bytes themselves (hashing
write) take them with `documentPayloadBytes`, the sanctioned escape.
- `decodeDocumentPayload(definition, payload)` reads a payload through the definition's formatter,
codec or plain map and then its schema. The definition is required.
codec or plain map and then its schema. The definition is required. A payload is exactly one
MessagePack value: bytes after the first are refused by count, naming the type, and never walked or
echoed (a payload that ends early is the decoder's to refuse).
- `encodeDocumentPayload(definition, value)` is the writer: the schema first, then the formatter,
codec or plain map.
- `documentPayloadFromBytes(bytes)` is where bytes enter (a record you validated, a store read, a
@ -172,7 +174,7 @@ const settings = cache.getRequiredGlobal(settingsDocument);
- `updateGlobal(...)`
- `delete(...)`
- `deleteGlobal(...)`
- `snapshot()`
- `snapshot()` (the logical image) and `storedSnapshot()` (the image as the stores hold it, for a whole flush)
- `getEnvelope(...)`
- `getRequiredEnvelope(...)`
- `getGlobalEnvelope(...)`
@ -231,6 +233,16 @@ its exact decimal expansion; NaN and infinities have no value. `roles` name sele
member's catalog `indexAlias`, so the catalog bytes are the same either way); they imply nothing about uniqueness and
are never indexed for lookup, so `getByIndex` on a role answers `undefined`. An alias is declared in one of the two.
### One record per key, and foreign records
A cache holds one record per key whatever its type (variants:ruling:cache-refuses-retype). A write of a type that does not
own the held record's schema id (it carries that id as its own or lists it compatible) is refused with
`SchemaConflictError` naming the key, with or without a store; retyping a key is `delete` then `put`. A store that holds a
record no registered type claims still loads: the cache does not serve it, `foreignRecords()` lists it (`key`,
`schemaId`, `schemaName`, `storedAt`, by key ordinal), every write keeps its bytes, and a `put` or `delete` at its key is
refused. Registering a type that claims its schema id serves it at the next `pullAllBackingStores`. Two stores that hold
one key refuse the load and admit nothing.
### Migrating an existing `indexes` key
Apply per key, and do it BEFORE the CultLib checkout your tools load is rebuilt or updated. A consumer that loads
@ -317,7 +329,7 @@ If you do not supply one, `CultCacheTS` uses MessagePack plus Zod validation by
The 256-container nesting bound (`MAX_PAYLOAD_DEPTH`, refused with `CultCachePayloadDepthError`) covers the MessagePack the
library itself writes: the default format and a codec. A payload your formatter writes is opaque bytes in any format, so the
library does not walk it as MessagePack; its format and its bounds are your formatter's.
library does not walk it as MessagePack; its format, its bounds and whether it is one value are your formatter's.
Zod is no longer mandatory. Any schema object with a `parse(...)` function will
do, which means generated Ajv-backed JSON-schema contracts can feed the cache
@ -361,8 +373,11 @@ const sceneDocument = defineDocumentType({
});
```
- A slot is `[name, codec, default?]`. A gap is written nil and skipped on read; an extra trailing slot is skipped; a
slot missing from the payload (or nil, when a default is declared) takes its default, and is refused without one.
- A slot is `[name, codec, default?]`. A gap is written nil and skipped on read; an extra trailing slot is read past and
marks the record read-only (every write or removal of its key is refused with `SchemaConflictError`, `cache.isWritable`
asks first). The one writable case is a depth-0 extra slot of a record that arrived under a schema id other than the reader's own, which the
reader lists in `compatibleSchemaIds`, whose catalog entry lists those slots: declaring that id consents to the loss, and a write restamps
the record. A nested slot, or a writer that keeps the reader's own id, is never consented to. A slot missing from the payload (or nil, when a default is declared) takes its default, and is refused without one.
- A union refuses an undeclared arm key on read, naming the union, the key and the declared keys. Nothing falls back
to a default arm. An arm with no slots is `codecs.unit` and is written `[key, []]`.
- A refusal names the union, the field and the slot; it never echoes the value it refused.

View file

@ -2,7 +2,7 @@
// (node:fs, koffi). Bring your own CacheBackingStore, or none.
export * as codecs from "./codec";
export type { Codec, SlotsCodec } from "./codec";
export { CultCache, CultCacheBuilder } from "./cult-cache";
export { CultCache, CultCacheBuilder, type CultCacheForeignRecord } from "./cult-cache";
export { inspectCultCacheBytes } from "./cult-cache-inspector";
export {
decodeDocumentPayload,
@ -34,6 +34,7 @@ export type {
CultCacheIndexScalar,
InferCultCacheSchemaValue,
CultCacheStoreRegistration,
CultCacheUnreadSlots,
PushAllOptions,
} from "./types";
export type {

View file

@ -198,6 +198,66 @@ type SlotSpec<TValue, TName extends string = string> =
type SlotsSpec<TValue> = Readonly<Record<number, { [K in keyof TValue & string]: SlotSpec<TValue[K], K> }[keyof TValue & string]>>;
// The slots a payload decode read past, noted by `slots` and the unit arm while the one decode entry (decodeNoting) has a scope
// open, and read out by that entry. Module-scoped as the reference's is thread-bound: a decode is synchronous, and a decode
// outside the entry (a codec called directly, a CultMesh read) notes nothing. No codec signature carries it.
export interface NotedSlots {
/** The first type read with extra slots, outermost first. */
readonly typeName: string;
readonly slotCount: number;
readonly declaredCount: number;
readonly occurrences: number;
/** True when every noted object was the document's own: a union adds no depth, each record or arm inside a field adds one. */
readonly depth0Only: boolean;
}
interface SlotScope {
depth: number;
found?: { typeName: string; slotCount: number; declaredCount: number; occurrences: number; depth0Only: boolean };
}
let scope: SlotScope | undefined;
/** The one decode entry: runs the decode with a scope open and returns its value with what it noted. */
export function decodeNoting<T>(decode: () => T): { value: T; unreadSlots?: NotedSlots } {
const outer = scope;
const mine: SlotScope = { depth: 0 };
scope = mine;
try {
const value = decode();
return mine.found ? { value, unreadSlots: mine.found } : { value };
} finally {
scope = outer;
}
}
function noteExtraSlots(typeName: string, slotCount: number, declaredCount: number): void {
if (!scope) return;
const found = scope.found;
if (found) {
found.occurrences += 1;
found.depth0Only &&= scope.depth === 0;
} else {
scope.found = { typeName, slotCount, declaredCount, occurrences: 1, depth0Only: scope.depth === 0 };
}
}
// What a record inside a field reads at: one deeper than the record holding the field.
function nested<T>(read: () => T): T {
if (!scope) return read();
scope.depth += 1;
try {
return read();
} finally {
scope.depth -= 1;
}
}
// The reference's type name for a mark: the type, not its namespace or the type it is nested in.
function simpleTypeName(typeName: string | undefined): string {
return typeName === undefined ? "record" : typeName.slice(Math.max(typeName.lastIndexOf("."), typeName.lastIndexOf("+")) + 1);
}
const INT32_MIN = -(2 ** 31);
const INT32_MAX = 2 ** 31 - 1;
@ -232,7 +292,7 @@ function pascalCase(name: string): string {
}
// A positional record: slot `key` holds field `name`. Gaps are written nil and skipped on read, extra trailing slots
// are skipped, and a slot missing from the payload takes its declared default or is refused. A default fills only a
// are read past and noted (decodeNoting): the record that carried them is read-only, never rewritten without them, and a slot missing from the payload takes its declared default or is refused. A default fills only a
// missing trailing slot, never a nil: as in the reference, a nil in a reference slot (string, bytes, record, union,
// list) reads as null and writes back as nil, so a record the reference wrote re-encodes byte-identical, and a nil in
// a value slot (int, float, bool) is refused unless the slot is nullable.
@ -281,6 +341,9 @@ export function slots<TValue extends object>(spec: SlotsSpec<TValue>, typeName?:
decode(raw) {
expect(raw, "an array of slots", Array.isArray(raw));
const input = raw as unknown[];
if (input.length > length) {
noteExtraSlots(simpleTypeName(typeName), input.length, length);
}
const out: Record<string, unknown> = {};
for (const field of fields) {
const present = field.key < input.length;
@ -291,7 +354,7 @@ export function slots<TValue extends object>(spec: SlotsSpec<TValue>, typeName?:
? defaultOf(field)
: input[field.key] === null && !field.codec.valueType
? null
: at(`${field.name} (slot ${field.key})`, () => field.codec.decode(input[field.key]));
: at(`${field.name} (slot ${field.key})`, () => nested(() => field.codec.decode(input[field.key])));
}
return out as TValue;
},
@ -363,6 +426,9 @@ export function union<TValue extends { kind: string }>(
if (arm === undefined) {
throw new Error(`Union ${name}: unknown arm key ${key} (declared keys: ${declared}).`);
}
if (arm.body === unit && raw[1].length > 0) {
noteExtraSlots(arm.armName, raw[1].length, 0);
}
const fields = arm.body === unit ? {} : at(`arm ${arm.armName}`, () => (arm.body as SlotsCodec<any>).decode(raw[1]));
return { kind: arm.armName, ...fields } as unknown as TValue;
},

View file

@ -1,6 +1,6 @@
import { decode } from "@msgpack/msgpack";
import { checkPayloadDepth } from "./payload-depth";
import { checkPayloadDepth, checkPayloadIsOneValue } from "./payload-depth";
import { type StoreFormat, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots } from "./store-format";
export interface CultCacheInspection {
@ -235,12 +235,22 @@ function decodeLegacyEnvelope(value: unknown): InspectedRecord {
};
}
// The inspector's one payload read: the depth walk first, so a payload past the limit is refused before any decoder sees it.
// The inspector's one payload read: the depth walk first, so a payload past the limit is refused before any decoder sees it,
// and bytes after the first value are refused with the shared clause (the tail is never decoded or shown).
function decodeBoundedPayload(payload: Uint8Array): unknown {
checkPayloadDepth(payload);
checkPayloadIsOneValue(payload, "document payload");
return decode(payload);
}
// Where the first value ends (the whole payload when it ends early or cannot be walked): a preview never shows a tail.
function valueEnd(payload: Uint8Array): number {
try {
return checkPayloadDepth(payload) ?? payload.length;
} catch {
return payload.length;
}
}
function previewPayload(payload: Uint8Array): Pick<InspectedRecord, "payloadPreview" | "payloadDecodeError"> {
try {
return { payloadPreview: toJsonSafe(decodeBoundedPayload(payload)) };
@ -248,7 +258,7 @@ function previewPayload(payload: Uint8Array): Pick<InspectedRecord, "payloadPrev
return {
payloadPreview: {
bytes: payload.length,
hexPrefix: [...payload.slice(0, 32)]
hexPrefix: [...payload.slice(0, Math.min(32, valueEnd(payload)))]
.map((value) => value.toString(16).padStart(2, "0"))
.join(" "),
},

View file

@ -1,6 +1,6 @@
import {
declaresRole,
decodeDocumentPayload,
decodeDocumentPayloadMarked,
documentPayloadBytes,
documentPayloadFromBytes,
encodeDocumentPayload,
@ -8,8 +8,9 @@ import {
schemaIdentityOf,
} from "./document";
import { resolveDocumentFormatter } from "./document-formatter";
import type { NotedSlots } from "./codec";
import type { DocumentPayload } from "./document";
import { SchemaConflictError } from "./store-format";
import { SchemaConflictError, schemaIdForEnvelope, writtenOwns } from "./store-format";
import type {
AnyCultCacheDocumentDefinition,
CacheBackingStore,
@ -20,6 +21,7 @@ import type {
CultCacheEnvelope,
CultCacheSchemaCatalogEntry,
CultCacheStoreRegistration,
CultCacheUnreadSlots,
} from "./types";
type Accessor = (value: unknown) => string | undefined;
@ -27,7 +29,7 @@ type Accessor = (value: unknown) => string | undefined;
// A registered definition reads and writes its documents through the owner, on payloads the cache has cloned.
type RegisteredFormatter = {
encode(value: unknown): DocumentPayload;
decode(payload: DocumentPayload): unknown;
decode(payload: DocumentPayload): { value: unknown; unreadSlots?: NotedSlots };
};
type RegisteredDefinition = {
@ -50,6 +52,10 @@ type HydratedEntry = CultCacheEnvelope & {
catalogEntry: NonNullable<CultCacheEnvelope["catalogEntry"]>;
value: unknown;
lookups: EntryLookups;
// What the payload decode read past: set when the record was admitted, and it makes the record read-only unless explained.
unreadSlots?: CultCacheUnreadSlots;
// The schema id the record arrived under, before it was admitted under its reader's: what an unread-slots refusal names.
storedSchemaId?: string;
};
// Everything a pull replaces. Built whole, then swapped in with one assignment.
@ -64,6 +70,17 @@ type HydratedState = {
// delete of that global removes it from the home store. Remove once no store holds a global under
// a key other than GLOBAL_KEY.
readonly legacyGlobals: Map<string, CultCacheEnvelope>;
// The records the last pull found that no registered type claims. Derived from the pull and replaced with the state; it
// decides nothing but the refusal of a write or a removal at its keys.
readonly foreign: CultCacheForeignRecord[];
};
/** A record a store carries that no registered type claims: the cache does not serve it, keeps its bytes, and refuses a write or a removal at its key. */
export type CultCacheForeignRecord = {
readonly key: string;
readonly schemaId: string;
readonly schemaName: string;
readonly storedAt: string;
};
// Lookups for every held record of one type under candidate accessors, derived before anything is installed.
@ -73,6 +90,20 @@ type DerivedTypeLookups = {
readonly indexMaps: Map<string, Map<string, string>>;
};
// The consent a compatible-id declaration gives (cultlib-gaps:ruling:slot-mark-consent): a mark is explained only when the record
// arrived under an id other than the reader's own that the reader lists compatible, it is at
// depth 0 and the schema comparison of the catalog entry the record arrived with against the reader's own reported the slots it
// carries as ignored, so its slot count is at most the highest ignored slot plus one. A record written or put through the cache
// arrives under the reader's own entry, which ignores nothing, so no mark of it is explained.
function explainedBy(noted: NotedSlots, reader: CultCacheSchemaCatalogEntry, arrivedId: string, arrived: CultCacheSchemaCatalogEntry | undefined): boolean {
if (arrivedId === reader.schemaId || !(reader.compatibleSchemaIds ?? []).includes(arrivedId)) {
return false;
}
const declared = new Set((reader.members ?? []).map((member) => member.slot));
const ignored = (arrived?.members ?? []).map((member) => member.slot).filter((slot) => !declared.has(slot));
return noted.depth0Only && noted.slotCount <= Math.max(-1, ...ignored) + 1;
}
function emptyHydratedState(): HydratedState {
return {
entries: new Map(),
@ -81,6 +112,7 @@ function emptyHydratedState(): HydratedState {
indexToKeyMaps: new Map(),
globalKeys: new Map(),
legacyGlobals: new Map(),
foreign: [],
};
}
@ -280,11 +312,14 @@ export class CultCache {
for (const entry of entries) {
const registered = this.#resolveDefinitionForEnvelope(entry);
if (!registered) {
throw new Error(
entry.schemaId
? `No schema is registered for persisted schema id "${entry.schemaId}".`
: `No schema is registered for persisted document type "${entry.type}".`,
);
// A store may hold what this cache has no type for; it is carried, not served, and not a reason to refuse the load.
next.foreign.push({
key: entry.key,
schemaId: schemaIdForEnvelope(entry),
schemaName: entry.catalogEntry?.schemaName ?? entry.type,
storedAt: entry.storedAt,
});
continue;
}
const type = registered.definition.type;
@ -297,7 +332,7 @@ export class CultCache {
}
const payload = this.#cloneBytes(entry.payload);
const value = registered.formatter.decode(payload);
const { value, unreadSlots } = registered.formatter.decode(payload);
let admitted: CultCacheEnvelope = { ...entry, type, payload };
if (registered.global) {
const persistedKey = persistedGlobalKeys.get(type);
@ -312,11 +347,28 @@ export class CultCache {
admitted = { ...admitted, key: CultCache.GLOBAL_KEY };
}
}
const hydrated = this.#admitEntry(next, registered, admitted, value);
const hydrated = this.#admitEntry(next, registered, admitted, value, unreadSlots);
const other = this.#heldUnderOtherType(next, type, hydrated.key);
if (other !== undefined) {
throw new Error(`Record ${hydrated.key} is held by two stores, as type "${other.type}" and as type "${type}".`);
}
this.#installEntry(next, registered, hydrated);
}
}
const foreignByKey = new Map<string, string>();
for (const record of next.foreign) {
const first = foreignByKey.get(record.key);
if (first !== undefined) {
throw new Error(`Record ${record.key} is held by two stores, as schema "${first}" and as schema "${record.schemaName}".`);
}
foreignByKey.set(record.key, record.schemaName);
const other = [...this.#definitions.keys()].map((type) => next.entries.get(this.#entryId(type, record.key))).find((held) => held !== undefined);
if (other !== undefined) {
throw new Error(`Record ${record.key} is held by two stores, as type "${other.type}" and as schema "${record.schemaName}".`);
}
}
// Judged whole, once: the refusal names the least violation of the state this load would leave, whatever the
// order of the stores and of the entries in them.
refuseLeastIndexSharing(function* () {
@ -486,10 +538,9 @@ export class CultCache {
if (!envelope.storedAt || envelope.storedAt.trim().length === 0) {
throw new Error(`CultCache envelope storedAt for type "${definition.type}" must be non-empty.`);
}
this.#requireGlobalKey(registered, envelope.key);
const payload = this.#cloneBytes(envelope.payload);
const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue<TDefinition>;
const { value: parsed, unreadSlots } = registered.formatter.decode(payload) as { value: CultCacheDocumentValue<TDefinition>; unreadSlots?: NotedSlots };
const entry: CultCacheEnvelope = {
key: envelope.key,
type: envelope.type,
@ -500,7 +551,7 @@ export class CultCache {
schemaId: registered.catalogEntry.schemaId,
catalogEntry: registered.catalogEntry,
};
await this.#writeNow(registered, entry, parsed);
await this.#writeNow(registered, entry, parsed, unreadSlots);
return parsed;
});
}
@ -553,10 +604,45 @@ export class CultCache {
});
}
/** What the record's payload carried past this build's declared slots, or undefined: the held record is read-only unless `explained`. */
unreadSlots(definition: AnyCultCacheDocumentDefinition, key: string): CultCacheUnreadSlots | undefined {
return this.#hydrated.entries.get(this.#entryId(this.#requireDefinition(definition).definition.type, key))?.unreadSlots;
}
/**
* Whether a write of the record's own type at the key would be let through: it asks the judge #writeNow asks, of the record the
* cache holds (a key a store carries foreign, a record of another type the write does not own or cannot replace, a global at
* another key, a record carrying unread slots nothing consents to). It answers the WRITE form. A removal asks the judge's
* removal form and differs in two states: an owner's `delete` of a record held in another store than its own home succeeds
* where this is false (a write would push to the wrong store), and `delete` of a global type at a key other than __global__ is
* the plain false of an unheld key where this is false. Live and advisory: the write or removal still decides.
*/
isWritable(definition: AnyCultCacheDocumentDefinition, key: string): boolean {
const registered = this.#requireDefinition(definition);
try {
this.#judge(registered, key, this.#probe(registered, key));
return true;
} catch {
return false;
}
}
// What the last pull found that no registered type claims, by key ordinal. A later registration claims a record at the next pull.
foreignRecords(): readonly CultCacheForeignRecord[] {
return [...this.#hydrated.foreign].sort((left, right) => compareOrdinal(left.key, right.key));
}
snapshot(): CultCacheEnvelope[] {
return [...this.#hydrated.entries.values()].map((entry) => this.#toEnvelope(entry));
}
// The image as the stores hold it, for persisting it whole (a flush): a global loaded from a legacy key is handed out under
// that key until its first write retires it, so a flush cannot leave the store holding it under two keys. `snapshot` is the
// logical image and always names a global by CultCache.GLOBAL_KEY.
storedSnapshot(): CultCacheEnvelope[] {
return [...this.#hydrated.entries.values()].map((entry) => this.#hydrated.legacyGlobals.get(entry.type) ?? this.#toEnvelope(entry));
}
#registerNow(definition: AnyCultCacheDocumentDefinition): void {
// Every registration refusal is a SchemaConflictError naming the new definition's schema id and both schema names.
const refuse = (holder: RegisteredDefinition, claimed: CultCacheSchemaCatalogEntry, what: string) =>
@ -643,9 +729,8 @@ export class CultCache {
value: CultCacheDocumentValue<TDefinition>,
): Promise<CultCacheDocumentValue<TDefinition>> {
const registered = this.#requireDefinition(definition);
this.#requireGlobalKey(registered, key);
const payload = registered.formatter.encode(value);
const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue<TDefinition>;
const { value: parsed, unreadSlots } = registered.formatter.decode(payload) as { value: CultCacheDocumentValue<TDefinition>; unreadSlots?: NotedSlots };
const entry: CultCacheEnvelope = {
key,
type: definition.type,
@ -654,15 +739,16 @@ export class CultCache {
schemaId: registered.catalogEntry.schemaId,
catalogEntry: registered.catalogEntry,
};
await this.#writeNow(registered, entry, parsed);
await this.#writeNow(registered, entry, parsed, unreadSlots);
return parsed;
}
// Validates the whole write (home, global singleton, name and index accessors) before the store
// is touched; once the store accepts, applying to the cache has no fallible step.
async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown): Promise<void> {
async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown, unreadSlots?: NotedSlots): Promise<void> {
const home = this.#homeStore(entry.type);
const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value);
const retyped = this.#judge(registered, entry.key, entry);
const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value, unreadSlots);
this.#refuseIndexSharing(this.#hydrated, entry.type, registered.catalogEntry.schemaName, entry.key, hydrated.lookups);
const legacy = this.#hydrated.legacyGlobals.get(entry.type);
await home?.push(entry);
@ -670,9 +756,133 @@ export class CultCache {
await this.#retireLegacyGlobal(home, legacy, entry);
}
this.#hydrated.legacyGlobals.delete(entry.type);
if (retyped !== undefined) {
// The record the write restamped (it owns the held id) is the same record: one key, one held type.
this.#removeEntry(this.#hydrated, this.#definitions.get(retyped.type)!, retyped);
}
this.#installEntry(this.#hydrated, registered, hydrated);
}
// An envelope of the definition's type at the key, carrying nothing: what the judge asks about a write or a removal by that type.
#probe(registered: RegisteredDefinition, key: string): CultCacheEnvelope {
return {
key, type: registered.definition.type, payload: documentPayloadFromBytes(new Uint8Array(0)), storedAt: "",
schemaId: registered.catalogEntry.schemaId, catalogEntry: registered.catalogEntry,
};
}
// The one judge of whether a key may be written (a written envelope) or removed (none), before any store is touched: it throws the
// refusal, and #writeNow, #deleteNow and isWritable all ask it, so the query cannot disagree with the write. For a write it
// returns the held record of another type that the write owns and so restamps, if any. A refusal names keys and schemas,
// never a payload or a slot value.
#judge(registered: RegisteredDefinition, key: string, written?: CultCacheEnvelope): HydratedEntry | undefined {
const type = registered.definition.type;
const held = this.#hydrated.entries.get(this.#entryId(type, key));
if (written === undefined) {
// A removal is by key: the record it removes is the one held at the key, of this type or of another type whose schema id
// this type owns (the record a write of this type would replace). A held record of a type it does not own is refused.
this.#refuseForeignKey(key, type, "removed");
this.#refuseUnread(held);
if (held !== undefined) {
return undefined;
}
const other = this.#heldUnderOtherType(this.#hydrated, type, key);
if (other === undefined) {
return undefined;
}
if (!writtenOwns(this.#probe(registered, key), schemaIdForEnvelope(other))) {
throw new SchemaConflictError(
registered.catalogEntry.schemaId,
[other.type, type],
key,
`CultCache holds record "${key}" as type "${other.type}", so type "${type}" cannot remove it.`,
);
}
this.#refuseUnread(other);
return other;
}
this.#requireGlobalKey(registered, key);
const retyped = this.#refuseRetype(written);
this.#refuseUnread(held);
this.#refuseUnread(retyped);
if (retyped !== undefined && this.#homeStore(retyped.type) !== this.#homeStore(type)) {
// The write owns the held record's id and would replace it, but the held record is in another store; the push
// reaches only this type's home, so the old record would stay in its own file and load as a second holder.
throw new SchemaConflictError(
schemaIdForEnvelope(written),
[retyped.type, type],
key,
`CultCache holds record "${key}" as type "${retyped.type}" in another store than the home of type "${type}", so the rename cannot replace it; remove the record first.`,
);
}
return retyped;
}
// The one refusal of a record held read-only for the slots its payload carries past this build's: a write or a removal of
// its key. It names the type and the counts, never a slot value. A mark that is explained (see #admitEntry) is consented to.
#unreadRefusal(held: HydratedEntry | undefined): SchemaConflictError | undefined {
const unread = held?.unreadSlots;
if (held === undefined || unread === undefined || unread.explained) {
return undefined;
}
return new SchemaConflictError(
held.storedSchemaId ?? held.schemaId ?? held.type,
[held.catalogEntry.schemaName],
held.key,
`${held.catalogEntry.schemaName} record ${held.key} is read-only: ${unread.typeName} was read with ${unread.slotCount} slots where this build declares ${unread.declaredCount} ` +
`(${unread.occurrences} object(s) in the record). Writing it would drop data a newer writer stored; read it with types that declare those slots, or write it from that build.`,
unread,
);
}
#refuseUnread(held: HydratedEntry | undefined): void {
const refusal = this.#unreadRefusal(held);
if (refusal !== undefined) {
throw refusal;
}
}
// The one key-identity judge of the cache: may this write land at its key, given what the cache holds there under
// every registered type and the keys its stores carry foreign. The store judges the file with the same predicate
// (writtenOwns); this judges the cache, which a store-less cache has no other judge for. Returns the held record of
// another type the write owns and so replaces, if any. A refusal names the key and schemas, never a payload.
#refuseRetype(entry: CultCacheEnvelope): HydratedEntry | undefined {
this.#refuseForeignKey(entry.key, entry.type, "written");
const held = this.#heldUnderOtherType(this.#hydrated, entry.type, entry.key);
if (held === undefined || writtenOwns(entry, schemaIdForEnvelope(held))) {
return held;
}
throw new SchemaConflictError(
schemaIdForEnvelope(entry),
[held.type, entry.type],
entry.key,
`CultCache holds record "${entry.key}" as type "${held.type}", so type "${entry.type}" cannot be written under the same key; remove the record first.`,
);
}
#refuseForeignKey(key: string, type: string, what: "written" | "removed"): void {
const foreign = this.#hydrated.foreign.find((record) => record.key === key);
if (foreign !== undefined) {
throw new SchemaConflictError(
foreign.schemaId,
[foreign.schemaName],
key,
`CultCache store carries record "${key}" as schema "${foreign.schemaName}" (${foreign.schemaId}), which no registered type claims, so type "${type}" cannot be ${what} at that key.`,
);
}
}
// The record another registered type holds at the key, if any.
#heldUnderOtherType(state: HydratedState, type: string, key: string): HydratedEntry | undefined {
for (const other of this.#definitions.keys()) {
const held = other === type ? undefined : state.entries.get(this.#entryId(other, key));
if (held !== undefined) {
return held;
}
}
return undefined;
}
// The first write of a global loaded from a legacy key removes that record in the same write. If the
// removal fails, the new record is taken back out, so the store keeps only the legacy record.
async #retireLegacyGlobal(home: CacheBackingStore, legacy: CultCacheEnvelope, written: CultCacheEnvelope): Promise<void> {
@ -689,15 +899,20 @@ export class CultCache {
key: string,
): Promise<boolean> {
const registered = this.#requireDefinition(definition);
const entry = this.#hydrated.entries.get(this.#entryId(definition.type, key));
// variants:ruling:unheld-removal-refused-at-commit: a key a store carries foreign is refused, a key held by nobody is the
// existing false; a key held as another type is removed by an owner of its id and refused to any other type.
// Removal is by key, in one store call: the record held at the key of this type, else the one of another type this type owns.
const owned = this.#judge(registered, key);
const entry = this.#hydrated.entries.get(this.#entryId(definition.type, key)) ?? owned;
if (!entry) {
return false;
}
const home = this.#homeStore(definition.type);
await home?.delete(this.#hydrated.legacyGlobals.get(definition.type) ?? this.#toEnvelope(entry));
this.#hydrated.legacyGlobals.delete(definition.type);
this.#removeEntry(this.#hydrated, registered, entry);
// The cache drops the record only once the store has removed it.
const home = this.#homeStore(entry.type);
await home?.delete(this.#hydrated.legacyGlobals.get(entry.type) ?? this.#toEnvelope(entry));
this.#hydrated.legacyGlobals.delete(entry.type);
this.#removeEntry(this.#hydrated, this.#definitions.get(entry.type)!, entry);
return true;
}
@ -724,7 +939,7 @@ export class CultCache {
return {
encode: (value: unknown) => this.#cloneBytes(encodeDocumentPayload(definition, value)),
decode: (payload: DocumentPayload) => decodeDocumentPayload(definition, this.#cloneBytes(payload)),
decode: (payload: DocumentPayload) => decodeDocumentPayloadMarked(definition, this.#cloneBytes(payload)),
};
}
@ -904,6 +1119,7 @@ export class CultCache {
registered: RegisteredDefinition,
entry: CultCacheEnvelope,
value: unknown,
noted?: NotedSlots,
): HydratedEntry {
if (registered.global) {
const currentGlobalKey = state.globalKeys.get(entry.type);
@ -924,6 +1140,8 @@ export class CultCache {
catalogEntry: registered.catalogEntry,
value,
lookups,
storedSchemaId: entry.schemaId ?? entry.type,
...(noted ? { unreadSlots: { ...noted, explained: explainedBy(noted, registered.catalogEntry, entry.schemaId ?? entry.type, entry.catalogEntry) } } : {}),
};
}

View file

@ -1,7 +1,7 @@
import { decode, encode } from "@msgpack/msgpack";
import { MAX_PAYLOAD_DEPTH } from "./payload-depth";
import type { SlotsCodec } from "./codec";
import { decodeNoting, type NotedSlots, type SlotsCodec } from "./codec";
import type { AnyCultCacheDocumentDefinition, CultCacheDocumentFormatter } from "./types";
import { readWire, writeWire } from "./wire";
@ -16,6 +16,13 @@ export function resolveDocumentFormatter(definition: AnyCultCacheDocumentDefinit
return definition.formatter ?? (definition.codec ? codecFormatter(definition.codec) : plainDocumentFormatter);
}
// The one decode of a document's bytes, with the slots it read past. A plain formatter or a user formatter has no slot codec
// and carries everything, so it notes nothing.
export function decodeMarked(definition: AnyCultCacheDocumentDefinition, bytes: Uint8Array): { value: unknown; unreadSlots?: NotedSlots } {
const formatter = resolveDocumentFormatter(definition);
return decodeNoting(() => formatter.decode(bytes));
}
const plainDocumentFormatter: CultCacheDocumentFormatter<unknown> = {
// The encoder's own depth refusal (100 by default) is set above the limit, so the payload walk decides.
encode: (value: unknown) => encode(value, { maxDepth: MAX_PAYLOAD_DEPTH * 2 }),

View file

@ -1,5 +1,6 @@
import { resolveDocumentFormatter } from "./document-formatter";
import { checkPayloadDepth } from "./payload-depth";
import type { NotedSlots } from "./codec";
import { decodeMarked, resolveDocumentFormatter } from "./document-formatter";
import { checkPayloadDepth, checkPayloadIsOneValue } from "./payload-depth";
import type {
AnyCultCacheDocumentDefinition,
CultCacheSchemaCatalogMember,
@ -118,10 +119,24 @@ export function decodeDocumentPayload<TDefinition extends AnyCultCacheDocumentDe
definition: TDefinition,
payload: DocumentPayload,
): CultCacheDocumentValue<TDefinition> {
return decodeDocumentPayloadMarked(definition, payload).value;
}
// The same read with the slots it read past (cult-cache-ts slot-readonly). The depth check runs first: a payload past the
// limit is refused, never marked.
export function decodeDocumentPayloadMarked<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition,
payload: DocumentPayload,
): { value: CultCacheDocumentValue<TDefinition>; unreadSlots?: NotedSlots } {
const bytes = documentPayloadBytes(payload);
if (writesLibraryMessagePack(definition)) checkPayloadDepth(bytes, `document type "${definition.type}" payload`);
const decoded = resolveDocumentFormatter(definition).decode(bytes);
return definition.schema.parse(decoded) as CultCacheDocumentValue<TDefinition>;
if (writesLibraryMessagePack(definition)) {
// A payload is exactly one MessagePack value: bytes after the first are refused by count before any formatter reads,
// so a tail is refused before any slot is marked.
checkPayloadIsOneValue(bytes, `document type "${definition.type}" payload`);
}
const { value: decoded, unreadSlots } = decodeMarked(definition, bytes);
const value = definition.schema.parse(decoded) as CultCacheDocumentValue<TDefinition>;
return unreadSlots ? { value, unreadSlots } : { value };
}
// The one writer: the definition's schema first, then its formatter, codec or plain map.

View file

@ -12,8 +12,9 @@ export class CultCachePayloadDepthError extends Error {
// Walks the headers of one MessagePack value iteratively (a count stack bounded by the limit, scalars skipped by length,
// nothing read) and throws at the first container past the limit. The message names the subject, the depth and the limit,
// never payload bytes. A payload that ends early is the reader's to refuse.
export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload"): void {
// never payload bytes. Returns the offset where the first value ends, or undefined when the payload ends early (the
// reader's to refuse); what follows that offset is never walked, and whether it may exist is the caller's rule.
export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload"): number | undefined {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const remaining = new Float64Array(MAX_PAYLOAD_DEPTH);
let depth = 0;
@ -33,12 +34,12 @@ export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload
else if (code <= 0x8f) { container = true; header = 1; children = 2 * (code & 0x0f); }
else if (code <= 0x9f) { container = true; header = 1; children = code & 0x0f; }
else if (code <= 0xbf) skip = 1 + (code & 0x1f);
else if (code === 0xc4 || code === 0xd9) { const n = width(1); if (n === undefined) return; skip = 2 + n; }
else if (code === 0xc5 || code === 0xda) { const n = width(2); if (n === undefined) return; skip = 3 + n; }
else if (code === 0xc6 || code === 0xdb) { const n = width(4); if (n === undefined) return; skip = 5 + n; }
else if (code === 0xc7) { const n = width(1); if (n === undefined) return; skip = 3 + n; }
else if (code === 0xc8) { const n = width(2); if (n === undefined) return; skip = 4 + n; }
else if (code === 0xc9) { const n = width(4); if (n === undefined) return; skip = 6 + n; }
else if (code === 0xc4 || code === 0xd9) { const n = width(1); if (n === undefined) return undefined; skip = 2 + n; }
else if (code === 0xc5 || code === 0xda) { const n = width(2); if (n === undefined) return undefined; skip = 3 + n; }
else if (code === 0xc6 || code === 0xdb) { const n = width(4); if (n === undefined) return undefined; skip = 5 + n; }
else if (code === 0xc7) { const n = width(1); if (n === undefined) return undefined; skip = 3 + n; }
else if (code === 0xc8) { const n = width(2); if (n === undefined) return undefined; skip = 4 + n; }
else if (code === 0xc9) { const n = width(4); if (n === undefined) return undefined; skip = 6 + n; }
else if (code === 0xca || code === 0xce || code === 0xd2) skip = 5;
else if (code === 0xcb || code === 0xcf || code === 0xd3) skip = 9;
else if (code === 0xcc || code === 0xd0) skip = 2;
@ -47,10 +48,10 @@ export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload
else if (code === 0xd6) skip = 6;
else if (code === 0xd7) skip = 10;
else if (code === 0xd8) skip = 18;
else if (code === 0xdc) { const n = width(2); if (n === undefined) return; container = true; header = 3; children = n; }
else if (code === 0xdd) { const n = width(4); if (n === undefined) return; container = true; header = 5; children = n; }
else if (code === 0xde) { const n = width(2); if (n === undefined) return; container = true; header = 3; children = 2 * n; }
else { const n = width(4); if (n === undefined) return; container = true; header = 5; children = 2 * n; }
else if (code === 0xdc) { const n = width(2); if (n === undefined) return undefined; container = true; header = 3; children = n; }
else if (code === 0xdd) { const n = width(4); if (n === undefined) return undefined; container = true; header = 5; children = n; }
else if (code === 0xde) { const n = width(2); if (n === undefined) return undefined; container = true; header = 3; children = 2 * n; }
else { const n = width(4); if (n === undefined) return undefined; container = true; header = 5; children = 2 * n; }
if (container) {
if (depth === MAX_PAYLOAD_DEPTH) throw new CultCachePayloadDepthError(MAX_PAYLOAD_DEPTH + 1, MAX_PAYLOAD_DEPTH, subject);
@ -63,8 +64,20 @@ export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload
position += skip;
}
// A scalar whose declared length runs past the payload is an early end, not a value.
if (position > bytes.length) return undefined;
// One element is complete; close every container it was the last element of.
while (depth > 0 && --remaining[depth - 1] === 0) depth--;
if (depth === 0) return;
if (depth === 0) return position;
}
return undefined;
}
// The one rule that a payload is exactly one MessagePack value: the depth walk, then a refusal of bytes after the first
// value, by count, naming the subject, from the walk's end. The tail is never walked or echoed; a payload that ends early
// stays the decoder's to refuse. decodeDocumentPayload and the inspector speak this clause and no other.
export function checkPayloadIsOneValue(bytes: Uint8Array, subject: string): number | undefined {
const end = checkPayloadDepth(bytes, subject);
if (end !== undefined && end < bytes.length) throw new Error(`${subject} has ${bytes.length - end} bytes after its first MessagePack value.`);
return end;
}

View file

@ -7,7 +7,7 @@ import { z } from "zod";
import { documentPayloadBytes, documentPayloadFromBytes } from "./document";
import { commitStoreFile, withStoreWriteLock } from "./store-file";
import { STORE_FORMAT_VERSION, SchemaConflictError, type StoreFormat, StoreUnreadableError, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots } from "./store-format";
import { STORE_FORMAT_VERSION, SchemaConflictError, type StoreFormat, StoreUnreadableError, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots, schemaIdForEnvelope, writtenOwns } from "./store-format";
import type {
CacheBackingStore,
CultCacheEnvelope,
@ -71,8 +71,10 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
const disk = this.#readDisk();
this.#format = disk.format;
const existing = disk.envelopes;
// The record the file stores at the key is the one removed when the removal's schema owns its stored id: the
// arrived record's type is its schema's name, which a type of another name does not carry.
const filtered = existing.filter(
(candidate) => !(candidate.type === entry.type && candidate.key === entry.key),
(candidate) => !(candidate.key === entry.key && (schemaIdForEnvelope(candidate) === schemaIdForEnvelope(entry) || writtenOwns(entry, schemaIdForEnvelope(candidate)))),
);
await this.#writeAll([], filtered);
});
@ -80,10 +82,12 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
async pushAll(entries: CultCacheEnvelope[], options: PushAllOptions = {}): Promise<void> {
await this.#enqueue(async () => {
// A flush of the whole store writes the header the file on disk carries, read now: a file marked for element ids
// stays marked, and one that is not (or is gone or legacy) is written unmarked. The file is read by the same
// reader as `pullAll`, so one it would refuse (not exactly one store, a variant, a body it cannot decode) is refused
// and left as it is. A soft flush asks that reader too: nothing at the path is written, a store is left alone.
// A flush writes `entries` onto the store as it is under the lock, the way a push does: a record under the same key
// is replaced and no other is removed, so a cache whose image is stale (or empty, never pulled) cannot drop what
// another writer added. The store is read by the same reader as `pullAll`, so one it would refuse (not exactly one
// store, a variant, a body it cannot decode) is refused and left as it is. A soft flush asks that reader too: nothing
// at the path is written, a store is left alone.
let held: CultCacheEnvelope[] = [];
try {
const disk = this.#readDisk();
if (options.soft && !disk.absent) {
@ -91,10 +95,11 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
}
this.#format = disk.format;
held = disk.envelopes;
} catch (error) {
// Only a dangling link reaches here with ENOENT: it holds no store, so a whole-store flush has no header to keep and
// replaces the link (R3 pins this). A soft flush does not replace what it cannot read, and every merging writer
// has already refused the link when it read the current entries.
// Only a dangling link reaches here with ENOENT: it holds no store, so a flush has nothing to merge with and no
// header to keep, and replaces the link (R3 pins this). A soft flush does not replace what it cannot read, and every
// merging writer has already refused the link when it read the current entries.
if (options.soft || (error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
@ -102,9 +107,10 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
this.#format = STORE_FORMAT_VERSION;
}
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()], []);
const supplied = [...new Map(entries.map((entry) => [entry.key, entry])).values()];
requireOneTypePerKey(this.filePath, entries, held);
await this.#writeAll(supplied, held);
});
}
@ -138,14 +144,6 @@ 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.
@ -383,10 +381,6 @@ function defaultEntryFor(entry: CultCacheEnvelope): CultCacheSchemaCatalogEntry
};
}
function schemaIdForEnvelope(entry: CultCacheEnvelope): string {
return entry.schemaId ?? entry.type;
}
function encodeCatalogEntry(entry: CultCacheSchemaCatalogEntry): unknown[] {
return [
entry.schemaId,

View file

@ -1,5 +1,7 @@
import { decode } from "@msgpack/msgpack";
import type { CultCacheEnvelope, CultCacheUnreadSlots } from "./types";
export const STORE_FORMAT_VERSION = "cultcache.store.v1";
/**
@ -81,13 +83,16 @@ export class SchemaConflictError extends Error {
readonly schemaId: string;
readonly schemaNames: string[];
readonly recordKey: string;
/** Set when the refusal is for unread slots; undefined for every other refusal. */
readonly unreadSlots?: CultCacheUnreadSlots;
constructor(schemaId: string, schemaNames: string[], recordKey: string, message?: string) {
constructor(schemaId: string, schemaNames: string[], recordKey: string, message?: string, unreadSlots?: CultCacheUnreadSlots) {
super(message ?? `Schema id "${schemaId}" cannot describe record "${recordKey}": ${schemaNames.map((name) => `"${name}"`).join(", ")}.`);
this.name = "SchemaConflictError";
this.schemaId = schemaId;
this.schemaNames = schemaNames;
this.recordKey = recordKey;
this.unreadSlots = unreadSlots;
}
}
@ -151,3 +156,19 @@ export function requireStoreSlots(decoded: unknown[]): void {
throw new Error(`CultCache store has ${decoded.length} top-level slots; this runtime reads ${STORE_SLOTS} (header, schema catalog, records).`);
}
}
/** The schema id a record is stored under: the one it carries, else its type. */
export function schemaIdForEnvelope(entry: CultCacheEnvelope): string {
return entry.schemaId ?? entry.type;
}
/**
* 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.)
* The one predicate of the key-identity rule: the store judges the file with it and the cache judges what it holds.
*/
export function writtenOwns(written: CultCacheEnvelope, storedId: string): boolean {
const entry = written.catalogEntry;
return entry !== undefined && (entry.schemaId === storedId || (entry.compatibleSchemaIds ?? []).includes(storedId));
}

View file

@ -99,6 +99,26 @@ export interface CultCacheEnvelope {
catalogEntry?: CultCacheSchemaCatalogEntry;
}
/**
* What a payload decode found past the slots this build declares, at any depth: the first type read with extra slots, how many
* slots it carried against how many it declares, and how many objects in the record did. It holds no slot value, so a refusal
* can name it. A record carrying it is read-only unless `explained`.
*/
export interface CultCacheUnreadSlots {
/** The first type read with extra slots (the unit arm's arm name for a unit arm). */
readonly typeName: string;
readonly slotCount: number;
readonly declaredCount: number;
readonly occurrences: number;
/** True when every mark sat in the document's own object. */
readonly depth0Only: boolean;
/**
* True only when every mark is at depth 0 and the schema comparison of the id the record was stored under reported every slot
* the mark carries (slotCount is at most the highest reported slot plus one): the loss a compatible-id declaration consents to.
*/
readonly explained: boolean;
}
export interface PushAllOptions {
soft?: boolean;
}

View file

@ -128,7 +128,6 @@ export function readWire(payload: Uint8Array): unknown {
}
}
const tree = next();
if (offset !== payload.length) throw new Error("the MessagePack payload has bytes after its value.");
return tree;
// One value is read; the caller owns what follows it (decodeDocumentPayload refuses a payload with a tail).
return next();
}

View file

@ -33,8 +33,7 @@ const cultcacheRsRoot = existsSync(resolve(cultLibRoot, "packages", "cultcache-r
? resolve(cultLibRoot, "packages", "cultcache-rs")
: resolve(cultCacheTsRoot, "..", "cultcache-rs");
const rustInteropBinary = resolve(
cultcacheRsRoot,
"target",
process.env.CARGO_TARGET_DIR ?? resolve(cultcacheRsRoot, "target"),
"debug",
"examples",
process.platform === "win32" ? "cultcache_interop.exe" : "cultcache_interop",
@ -266,10 +265,10 @@ test("CultCache fails closed on unknown or illegal persisted polymorphic state",
]))
.build();
await assert.rejects(
async () => cacheWithUnknownType.pullAllBackingStores(),
/No schema is registered for persisted document type "forbidden"\./,
);
// A record no registered type claims is carried, not a reason to refuse the load.
await cacheWithUnknownType.pullAllBackingStores();
assert.deepEqual(cacheWithUnknownType.snapshot(), []);
assert.deepEqual(cacheWithUnknownType.foreignRecords().map((record) => [record.key, record.schemaName]), [["mystery", "forbidden"]]);
const cacheWithDuplicateGlobal = CultCache.builder()
.withDocumentType(settingsDocument)
@ -1019,6 +1018,7 @@ test("legacyKeyGlobalLoadsWithoutWritingAndFirstWriteLeavesOneGlobal", async ()
assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), { t: "new" });
// Deleting an adopted global removes the legacy record.
await rm(storePath);
await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy]);
const deleting = build();
await deleting.pullAllBackingStores();
@ -1026,10 +1026,30 @@ test("legacyKeyGlobalLoadsWithoutWritingAndFirstWriteLeavesOneGlobal", async ()
assert.deepEqual(await new SingleFileMessagePackBackingStore(storePath).pullAll(), []);
// Two globals of one type on disk, under any keys, are refused.
await rm(storePath);
await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy, { ...legacy, key: CultCache.GLOBAL_KEY }]);
await assert.rejects(build().pullAllBackingStores(), /has multiple persisted entries/u);
});
test("a flush of a cache that loaded a legacy-key global leaves the store reopenable", async () => {
const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ t: z.string() }), global: true });
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-legacy-flush-")), "generic.cc");
const store = new SingleFileMessagePackBackingStore(storePath);
const build = () => CultCache.builder().withRegistry(defineDocumentRegistry(settingsDocument)).withGenericStore(store).build();
const seed = CultCache.builder().withRegistry(defineDocumentRegistry(settingsDocument)).build();
await seed.putGlobal(settingsDocument, { t: "old" });
await store.push({ ...seed.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY), key: "legacy" });
const cache = build();
await cache.pullAllBackingStores();
assert.equal(cache.snapshot()[0]!.key, CultCache.GLOBAL_KEY, "the logical image names the global by the global key");
await store.pushAll(cache.storedSnapshot());
assert.deepEqual((await store.pullAll()).map((entry) => entry.key), ["legacy"], "the flush wrote the record under the key the store holds it");
const reopened = build();
await reopened.pullAllBackingStores();
assert.deepEqual(reopened.getRequiredGlobal(settingsDocument), { t: "old" });
});
test("concurrentPutAndAttachCannotLandRecordInNonHomeStore", async () => {
const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ t: z.string() }) });
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-put-attach-race-"));
@ -1252,6 +1272,48 @@ test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Pyth
}
});
// A Bifrost-shaped store (a union field, nested bytes) written by one of C# and Rust is opened by the other: C# reads through
// its store, Rust through the read-only snapshot door, which takes no lock and writes nothing. Leftover staging files from an
// interrupted write of either runtime sit beside the store; a read neither deletes them nor lets them change what it reads, and
// the store file's bytes are the same after every read.
test("A store written by C# or Rust with a union and nested bytes is read back equal by the other, read-only, past leftover staging files", async () => {
await buildInteropPeers();
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-shapes-"));
const rustRead = (file: string) => runJsonCommand("rust-read-shapes", rustInteropBinary, ["read-shapes", "--file", file], cultcacheRsRoot);
const csharpRead = (file: string) => runJsonCommand("csharp-read-shapes", dotnetCommand, [csharpInteropDll, "read-shapes", "--file", file], cultLibRoot);
const peers = [
{
writer: "csharp",
write: (file: string) => runJsonCommand("csharp-write-shapes", dotnetCommand, [csharpInteropDll, "write-shapes", "--file", file], cultLibRoot),
read: rustRead,
},
{
writer: "rust",
write: (file: string) => runJsonCommand("rust-write-shapes", rustInteropBinary, ["write-shapes", "--file", file], cultcacheRsRoot),
read: csharpRead,
},
];
for (const { writer, write, read } of peers) {
const file = join(tempDir, `${writer}.cc`);
const written = await write(file);
assert.equal(written.length, 2);
// The leftovers an interrupted write leaves: C# stages as `.<name>.<guid N>.tmp`, Rust as `<name>.<guid>.tmp`; both hold partial bytes.
const leftovers = [
join(tempDir, `.${writer}.cc.0123456789abcdef0123456789abcdef.tmp`),
join(tempDir, `${writer}.cc.00000000-0000-4000-8000-000000000000.tmp`),
];
for (const leftover of leftovers) {
await writeFile(leftover, Buffer.from([0x93, 0xb2]));
}
const before = await readFile(file);
assert.deepEqual(await read(file), written, `${writer}'s store did not read back equal`);
assert.deepEqual(await readFile(file), before, `reading ${writer}'s store changed its bytes`);
for (const leftover of leftovers) {
assert.ok(await exists(leftover), `reading ${writer}'s store removed ${leftover}`);
}
}
});
// Records and catalog entries a runtime does not own survive its writes. C# writes a store and each other runtime writes its own
// note into it: C# reads its note back, and its record and catalog entry are as C# wrote them. C# then writes again, and the other
// runtime's record and catalog entry are as that runtime wrote them. Identity is compared on the decoded MessagePack values, every
@ -2261,7 +2323,7 @@ test("a stamp this runtime reads, followed by bytes that are not UTF-8, gets the
}
});
test("pushAll leaves a store it reads alone when soft, and replaces one it reads otherwise", async () => {
test("pushAll leaves a store it reads alone when soft, and writes onto one it reads otherwise", async () => {
const vector = await readFile(resolve(cultLibRoot, "tests", "vectors", "document-variants-c2a", "v3-base.msgpack"));
const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" };
const existing = join(await mkdtemp(join(tmpdir(), "cultcache-pushall-soft-")), "store.msgpack");
@ -2273,7 +2335,7 @@ test("pushAll leaves a store it reads alone when soft, and replaces one it reads
const store = new SingleFileMessagePackBackingStore(own);
await store.pushAll([envelope]);
await store.pushAll([{ ...envelope, key: "second" }]);
assert.deepEqual((await store.pullAll()).map((entry) => entry.key), ["second"]);
assert.deepEqual((await store.pullAll()).map((entry) => entry.key), ["probe", "second"]);
});
test("SingleFileMessagePackBackingStore reads only a missing store as empty", async () => {
@ -2575,7 +2637,7 @@ test("SingleFileMessagePackBackingStore pushAll refuses a store whose header it
// A file is replaced by a rewrite exactly when this runtime's own reader opens it: one verdict per file, asked by pullAll,
// pushAll, soft pushAll, push and delete alike; a soft flush leaves the files that read as they are. The bytes and every runtime's verdict: tests/vectors/document-variants-c2a/readability.
test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", async () => {
test("SingleFileMessagePackBackingStore rewrites a file exactly when it reads", async () => {
const root = join(c2aVectors, "readability");
const rows = (await readFile(join(root, "manifest.txt"), "utf8"))
.split(/\r?\n/u)
@ -2612,6 +2674,7 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads",
? store.push(envelopes[0]!)
: store.delete(envelopes[0]!);
if (reads) {
const heldBefore = operation === "pushAll" ? (await new SingleFileMessagePackBackingStore(file).pullAll()).map((entry) => entry.key) : [];
await run();
if (operation === "pullAll") {
continue;
@ -2628,7 +2691,8 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads",
assert.equal((decode(await readFile(file)) as unknown[])[0], vector.includes("v3") ? "cultcache.store.v3" : "cultcache.store.v1", `${vector} ${operation}`);
const keys = (await new SingleFileMessagePackBackingStore(file).pullAll()).map((entry) => entry.key);
if (operation === "pushAll") {
assert.deepEqual(keys, envelopes.map((entry) => entry.key).sort(), `${vector} ${operation}`);
assert.deepEqual(envelopes.map((entry) => entry.key).filter((key) => !keys.includes(key)), [], `${vector} ${operation}: every written key is held`);
assert.deepEqual(heldBefore.filter((key) => !keys.includes(key)), [], `${vector} ${operation}: nothing else is removed`);
} else if (operation === "push") {
assert.ok(keys.includes(envelopes[0]!.key), `${vector} ${operation}`);
} else {
@ -2653,19 +2717,21 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads",
// 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" });
// One cache holds one record per key, so each envelope is made in a cache of its own.
const owner = CultCache.builder().withDocumentType(ownsId).build();
const lister = CultCache.builder().withDocumentType(listsId).build();
await owner.put(ownsId, "k", { name: "owner" });
await lister.put(listsId, "k", { name: "lister" });
const file = join(await mkdtemp(join(tmpdir(), "cultcache-push-key-")), "store.msgpack");
const store = new SingleFileMessagePackBackingStore(file);
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);
await store.push(lister.getRequiredEnvelope(listsId, "k"));
await assert.rejects(() => store.push(owner.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"));
await store.pushAll([lister.getRequiredEnvelope(listsId, "k")]);
await store.push(lister.getRequiredEnvelope(listsId, "k"));
assert.equal((await store.pullAll()).length, 1);
});

View file

@ -0,0 +1,377 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { test } from "node:test";
import { decode, encode } from "@msgpack/msgpack";
import { z } from "zod";
import { CultCache } from "../src/cult-cache";
import { defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document";
import { SingleFileMessagePackBackingStore, readSingleFileStore } from "../src/single-file-messagepack-backing-store";
import { SchemaConflictError } from "../src/store-format";
import type { AnyCultCacheDocumentDefinition, CultCacheEnvelope } from "../src/types";
// variants:ruling:cache-refuses-retype, write-ownership-by-written-type, unheld-removal-refused-at-commit. The vectors in
// tests/vectors/key-identity are written by the C# reference (see its README for the rule and the row shapes); this walks
// every row against the TS cache. A leaf keeps no verdict of its own.
function findVectors(from: string): string {
for (let dir = from; ; dir = resolve(dir, "..")) {
if (existsSync(join(dir, "tests", "vectors", "key-identity"))) return join(dir, "tests", "vectors", "key-identity");
if (resolve(dir, "..") === dir) throw new Error("tests/vectors/key-identity not found above the test.");
}
}
const vectors = findVectors(__dirname);
// One string slot [0: Text]; the schema ids are the ones C# wrote (README). The type is the schema name, the name a record
// read back from a store carries as its type (the store's delete matches on it).
function kiType(type: string, id: string) {
const schemaName = `tests.ki_${type}`;
return defineDocumentType({
type: schemaName,
schemaId: `sha256:${id}`,
schemaName,
schemaVersion: `${schemaName}.v1`,
schema: z.object({ text: z.string() }),
formatter: {
encode: (value: { text: string }) => encode([value.text]),
decode: (payload: Uint8Array) => ({ text: (decode(payload) as [string])[0] }),
},
});
}
const types: Record<string, AnyCultCacheDocumentDefinition> = {
alpha: kiType("alpha", "b20703a98f49c253898404ab50ba2c630720f0b8a5e1a41614d5650be22799fe"),
beta: kiType("beta", "dee4b727cf2ee04e61ab635ff0c5f3dca7345a411dc5f1018e583ab16009cf94"),
gamma: kiType("gamma", "fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d"),
};
type Op = [string, ...unknown[]];
type Row = [string, string[], Op[], [[string] | null, [string, string, string][], [string, string][] | null, string[], string[]]];
const compare = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
const textOf = (envelope: CultCacheEnvelope) => (decode(documentPayloadBytes(envelope.payload)) as [string])[0];
const stored = (path: string) => new Map(readSingleFileStore(path).envelopes.map((e) => [e.key, e]));
const sameRecord = (left: CultCacheEnvelope | undefined, right: CultCacheEnvelope | undefined) =>
left !== undefined && right !== undefined && left.schemaId === right.schemaId && left.storedAt === right.storedAt &&
Buffer.compare(Buffer.from(documentPayloadBytes(left.payload)), Buffer.from(documentPayloadBytes(right.payload))) === 0;
test("vectors rows.msgpack: every row's refusal, held records, file, foreign list and kept bytes", async () => {
const rows = decode(await readFile(join(vectors, "rows.msgpack"))) as Row[];
assert.equal(rows.length, 12);
let walked = 0;
const failures: string[] = [];
for (const [label, registered, ops, expect] of rows) {
const dir = await mkdtemp(join(tmpdir(), "ki-"));
try {
const [refusal, held, file, foreign, kept] = expect;
let counter = 0;
let path: string | undefined;
let cache!: CultCache;
let before: Map<string, CultCacheEnvelope> | undefined;
const attached: string[] = [];
const build = (store: string | undefined) => {
let builder = CultCache.builder();
for (const name of registered) builder = builder.withDocumentType(types[name]!);
if (store !== undefined) builder = builder.withGenericStore(new SingleFileMessagePackBackingStore(store));
return builder.build();
};
const fresh = async (name: string) => {
const copy = join(dir, `${counter++}-${name}`);
await copyFile(join(vectors, name), copy);
return copy;
};
let refused: unknown;
let failed = false;
let beforeBytes: Buffer | undefined;
let beforeCache: CultCacheEnvelope[] = [];
let beforeAttached: Buffer[] = [];
for (const [index, op] of ops.entries()) {
const last = index === ops.length - 1;
if (last) {
beforeCache = cache?.snapshot() ?? [];
beforeAttached = await Promise.all(attached.map((copy) => readFile(copy)));
beforeBytes = path !== undefined ? await readFile(path) : undefined;
}
try {
switch (op[0]) {
case "open": {
const arg = op[1] as string | null;
path = arg === "memory" ? undefined : arg === null ? join(dir, "fresh.cc") : await fresh(arg);
if (path !== undefined && arg !== null) before = stored(path);
cache = build(path);
await cache.pullAllBackingStores();
break;
}
case "attach": {
const copy = await fresh(op[1] as string);
attached.push(copy);
await cache.addBackingStore(new SingleFileMessagePackBackingStore(copy), ...(op[2] as string[]).map((name) => types[name]!));
await cache.pullAllBackingStores();
break;
}
case "reopen":
cache = build(path);
await cache.pullAllBackingStores();
break;
case "put":
await cache.put(types[op[1] as string]!, op[2] as string, { text: op[3] as string } as never);
break;
case "delete":
await cache.delete(types[op[1] as string]!, op[2] as string);
break;
default:
assert.fail(`${label}: unknown op ${op[0]}`);
}
} catch (error) {
if (!last) throw error;
refused = error;
failed = true;
}
}
if (refusal === null) {
assert.equal(failed, false, `${label}: the last op lands (${String(refused)})`);
} else {
assert.equal(failed, true, `${label}: the last op is refused`);
const [key] = refusal;
if (label === "key-in-two-stores-refused") {
// The key is read out of the refusal as a delimited token: the message opens "Record <key> is held by" and the key
// occurs nowhere else in it (not inside a schema name).
const message = (refused as Error).message;
const named = /^Record (\S+) is held by/u.exec(message);
assert.equal(named?.[1], key, `${label}: the refusal opens with the key`);
assert.equal(message.split(/[^A-Za-z0-9_.]+/u).filter((token) => token === key).length, 1, `${label}: the key occurs only as a delimited token`);
assert.deepEqual(cache.snapshot(), beforeCache, `${label}: the earlier pulled state survives`);
assert.deepEqual(await Promise.all(attached.slice(0, beforeAttached.length).map((copy) => readFile(copy))), beforeAttached, `${label}: the attached files are byte-identical`);
} else {
assert.ok(refused instanceof SchemaConflictError, `${label}: a typed schema conflict, got ${String(refused)}`);
assert.equal((refused as SchemaConflictError).recordKey, key, `${label}: names the key`);
assert.deepEqual(cache.snapshot(), beforeCache, `${label}: the cache is unchanged`);
if (path !== undefined) assert.deepEqual(await readFile(path), beforeBytes, `${label}: the store is byte-identical`);
}
}
assert.deepEqual(
cache.snapshot()
.map((e) => [e.type.replace("tests.ki_", ""), e.key, textOf(e)] as [string, string, string])
.sort((l, r) => compare(l[0], r[0]) || compare(l[1], r[1])),
held,
`${label}: held`,
);
if (path === undefined) {
assert.equal(file, null, `${label}: no store`);
} else {
assert.deepEqual(
[...stored(path).values()]
.map((e) => [e.key, e.catalogEntry?.schemaName ?? e.type] as [string, string])
.sort((l, r) => compare(l[0], r[0])),
file,
`${label}: file`,
);
if (before !== undefined) {
const after = stored(path);
assert.deepEqual(foreign.filter((key) => sameRecord(before!.get(key), after.get(key))), kept, `${label}: kept`);
}
}
assert.deepEqual(cache.foreignRecords().map((record: { key: string }) => record.key), foreign, `${label}: foreign`);
walked++;
} catch (error) {
failures.push(`${label}: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
assert.deepEqual(failures, [], "every row holds its verdict");
assert.equal(walked, 12);
});
// Beyond the vector: what the walker's string-typed rows cannot say.
const CANARY = "CANARY-ki-77c1";
const plain = (type: string, schemaId: string, compatibleSchemaIds?: string[]) =>
defineDocumentType({ type, schemaId, schemaName: type, schemaVersion: `${type}.v1`, schema: z.object({ v: z.string() }), ...(compatibleSchemaIds ? { compatibleSchemaIds } : {}) });
test("a refusal names the key and schemas and never echoes the written value", async () => {
const alpha = plain("cn.alpha", "cn.alpha.v1");
const beta = plain("cn.beta", "cn.beta.v1");
const cache = CultCache.builder().withDocumentType(alpha).withDocumentType(beta).build();
await cache.put(alpha, "k", { v: "first" });
await assert.rejects(cache.put(beta, "k", { v: CANARY }), (error: Error) => {
assert.ok(error instanceof SchemaConflictError && error.recordKey === "k");
assert.ok(!error.message.includes(CANARY));
return true;
});
assert.deepEqual(cache.get(alpha, "k"), { v: "first" });
assert.equal(cache.get(beta, "k"), undefined);
await assert.rejects(cache.putEnvelope(beta, { key: "k", type: "cn.beta", payload: documentPayloadFromBytes(encode({ v: CANARY })), storedAt: "2026-01-01T00:00:00Z" }), SchemaConflictError);
assert.equal(cache.snapshot().length, 1);
});
test("a type that owns the held record's id replaces it: one record at the key, no second type held", async () => {
const old = plain("cn.old", "cn.id.v1");
const renamed = plain("cn.new", "cn.id.v2", ["cn.id.v2", "cn.id.v1"]);
const cache = CultCache.builder().withDocumentType(old).withDocumentType(renamed).build();
await cache.put(old, "k", { v: "a" });
await cache.put(renamed, "k", { v: "b" });
assert.deepEqual(cache.snapshot().map((e) => [e.type, e.key]), [["cn.new", "k"]]);
assert.equal(cache.get(old, "k"), undefined);
});
test("a store carrying an unregistered record loads, lists it sorted, keeps it through writes of other keys, and a later registration claims it", async () => {
const dir = await mkdtemp(join(tmpdir(), "ki-"));
try {
const path = join(dir, "s.cc");
await copyFile(join(vectors, "foreign-store.cc"), path);
const open = (...names: string[]) => {
let builder = CultCache.builder();
for (const name of names) builder = builder.withDocumentType(types[name]!);
return builder.withGenericStore(new SingleFileMessagePackBackingStore(path)).build();
};
const cache = open("alpha");
await cache.pullAllBackingStores();
const record = stored(path).get("k")!;
assert.deepEqual(cache.foreignRecords(), [
{ key: "k", schemaId: "sha256:fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d", schemaName: "tests.ki_gamma", storedAt: record.storedAt },
]);
await cache.put(types.alpha!, "z", { text: "z" } as never);
await cache.delete(types.alpha!, "j");
assert.ok(sameRecord(stored(path).get("k"), record), "the foreign record's stored bytes survive");
assert.equal(await cache.delete(types.alpha!, "nobody"), false);
const claimed = open("alpha", "gamma");
await claimed.pullAllBackingStores();
assert.deepEqual(claimed.foreignRecords(), []);
assert.equal(claimed.snapshot().length, 2);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("a foreign key one store carries and another holds under a registered type refuses the load and admits nothing", async () => {
const dir = await mkdtemp(join(tmpdir(), "ki-"));
try {
const foreign = join(dir, "foreign.cc");
const catalog = join(dir, "catalog.cc");
await copyFile(join(vectors, "catalog-store.cc"), catalog);
const writer = CultCache.builder().withDocumentType(types.gamma!).withGenericStore(new SingleFileMessagePackBackingStore(foreign)).build();
await writer.put(types.gamma!, "qx", { text: "g" } as never);
const cache = CultCache.builder().withDocumentType(types.alpha!).build();
await cache.addBackingStore(new SingleFileMessagePackBackingStore(catalog), types.alpha!);
await cache.pullAllBackingStores();
await cache.addBackingStore(new SingleFileMessagePackBackingStore(foreign));
await assert.rejects(cache.pullAllBackingStores(), /held by two stores/u);
assert.deepEqual(cache.snapshot().map((e) => e.key), ["qx"], "the earlier pulled state survives");
assert.deepEqual(cache.foreignRecords(), []);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// The cache judge answers for what the cache holds whatever the store is: a store that never judges (it only records
// what it is asked) is asked nothing at a foreign key, and foreign records list by key ordinal.
test("a foreign key is refused by the cache before any store is asked, and foreign records list sorted", async () => {
const calls: string[] = [];
const record = (key: string, schemaId: string): CultCacheEnvelope => ({
key,
type: "unclaimed",
schemaId,
storedAt: "2026-01-01T00:00:00Z",
payload: documentPayloadFromBytes(encode(["x"])),
});
const store = {
pullAll: async () => [record("m", "unclaimed.m.v1"), record("b", "unclaimed.b.v1")],
push: async (entry: CultCacheEnvelope) => void calls.push(`push ${entry.key}`),
delete: async (entry: CultCacheEnvelope) => void calls.push(`delete ${entry.key}`),
};
const cache = CultCache.builder().withDocumentType(types.alpha!).withGenericStore(store).build();
await cache.pullAllBackingStores();
assert.deepEqual(cache.foreignRecords().map((r) => [r.key, r.schemaId, r.schemaName]), [["b", "unclaimed.b.v1", "unclaimed"], ["m", "unclaimed.m.v1", "unclaimed"]]);
await assert.rejects(cache.put(types.alpha!, "m", { text: CANARY } as never), (error: Error) => {
assert.ok(error instanceof SchemaConflictError && error.recordKey === "m");
assert.ok(!error.message.includes(CANARY));
return true;
});
await assert.rejects(cache.delete(types.alpha!, "b"), (error: Error) => error instanceof SchemaConflictError && error.recordKey === "b");
assert.equal(await cache.delete(types.alpha!, "nobody"), false);
assert.deepEqual(calls, []);
});
// The store's delete matches the record the file stores by its schema id, not by the type string: a record read back is
// typed by its schema's name, which a type of another name does not carry.
test("a delete removes the record the file stores although the type differs from the schema name, and the key retypes across a reload", async () => {
const dir = await mkdtemp(join(tmpdir(), "ki-"));
try {
const path = join(dir, "s.cc");
const named = (type: string, schemaName: string, schemaId: string) =>
defineDocumentType({ type, schemaId, schemaName, schemaVersion: `${schemaName}.v1`, schema: z.object({ v: z.string() }) });
const first = named("app.first", "wire.first", "wire.first.v1");
const second = named("app.second", "wire.second", "wire.second.v1");
const open = () => CultCache.builder().withDocumentType(first).withDocumentType(second).withGenericStore(new SingleFileMessagePackBackingStore(path)).build();
const writer = open();
await writer.put(first, "k", { v: "a" });
const reader = open();
await reader.pullAllBackingStores();
assert.equal(await reader.delete(first, "k"), true);
assert.deepEqual([...stored(path).keys()], [], "the record is gone from the file");
await reader.put(second, "k", { v: "b" });
assert.deepEqual([...stored(path).values()].map((e) => [e.key, e.catalogEntry?.schemaName]), [["k", "wire.second"]]);
const again = open();
await again.pullAllBackingStores();
assert.deepEqual(again.snapshot().map((e) => [e.type, e.key]), [["app.second", "k"]]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("an owned rename whose two types home in different stores is refused, and neither store changes", async () => {
const dir = await mkdtemp(join(tmpdir(), "ki-"));
try {
const oldPath = join(dir, "old.cc");
const newPath = join(dir, "new.cc");
const old = plain("rn.old", "rn.id.v1");
const renamed = plain("rn.new", "rn.id.v2", ["rn.id.v2", "rn.id.v1"]);
const cache = CultCache.builder().withDocumentType(old).withDocumentType(renamed).build();
await cache.addBackingStore(new SingleFileMessagePackBackingStore(oldPath), old);
await cache.addBackingStore(new SingleFileMessagePackBackingStore(newPath), renamed);
await cache.put(old, "k", { v: "a" });
const before = await readFile(oldPath);
await assert.rejects(cache.put(renamed, "k", { v: CANARY }), (error: Error) => {
assert.ok(error instanceof SchemaConflictError && error.recordKey === "k");
assert.ok(!error.message.includes(CANARY));
return true;
});
assert.deepEqual(await readFile(oldPath), before);
assert.equal(existsSync(newPath), false, "the new home was never written");
assert.deepEqual(cache.snapshot().map((e) => [e.type, e.key]), [["rn.old", "k"]]);
// Same home: the rename replaces.
const together = CultCache.builder().withDocumentType(old).withDocumentType(renamed).withGenericStore(new SingleFileMessagePackBackingStore(join(dir, "one.cc"))).build();
await together.put(old, "k", { v: "a" });
await together.put(renamed, "k", { v: "b" });
assert.deepEqual(together.snapshot().map((e) => [e.type, e.key]), [["rn.new", "k"]]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("two stores each carrying an unregistered record at one key refuse the load and admit nothing", async () => {
const dir = await mkdtemp(join(tmpdir(), "ki-"));
try {
const paths = [join(dir, "a.cc"), join(dir, "b.cc")];
for (const path of paths) {
const writer = CultCache.builder().withDocumentType(types.gamma!).withGenericStore(new SingleFileMessagePackBackingStore(path)).build();
await writer.put(types.gamma!, "k", { text: "g" } as never);
}
// Neither store's records are claimed: gamma is not registered here.
const cache = CultCache.builder().withDocumentType(types.alpha!).withDocumentType(types.beta!).build();
await cache.addBackingStore(new SingleFileMessagePackBackingStore(paths[0]!), types.alpha!);
await cache.addBackingStore(new SingleFileMessagePackBackingStore(paths[1]!), types.beta!);
await assert.rejects(cache.pullAllBackingStores(), /^Error: Record k is held by two stores/u);
assert.deepEqual(cache.foreignRecords(), []);
assert.deepEqual(cache.snapshot(), []);
} finally {
await rm(dir, { recursive: true, force: true });
}
});

View file

@ -119,6 +119,19 @@ test("a payload that ends early, is empty or carries trailing bytes is the reade
assert.doesNotThrow(() => checkPayloadDepth(bytes(0xc0)));
});
test("the walk returns where the first value ends, and undefined when the payload ends early", () => {
assert.equal(checkPayloadDepth(bytes(0xc0)), 1);
assert.equal(checkPayloadDepth(bytes(0xc0, 0xc1, 0xc1)), 1, "a top-level scalar ends at its own length; the tail is not judged");
assert.equal(checkPayloadDepth(bytes(0x92, 0x01, 0xa1, 0x61, 0xc0)), 5 - 1, "a container ends when its last child completes");
assert.equal(checkPayloadDepth(cat(bytes(0x90), arrays(L + 50))), 1, "the tail is never walked");
assert.equal(checkPayloadDepth(bytes()), undefined);
assert.equal(checkPayloadDepth(bytes(0x92, 0x01)), undefined);
assert.equal(checkPayloadDepth(bytes(0xd9)), undefined);
for (const cut of [[0xcc], [0xcb, 0x3f, 0xf8], [0xa5, 0x61, 0x62, 0x63], [0xc4, 0x02, 0x01], [0xd7, 0x01]]) {
assert.equal(checkPayloadDepth(bytes(...cut)), undefined, `a scalar cut short: ${cut.map((value) => value.toString(16)).join(" ")}`);
}
});
test("the walk is iterative: 100000 nested arrays are refused with the depth error, not a RangeError", () => {
refused(arrays(100_000), "100000 nested arrays");
refused(chain(bytes(0x81, 0x00), 100_000), "100000 nested maps");
@ -198,6 +211,13 @@ test("the inspector previews a payload at the limit and reports one past it, or
}
});
test("the inspector refuses bytes after a payload's first value with the shared clause and previews no tail", async () => {
const file = await storeWith(bytes(0xc0, 0xc1, 0xc1));
const record = inspectCultCacheBytes(file, await readFile(file)).records[0]!;
assert.equal(record.payloadDecodeError, "document payload has 2 bytes after its first MessagePack value.");
assert.deepEqual(record.payloadPreview, { bytes: 3, hexPrefix: "c0" });
});
// A header whose length or count needs all four bytes: the low sixteen bits of the length are small, the whole is not.
const be32 = (n: number) => bytes((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);

View file

@ -0,0 +1,461 @@
import assert from "node:assert/strict";
import { copyFile, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { decode, encode } from "@msgpack/msgpack";
import { z } from "zod";
import { codecs } from "../src";
import { CultCache } from "../src/cult-cache";
import { defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document";
import { SingleFileMessagePackBackingStore, readSingleFileStore } from "../src/single-file-messagepack-backing-store";
import { SchemaConflictError, StoreUnreadableError } from "../src/store-format";
import type { CacheBackingStore, CultCacheEnvelope, CultCacheUnreadSlots } from "../src/types";
import {
type Surface, envelopeOf, golden, hex, memoryStore, read, slotUnitDocument, surfaceCodec, surfaceDeclaringDocument, surfaceDocument, surfaceSchema, vectors,
} from "./union-surface-fixture";
// cultlib-gaps:cut:slot-readonly-ts. A payload carrying a slot past its reader's declared slots, at any depth, is read, held
// read-only, and every write or removal of its key is refused before a store is touched. The vectors are written by the C#
// reference: tests/vectors/union-parity/README.md, "Extra trailing slot" and "Slot stores".
const { slots, string } = codecs;
const SLOT_VALUE = "extra-slot";
function mark(typeName: string, slotCount: number, declaredCount: number, depth0Only: boolean, explained = false, occurrences = 1): CultCacheUnreadSlots {
return { typeName, slotCount, declaredCount, occurrences, depth0Only, explained };
}
// File, then the mark the README table gives for it.
const PAYLOAD_ROWS: [string, CultCacheUnreadSlots][] = [
["extra-slot-record.msgpack", mark("UnionSurface", 8, 7, true)],
["extra-slot-nested-record.msgpack", mark("Composition", 3, 2, false)],
["extra-slot-arm.msgpack", mark("Box", 3, 2, false)],
["extra-slot-list-element.msgpack", mark("Text", 3, 2, false)],
["depth-skipped-at-limit.msgpack", mark("UnionSurface", 8, 7, true)],
];
async function readOnlyFiles(name: string): Promise<string[]> {
const rows = decode(await read(name)) as unknown[][];
return rows.filter((row) => row[1] === "read-only").map((row) => row[0] as string);
}
test("the read-only payload vectors are exactly the ones this walker names: four in outcomes, one in bounds", async () => {
const listed = [...(await readOnlyFiles("outcomes.msgpack")), ...(await readOnlyFiles("bounds-outcomes.msgpack"))];
assert.deepEqual(listed.sort(), PAYLOAD_ROWS.map(([file]) => file).sort());
});
for (const [file, expected] of PAYLOAD_ROWS) {
test(`${file}: read past the extra slot, held read-only with the mark, every write and removal of the key refused`, async () => {
const document = surfaceDocument();
const entries = [envelopeOf(await read(file))];
const before = hex(documentPayloadBytes(entries[0]!.payload));
const cache = CultCache.builder().withDocumentType(document).withGenericStore(memoryStore(entries)).build();
await cache.pullAllBackingStores();
assert.deepEqual(cache.get(document, "surface"), golden, "the extra slot is read past");
assert.deepEqual(cache.unreadSlots(document, "surface"), expected);
assert.equal(cache.isWritable(document, "surface"), false);
assert.equal(cache.isWritable(document, "another"), true);
const refused = (error: unknown) => {
assert.ok(error instanceof SchemaConflictError, "a SchemaConflictError");
assert.deepEqual(error.unreadSlots, expected);
assert.equal(error.recordKey, "surface");
assert.ok(error.message.includes(expected.typeName), error.message);
assert.ok(error.message.includes(`read with ${expected.slotCount} slots where this build declares ${expected.declaredCount} (${expected.occurrences} object(s)`), error.message);
assert.ok(!error.message.includes(SLOT_VALUE), "the refusal never holds a slot value");
return true;
};
await assert.rejects(() => cache.put(document, "surface", golden), refused);
await assert.rejects(() => cache.update(document, "surface", (value) => ({ ...value!, version: 4 })), refused);
const replacement = envelopeOf(await read("union-surface.msgpack"));
await assert.rejects(() => cache.putEnvelope(document, replacement), refused);
await assert.rejects(() => cache.delete(document, "surface"), refused);
assert.equal(entries.length, 1, "nothing reached the store");
assert.equal(hex(documentPayloadBytes(entries[0]!.payload)), before, "the held bytes are untouched");
assert.deepEqual(cache.get(document, "surface"), golden, "the record is still held");
await cache.put(document, "another", golden);
assert.equal(entries.length, 2, "another key of the same store writes");
});
}
test("a clean payload and a short one are writable: missing trailing slots keep the defaults and mark nothing", async () => {
const document = surfaceDocument();
const short = encode([...(decode(await read("union-surface.msgpack")) as unknown[]).slice(0, 6)]);
const entries = [envelopeOf(await read("union-surface.msgpack")), { ...envelopeOf(short), key: "short" }];
const cache = CultCache.builder().withDocumentType(document).withGenericStore(memoryStore(entries)).build();
await cache.pullAllBackingStores();
for (const key of ["surface", "short"]) {
assert.equal(cache.unreadSlots(document, key), undefined);
assert.equal(cache.isWritable(document, key), true);
}
await cache.put(document, "short", golden);
});
test("a payload past the nesting limit is refused, never marked", async () => {
const entries = [envelopeOf(await read("depth-skipped-past-limit.msgpack"))];
const cache = CultCache.builder().withDocumentType(surfaceDocument()).withGenericStore(memoryStore(entries)).build();
await assert.rejects(() => cache.pullAllBackingStores(), /nesting|depth/iu);
});
test("putEnvelope of a payload carrying extra slots lands the bytes and holds the record read-only", async () => {
const document = surfaceDocument();
const entries: ReturnType<typeof envelopeOf>[] = [];
const cache = CultCache.builder().withDocumentType(document).withGenericStore(memoryStore(entries)).build();
const marked = envelopeOf(await read("extra-slot-nested-record.msgpack"));
await cache.putEnvelope(document, marked);
assert.equal(hex(documentPayloadBytes(entries[0]!.payload)), hex(await read("extra-slot-nested-record.msgpack")), "the bytes are carried");
assert.deepEqual(cache.unreadSlots(document, "surface"), mark("Composition", 3, 2, false));
assert.equal(cache.isWritable(document, "surface"), false);
await assert.rejects(() => cache.put(document, "surface", golden), SchemaConflictError);
});
test("a key a store carries foreign is not writable", async () => {
const document = surfaceDocument();
const foreign = { ...envelopeOf(await read("union-surface.msgpack")), key: "stranger", type: "tests.unknown", schemaId: "tests.unknown.v1" };
const cache = CultCache.builder().withDocumentType(document).withGenericStore(memoryStore([foreign])).build();
await cache.pullAllBackingStores();
assert.equal(cache.isWritable(document, "stranger"), false);
assert.equal(cache.isWritable(document, "other"), true);
});
test("a write by a type that owns the held record's id restamps it, and is refused when that record carries unread slots", async () => {
const typed = (type: string, schemaId: string, compatibleSchemaIds: string[] = []) =>
defineDocumentType({ type, schemaId, schemaName: type, schemaVersion: `${type}.v1`, compatibleSchemaIds, schema: surfaceSchema, codec: surfaceCodec, name: "id" });
const older = typed("tests.surface_older", "tests.surface_older.v1");
const newer = typed("tests.surface_newer", "tests.surface_newer.v1", ["tests.surface_older.v1"]);
for (const [file, lands] of [["union-surface.msgpack", true], ["extra-slot-record.msgpack", false]] as const) {
const held = { ...envelopeOf(await read(file)), type: older.type, schemaId: "tests.surface_older.v1" };
const cache = CultCache.builder().withDocumentType(older).withDocumentType(newer).withGenericStore(memoryStore([held])).build();
await cache.pullAllBackingStores();
assert.equal(cache.isWritable(newer, "surface"), lands, `${file}: isWritable agrees with the write by the type that owns the id`);
const write = cache.put(newer, "surface", golden);
if (lands) {
await write;
} else {
await assert.rejects(() => write, (error: unknown) => error instanceof SchemaConflictError && error.unreadSlots?.typeName === "UnionSurface");
}
}
});
// The store vectors. TS has no variant records: extra-slot-store.cc carries one (marked-variant), so the whole file is refused
// by name, and the walk runs over the file without that record.
async function copyOf(name: string): Promise<string> {
const path = join(await mkdtemp(join(tmpdir(), "slot-readonly-")), name);
await copyFile(join(vectors, name), path);
return path;
}
async function withoutVariant(path: string): Promise<void> {
const [format, catalog, records] = decode(await readFile(path)) as [unknown, unknown[], unknown[][]];
await writeFile(path, encode([format, catalog, records.filter((record) => record[0] !== "marked-variant")]));
}
type SlotRow = [string, string, boolean, boolean | null, string | null, number | null, number | null, number | null, string | null];
async function slotRows(): Promise<SlotRow[]> {
return decode(await read("slot-outcomes.msgpack")) as SlotRow[];
}
// The catalog entry the file publishes for a schema: its id and the reference's content hash, which a store rewrites with the
// definition's own.
async function catalogOf(file: string, schemaName: string): Promise<[string, string]> {
const [, catalog] = decode(await read(file)) as [unknown, string[][]];
const entry = catalog.find((candidate) => candidate[1] === schemaName)!;
return [entry[0]!, entry[3]!];
}
async function openStore(file: string, consent: boolean) {
const path = await copyOf(file);
if (file === "extra-slot-store.cc") await withoutVariant(path);
const surface = consent ? surfaceDeclaringDocument() : surfaceDocument(...(await catalogOf(file, "tests.union_surface")));
const slotUnit = consent ? undefined : slotUnitDocument(...(await catalogOf(file, "tests.slot_unit")));
const builder = CultCache.builder().withDocumentType(surface).withGenericStore(new SingleFileMessagePackBackingStore(path));
const cache = (slotUnit ? builder.withDocumentType(slotUnit) : builder).build();
await cache.pullAllBackingStores();
return { path, cache, surface, slotUnit };
}
test("extra-slot-store.cc carries a variant, which this runtime refuses by name; the rest of the file is walked", async () => {
const path = await copyOf("extra-slot-store.cc");
assert.throws(() => readSingleFileStore(path), StoreUnreadableError);
});
test("every slot-outcomes row holds: the mark, IsWritable, and the removal refused exactly where the record is not writable", async () => {
const rows = await slotRows();
assert.equal(rows.length, 8);
let walked = 0;
for (const [file, key, writable, explained, typeName, slots, declared, occurrences, flattenRefusedBy] of rows) {
if (flattenRefusedBy !== null) continue; // a variant: this runtime holds none
walked += 1;
const { path, cache, surface, slotUnit } = await openStore(file!, file === "extra-slot-consent.cc");
const document = key === "unit-arm" ? slotUnit! : surface;
const label = `${file} ${key}`;
const found = cache.unreadSlots(document, key!);
if (typeName === null) {
assert.equal(found, undefined, label);
} else {
assert.ok(found, label);
assert.deepEqual([found.explained, found.typeName, found.slotCount, found.declaredCount, found.occurrences], [explained, typeName, slots, declared, occurrences], label);
}
assert.equal(cache.isWritable(document, key!), writable, label);
const original = await readFile(path);
if (writable) {
assert.equal(await cache.delete(document, key!), true, label);
} else {
await assert.rejects(
() => cache.delete(document, key!),
(error: unknown) => error instanceof SchemaConflictError && error.unreadSlots?.typeName === typeName && !error.message.includes(SLOT_VALUE),
label,
);
assert.equal(Buffer.compare(original, await readFile(path)), 0, `${label}: the file is unchanged`);
}
}
assert.equal(walked, 7);
});
test("consent: a write of the explained record lands; the over-consent write is refused; the nested mark is refused", async () => {
const consented = await openStore("extra-slot-consent.cc", true);
const value = consented.cache.get(consented.surface, "consented") as Surface;
assert.deepEqual(value, golden);
await consented.cache.put(consented.surface, "consented", value);
assert.equal(consented.cache.unreadSlots(consented.surface, "consented"), undefined, "the write dropped the explained slot and the mark with it");
assert.equal(readSingleFileStore(consented.path).envelopes.find((e) => e.key === "consented")!.schemaId, "tests.union_surface.v2");
for (const key of ["over-consent", "nested"]) {
const refused = await openStore("extra-slot-consent.cc", true);
await assert.rejects(
() => refused.cache.put(refused.surface, key, golden),
(error: unknown) => error instanceof SchemaConflictError && error.schemaId === "tests.union_surface.wide",
`${key}: the refusal names the id the record was stored under`,
);
}
});
test("carried: after writing another record, the marked record's bytes, schema id, stored-at and every catalog entry are the originals", async () => {
const { path, cache, surface } = await openStore("extra-slot-store.cc", false);
const originals = readSingleFileStore(path);
await cache.put(surface, "clean", golden);
const after = readSingleFileStore(path);
for (const key of ["marked", "unit-arm"]) {
const was = originals.envelopes.find((e) => e.key === key)!;
const now = after.envelopes.find((e) => e.key === key)!;
assert.equal(hex(documentPayloadBytes(now.payload)), hex(documentPayloadBytes(was.payload)), key);
assert.equal(now.schemaId, was.schemaId, key);
assert.equal(now.storedAt, was.storedAt, key);
assert.deepEqual(now.catalogEntry, was.catalogEntry, key);
}
});
// A writer that adds a slot and keeps its schema id (TS ids are hand-written; C# ids hash the members): the reader of that
// same id has declared nothing, so it consents to nothing.
test("a wider writer under the reader's own schema id is read-only, and a real round trip does not shed the slot", async () => {
const doc = (columns: 1 | 2, compatibleSchemaIds: string[] = []) => defineDocumentType({
type: "tests.own_id",
schemaId: "tests.own_id.v1",
schemaName: "tests.own_id",
compatibleSchemaIds,
schema: z.object({ n: z.string(), extra: z.string().optional() }),
codec: columns === 2
? slots<{ n: string; extra: string }>({ 0: ["n", string], 1: ["extra", string] }, "Own")
: slots<{ n: string }>({ 0: ["n", string] }, "Own"),
name: "n",
});
const path = join(await mkdtemp(join(tmpdir(), "slot-readonly-")), "own.cc");
const wide = doc(2);
const writer = CultCache.builder().withDocumentType(wide).withGenericStore(new SingleFileMessagePackBackingStore(path)).build();
await writer.put(wide, "k", { n: "n1", extra: "kept" });
const bytes = await readFile(path);
for (const compatible of [[], ["tests.own_id.v1"]]) {
const reader = doc(1, compatible);
const cache = CultCache.builder().withDocumentType(reader).withGenericStore(new SingleFileMessagePackBackingStore(path)).build();
await cache.pullAllBackingStores();
assert.deepEqual(cache.unreadSlots(reader, "k"), mark("Own", 2, 1, true), "unexplained: the arrived id is the reader's own");
assert.equal(cache.isWritable(reader, "k"), false);
await assert.rejects(() => cache.put(reader, "k", { n: "n2" }), SchemaConflictError);
await assert.rejects(() => cache.delete(reader, "k"), SchemaConflictError);
assert.equal(Buffer.compare(bytes, await readFile(path)), 0, "the file still holds the slot");
}
});
test("a record that arrived under an id the reader does not list compatible is never consented to", async () => {
const path = await copyOf("extra-slot-consent.cc");
const reader = surfaceDocument("tests.union_surface.v2"); // resolves by type name; lists nothing
const cache = CultCache.builder().withDocumentType(reader).withGenericStore(new SingleFileMessagePackBackingStore(path)).build();
await cache.pullAllBackingStores();
assert.equal(cache.unreadSlots(reader, "consented")?.explained, false);
assert.equal(cache.isWritable(reader, "consented"), false);
});
// isWritable is the write's own judge, not a second one: for every key state it answers what put does.
test("isWritable equals the outcome of put for every key state the write judges", async () => {
const typed = (type: string, schemaId: string, extra: Record<string, unknown> = {}) =>
defineDocumentType({ type, schemaId, schemaName: type, schemaVersion: `${type}.v1`, schema: surfaceSchema, codec: surfaceCodec, name: "id", ...extra });
const older = typed("tests.surface_older", "tests.surface_older.v1");
const newer = typed("tests.surface_newer", "tests.surface_newer.v1", { compatibleSchemaIds: ["tests.surface_older.v1"] });
const stranger = typed("tests.surface_stranger", "tests.surface_stranger.v1");
const global = typed("tests.surface_global", "tests.surface_global.v1", { global: true });
const clean = (key: string, type: string, schemaId: string) => ({ ...envelopeOf(new Uint8Array()), key, type, schemaId });
const held = async (key: string, file: string, type: string, schemaId: string) =>
({ ...envelopeOf(await read(file)), key, type, schemaId });
type Row = { name: string; build: () => Promise<{ cache: CultCache; target: typeof older; key: string }> };
const rows: Row[] = [
{ name: "owned clean record in the writer's own store", build: async () => {
const cache = CultCache.builder().withDocumentType(older).withDocumentType(newer)
.withGenericStore(memoryStore([await held("k", "union-surface.msgpack", older.type, older.schemaId!)])).build();
return { cache, target: newer, key: "k" };
} },
{ name: "owned record held in a store other than the writer's home", build: async () => {
const cache = CultCache.builder().withDocumentType(older).withDocumentType(newer)
.withBackingStore(memoryStore([await held("k", "union-surface.msgpack", older.type, older.schemaId!)]), older)
.withBackingStore(memoryStore([]), newer).build();
return { cache, target: newer, key: "k" };
} },
{ name: "a record of a type that does not own the writer's id", build: async () => {
const cache = CultCache.builder().withDocumentType(older).withDocumentType(stranger)
.withGenericStore(memoryStore([await held("k", "union-surface.msgpack", older.type, older.schemaId!)])).build();
return { cache, target: stranger, key: "k" };
} },
{ name: "a global type at a key other than the global key", build: async () => {
const cache = CultCache.builder().withDocumentType(global).withGenericStore(memoryStore([])).build();
return { cache, target: global, key: "x" };
} },
{ name: "a key a store carries foreign", build: async () => {
const cache = CultCache.builder().withDocumentType(older)
.withGenericStore(memoryStore([{ ...clean("k", "tests.unknown", "tests.unknown.v1"), payload: documentPayloadFromBytes(await read("union-surface.msgpack")) }])).build();
return { cache, target: older, key: "k" };
} },
{ name: "a marked record of the writer's own type", build: async () => {
const cache = CultCache.builder().withDocumentType(older)
.withGenericStore(memoryStore([await held("k", "extra-slot-record.msgpack", older.type, older.schemaId!)])).build();
return { cache, target: older, key: "k" };
} },
{ name: "an unheld key", build: async () => ({ cache: CultCache.builder().withDocumentType(older).withGenericStore(memoryStore([])).build(), target: older, key: "k" }) },
];
const outcomes: [string, boolean, boolean][] = [];
for (const row of rows) {
const { cache, target, key } = await row.build();
await cache.pullAllBackingStores();
const predicted = cache.isWritable(target, key);
const landed = await cache.put(target, key === "x" ? "x" : key, golden).then(() => true, () => false);
assert.equal(predicted, landed, row.name);
outcomes.push([row.name, predicted, landed]);
}
assert.deepEqual(outcomes.map(([, writable]) => writable), [true, false, false, false, false, false, true]);
// The same judge refuses a removal of a key held read-only or carried foreign.
const removals = CultCache.builder().withDocumentType(older)
.withGenericStore(memoryStore([await held("k", "extra-slot-record.msgpack", older.type, older.schemaId!)])).build();
await removals.pullAllBackingStores();
await assert.rejects(() => removals.delete(older, "k"), SchemaConflictError);
const stranger2 = CultCache.builder().withDocumentType(older)
.withGenericStore(memoryStore([{ ...envelopeOf(await read("union-surface.msgpack")), key: "f", type: "tests.unknown", schemaId: "tests.unknown.v1" }])).build();
await stranger2.pullAllBackingStores();
await assert.rejects(() => stranger2.delete(older, "f"), SchemaConflictError);
});
// A removal is by key (C# Remove): the owner of a held record's id removes the one record that exists, in one store call and
// with the cache dropped only once the store has; a type that does not own it is refused like put, and isWritable agrees.
test("delete of a key held as another type: the owner removes it once, a stranger is refused, a failing store leaves cache and store agreeing", async () => {
const typed = (type: string, schemaId: string, extra: Record<string, unknown> = {}) =>
defineDocumentType({ type, schemaId, schemaName: type, schemaVersion: `${type}.v1`, schema: surfaceSchema, codec: surfaceCodec, name: "id", ...extra });
const older = typed("tests.surface_older", "tests.surface_older.v1");
const newer = typed("tests.surface_newer", "tests.surface_newer.v1", { compatibleSchemaIds: ["tests.surface_older.v1"] });
const stranger = typed("tests.surface_stranger", "tests.surface_stranger.v1");
const heldRecord = async (): Promise<CultCacheEnvelope> => ({ ...envelopeOf(await read("union-surface.msgpack")), key: "k", type: older.type, schemaId: older.schemaId });
const open = async (failing = false) => {
const rows = [await heldRecord()];
const calls: string[] = [];
const store: CacheBackingStore = {
async pullAll() { return rows.map((row) => ({ ...row })); },
async push(entry) { rows.push(entry); },
async delete(entry) {
calls.push(entry.key);
if (failing) throw new Error("store refused the removal");
rows.splice(rows.findIndex((row) => row.key === entry.key), 1);
},
};
const cache = CultCache.builder().withDocumentType(older).withDocumentType(newer).withDocumentType(stranger).withGenericStore(store).build();
await cache.pullAllBackingStores();
return { cache, rows, calls };
};
const owner = await open();
assert.equal(owner.cache.isWritable(newer, "k"), true);
assert.equal(await owner.cache.delete(newer, "k"), true, "the owner's removal removes the one record");
assert.deepEqual([owner.rows.length, owner.calls.length, owner.cache.get(older, "k")], [0, 1, undefined], "one store call, record gone from store and cache");
const refused = await open();
assert.equal(refused.cache.isWritable(stranger, "k"), false);
await assert.rejects(() => refused.cache.delete(stranger, "k"), SchemaConflictError);
assert.deepEqual([refused.rows.length, refused.calls.length, refused.cache.get(older, "k") !== undefined], [1, 0, true]);
const failing = await open(true);
await assert.rejects(() => failing.cache.delete(newer, "k"), /store refused/u);
assert.deepEqual([failing.rows.length, failing.calls.length, failing.cache.get(older, "k") !== undefined], [1, 1, true], "the store failed: neither side dropped it");
const same = await open();
assert.equal(await same.cache.delete(older, "k"), true);
assert.equal(await same.cache.delete(older, "k"), false, "an unheld key is still the plain false");
});
// The removal form's three finer behaviours (cultlib-gaps:finding:cut-slot-readonly-ts.s4.removal-form-unpinned).
test("an owner's delete: refused for a marked record, taken from the held record's store, and clears the held type's lookups", async () => {
const typed = (type: string, schemaId: string, extra: Record<string, unknown> = {}) =>
defineDocumentType({ type, schemaId, schemaName: type, schemaVersion: `${type}.v1`, schema: surfaceSchema, codec: surfaceCodec, name: "id", ...extra });
const older = typed("tests.surface_older", "tests.surface_older.v1", { indexes: { byVersion: (value: Surface) => String(value.version) } });
const newer = typed("tests.surface_newer", "tests.surface_newer.v1", { compatibleSchemaIds: ["tests.surface_older.v1"] });
const heldIn = async (file: string): Promise<CultCacheEnvelope> =>
({ ...envelopeOf(await read(file)), key: "k", type: older.type, schemaId: older.schemaId });
const recording = (rows: CultCacheEnvelope[]) => {
const removed: string[] = [];
const store: CacheBackingStore = {
async pullAll() { return rows.map((row) => ({ ...row })); },
async push(entry) { rows.push(entry); },
async delete(entry) { removed.push(entry.key); rows.splice(rows.findIndex((row) => row.key === entry.key), 1); },
};
return { store, removed, rows };
};
// 1. The owner may not remove a record carrying unread slots nothing consents to.
const marked = recording([await heldIn("extra-slot-record.msgpack")]);
const markedCache = CultCache.builder().withDocumentType(older).withDocumentType(newer).withGenericStore(marked.store).build();
await markedCache.pullAllBackingStores();
await assert.rejects(() => markedCache.delete(newer, "k"), (error: unknown) => error instanceof SchemaConflictError && error.unreadSlots?.typeName === "UnionSurface");
assert.deepEqual([marked.rows.length, marked.removed.length, markedCache.get(older, "k") !== undefined], [1, 0, true]);
// 2. The removal goes to the store of the record that exists, not the deleting type's; there the write form is false (it would
// push to the wrong store) while the removal succeeds.
const olderStore = recording([await heldIn("union-surface.msgpack")]);
const newerStore = recording([]);
const split = CultCache.builder().withDocumentType(older).withDocumentType(newer)
.withBackingStore(olderStore.store, older).withBackingStore(newerStore.store, newer).build();
await split.pullAllBackingStores();
assert.equal(split.isWritable(newer, "k"), false, "isWritable answers the write form: the rename cannot replace a record in another store");
assert.equal(await split.delete(newer, "k"), true);
assert.deepEqual([olderStore.removed, newerStore.removed, split.get(older, "k")], [["k"], [], undefined]);
// 3. The held type's name and index lookups are cleared with the record.
const single = recording([await heldIn("union-surface.msgpack")]);
const cache = CultCache.builder().withDocumentType(older).withDocumentType(newer).withGenericStore(single.store).build();
await cache.pullAllBackingStores();
assert.equal(cache.getKeyByName(older, "surface"), "k");
assert.equal(cache.getKeyByIndex(older, "byVersion", "3"), "k");
await cache.delete(newer, "k");
assert.equal(cache.getKeyByName(older, "surface"), undefined);
assert.equal(cache.getKeyByIndex(older, "byVersion", "3"), undefined);
assert.equal(cache.getAll(older).length, 0);
});
test("a global type at a key other than the global key: isWritable is false and delete is the plain false of an unheld key", async () => {
const global = defineDocumentType({ type: "tests.surface_global", schemaId: "tests.surface_global.v1", schemaName: "tests.surface_global", schemaVersion: "tests.surface_global.v1", schema: surfaceSchema, codec: surfaceCodec, name: "id", global: true });
const cache = CultCache.builder().withDocumentType(global).withGenericStore(memoryStore([])).build();
assert.equal(cache.isWritable(global, "x"), false);
assert.equal(await cache.delete(global, "x"), false);
});

View file

@ -670,7 +670,7 @@ test("delete removes only the record at that type and key", async () => {
}
});
test("pushAll replaces the whole store, and a soft pushAll leaves an existing store alone", async () => {
test("pushAll writes onto the store as it is: it replaces by key and removes nothing, and a soft pushAll leaves an existing store alone", async () => {
const directory = await scratch();
try {
const path = join(directory, "store.cc");
@ -681,7 +681,14 @@ test("pushAll replaces the whole store, and a soft pushAll leaves an existing st
await store.pushAll([entryOf("b", "t", 2)], { soft: true });
assert.deepEqual([...(await readFile(path))], [...before], "soft leaves an existing store byte for byte");
await store.pushAll([entryOf("b", "t", 2)]);
assert.deepEqual(await snapshotOf(store), ["t/b:2"]);
assert.deepEqual(await snapshotOf(store), ["t/a:1", "t/b:2"], "a record the batch does not name stays");
await store.pushAll([entryOf("a", "t", 7)]);
assert.deepEqual(await snapshotOf(store), ["t/a:7", "t/b:2"], "a record the batch names is replaced");
await store.pushAll([]);
assert.deepEqual(await snapshotOf(store), ["t/a:7", "t/b:2"], "an empty flush from a cache that holds nothing wipes nothing");
const before2 = [...(await readFile(path))];
await assert.rejects(() => store.pushAll([entryOf("a", "u", 1)]), SchemaConflictError);
assert.deepEqual([...(await readFile(path))], before2, "a batch that retypes a held key is refused and the file is unchanged");
} finally {
await rm(directory, { recursive: true, force: true });
}

View file

@ -1,8 +1,7 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { join } from "node:path";
import { test } from "node:test";
import { decode, encode } from "@msgpack/msgpack";
@ -22,190 +21,14 @@ import {
encodeDocumentPayload,
} from "../src/document";
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope } from "../src/types";
import type { AnyCultCacheDocumentDefinition, CultCacheEnvelope } from "../src/types";
import {
CANARY, type Node, type Surface, golden, hex, node, outcome, read, surfaceCodec, surfaceDocument, surfaceSchema, vectors, envelopeOf, memoryStore,
} from "./union-surface-fixture";
const { array, boolean, bytes, float32, float64, int32, int64, lazy, list, nullable, slots, string, union, unit } = codecs;
// The names the C# reference gives the fixture's types (CultSchemaTypeNames.FromType), as union-surface.cc carries them.
const CS = "GameCult.Caching.Tests.UnionParityVectorTests+";
// The golden is tests/vectors/union-parity, written by the C# reference. Everything below that is bytes or an expected
// outcome is read from those committed files; only the value the payload must decode to is spelled here.
function findVectors(from: string): string {
for (let dir = from; ; dir = resolve(dir, "..")) {
if (existsSync(join(dir, "tests", "vectors", "union-parity"))) return join(dir, "tests", "vectors", "union-parity");
if (resolve(dir, "..") === dir) throw new Error("tests/vectors/union-parity not found above the test.");
}
}
const vectors = findVectors(__dirname);
const read = async (name: string) => new Uint8Array(await readFile(join(vectors, name)));
// The string the C# writer put inside every refusal payload, to prove a refusal never echoes the payload.
const CANARY = "CANARY-union-golden-4c2a";
type Node =
| { kind: "Box"; id: string; children: (Node | null)[] }
| { kind: "Text"; id: string; content: string }
| { kind: "Use"; id: string; composition: Composition; slots: (Node | null)[] }
| { kind: "Image"; id: string; uri: string; alt: string }
| { kind: "Wrap"; inner: Node | null; id: string };
interface Composition { name: string; body: Node | null }
type Outcome = { kind: "Pending"; message: string } | { kind: "Completed"; sourceVersion: number } | { kind: "Denied" } | { kind: "Floor" } | { kind: "Ceiling" };
// The golden's Outcome: the arms of a list carry an element id, so Denied, Floor and Ceiling are one-slot records.
type GoldenOutcome =
| { kind: "Pending"; message: string; id: string }
| { kind: "Completed"; sourceVersion: number; id: string }
| { kind: "Denied"; id: string }
| { kind: "Floor"; id: string }
| { kind: "Ceiling"; id: string };
type Item = { kind: "Gem"; name: string } | { kind: "Tool"; name: string; uses: number } | { kind: "Part"; name: string; count: number };
type CraftedItem = { kind: "Tool"; name: string; uses: number } | { kind: "Part"; name: string; count: number };
interface Surface {
id: string;
version: number;
root: Node | null;
outcomes: (GoldenOutcome | null)[];
part: Item | null;
crafted: CraftedItem | null;
missing: Node | null;
}
const composition = slots<Composition>({ 0: ["name", string], 1: ["body", lazy(() => node)] }, `${CS}Composition`);
const node: Codec<Node | null> = union<Node>("Node", {
0: ["Box", slots<{ id: string; children: (Node | null)[] }>({ 0: ["id", string], 1: ["children", list(lazy(() => node))] })],
1: ["Text", slots<{ id: string; content: string }>({ 0: ["id", string], 1: ["content", string] })],
2: ["Use", slots<{ id: string; composition: Composition; slots: (Node | null)[] }>({
0: ["id", string], 1: ["composition", composition], 2: ["slots", list(lazy(() => node))],
})],
5: ["Image", slots<{ id: string; uri: string; alt: string }>({ 0: ["id", string], 1: ["uri", string], 3: ["alt", string] })],
6: ["Wrap", slots<{ inner: Node | null; id: string }>({ 0: ["inner", lazy(() => node)], 1: ["id", string] })],
}, `${CS}Node`);
const outcome = union<Outcome>("Outcome", {
0: ["Pending", slots<{ message: string }>({ 0: ["message", string] })],
1: ["Completed", slots<{ sourceVersion: number }>({ 0: ["sourceVersion", int32] })],
2: ["Denied", unit],
[-2147483648]: ["Floor", unit],
2147483647: ["Ceiling", unit],
}, `${CS}Outcome`);
const idOnly = slots<{ id: string }>({ 0: ["id", string] });
const goldenOutcome = union<GoldenOutcome>("Outcome", {
0: ["Pending", slots<{ message: string; id: string }>({ 0: ["message", string], 1: ["id", string] })],
1: ["Completed", slots<{ sourceVersion: number; id: string }>({ 0: ["sourceVersion", int32], 1: ["id", string] })],
2: ["Denied", idOnly],
[-2147483648]: ["Floor", idOnly],
2147483647: ["Ceiling", idOnly],
}, `${CS}Outcome`);
const toolArm = slots<{ name: string; uses: number }>({ 0: ["name", string], 1: ["uses", int32] });
const partArm = slots<{ name: string; count: number }>({ 0: ["name", string], 1: ["count", int32] });
const item = union<Item>("Item", {
0: ["Gem", slots<{ name: string }>({ 0: ["name", string] })],
1: ["Tool", toolArm],
2: ["Part", partArm],
}, `${CS}Item`);
const craftedItem = union<CraftedItem>("CraftedItem", { 0: ["Tool", toolArm], 1: ["Part", partArm] }, `${CS}CraftedItem`);
const surfaceCodec = slots<Surface>({
0: ["id", string],
1: ["version", int32],
2: ["root", node],
3: ["outcomes", list(goldenOutcome)],
4: ["part", item],
5: ["crafted", craftedItem],
6: ["missing", node, null],
}, `${CS}UnionSurface`);
const nodeSchema: z.ZodType<Node | null> = z.lazy(() =>
z.union([
z.null(),
z.discriminatedUnion("kind", [
z.object({ kind: z.literal("Box"), id: z.string(), children: z.array(nodeSchema) }),
z.object({ kind: z.literal("Text"), id: z.string(), content: z.string() }),
z.object({
kind: z.literal("Use"),
id: z.string(),
composition: z.object({ name: z.string(), body: nodeSchema }),
slots: z.array(nodeSchema),
}),
z.object({ kind: z.literal("Image"), id: z.string(), uri: z.string(), alt: z.string() }),
z.object({ kind: z.literal("Wrap"), inner: nodeSchema, id: z.string() }),
]),
]));
const surfaceSchema = z.object({
id: z.string(),
version: z.number(),
root: nodeSchema,
outcomes: z.array(z.union([
z.null(),
z.discriminatedUnion("kind", [
z.object({ kind: z.literal("Pending"), message: z.string(), id: z.string() }),
z.object({ kind: z.literal("Completed"), sourceVersion: z.number(), id: z.string() }),
z.object({ kind: z.literal("Denied"), id: z.string() }),
z.object({ kind: z.literal("Floor"), id: z.string() }),
z.object({ kind: z.literal("Ceiling"), id: z.string() }),
]),
])),
part: z.union([z.null(), z.discriminatedUnion("kind", [
z.object({ kind: z.literal("Gem"), name: z.string() }),
z.object({ kind: z.literal("Tool"), name: z.string(), uses: z.number() }),
z.object({ kind: z.literal("Part"), name: z.string(), count: z.number() }),
])]),
crafted: z.union([z.null(), z.discriminatedUnion("kind", [
z.object({ kind: z.literal("Tool"), name: z.string(), uses: z.number() }),
z.object({ kind: z.literal("Part"), name: z.string(), count: z.number() }),
])]),
missing: nodeSchema,
}) as unknown as z.ZodType<Surface>;
function surfaceDocument(schemaId?: string) {
return defineDocumentType({
type: "tests.union_surface",
schemaId: schemaId ?? "tests.union_surface.v1",
schemaName: "tests.union_surface",
schemaVersion: "tests.union_surface.v1",
schema: surfaceSchema,
codec: surfaceCodec,
name: "id",
});
}
const golden: Surface = {
id: "surface",
version: 3,
root: {
kind: "Box",
id: "0000000000c0",
children: [
{ kind: "Text", id: "0000000000c1", content: "hello" },
{
kind: "Use",
id: "0000000000c2",
composition: {
name: "comp",
body: {
kind: "Box",
id: "0000000000d0",
children: [
{ kind: "Text", id: "0000000000d1", content: "x" },
{ kind: "Image", id: "0000000000d2", uri: "uri://a", alt: "alt" },
],
},
},
slots: [{ kind: "Text", id: "0000000000e1", content: "slot" }, null, { kind: "Box", id: "0000000000e2", children: [] }],
},
{ kind: "Image", id: "0000000000c3", uri: "uri://b", alt: "" },
],
},
outcomes: [
{ kind: "Pending", message: "wait", id: "0000000000a1" },
{ kind: "Completed", sourceVersion: 7, id: "0000000000a2" },
{ kind: "Denied", id: "0000000000a3" },
],
part: { kind: "Tool", name: "hammer", uses: 12 },
crafted: { kind: "Part", name: "gear", count: 4 },
missing: null,
};
const hex = (data: Uint8Array) => Buffer.from(data).toString("hex");
async function outcomeEntries() {
const entries = decode(await read("outcomes.msgpack")) as [string, string, string, number | null, string][];
return entries.map(([file, outcome, unionName, key, detail]) => ({ file, outcome, unionName, key, detail }));
@ -515,7 +338,7 @@ test("the wire reader keeps 64-bit integers whole, tells a float from an integer
assert.throws(() => readWire(fromHex("dd")), /truncated/u);
assert.throws(() => readWire(fromHex("ddffffffff")), /truncated/u, "a huge array header reserves nothing");
assert.throws(() => readWire(fromHex("a5616263")), /truncated/u);
assert.throws(() => readWire(fromHex("0102")), /bytes after its value/u);
assert.equal(readWire(fromHex("0102")), 1, "the reader reads one value; the document owner refuses what follows");
assert.throws(() => readWire(fromHex("81a161a162")), /map, extension or reserved/u);
assert.throws(() => readWire(fromHex("c1")), /map, extension or reserved/u);
assert.throws(() => readWire(fromHex("d4010a")), /map, extension or reserved/u);
@ -729,19 +552,6 @@ test("the Int32 edges of an arm key: the largest is an arm, one past either end
assert.throws(() => edge.decode(readWire(fromHex("92d3ffffffff8000000090"))), { message: "Union U: unknown arm key -2147483648 (declared keys: 2147483647)." });
});
function envelopeOf(payload: Uint8Array): CultCacheEnvelope {
return { key: "surface", type: "tests.union_surface", payload: documentPayloadFromBytes(payload), storedAt: new Date().toISOString() };
}
function memoryStore(entries: CultCacheEnvelope[]): CacheBackingStore & { entries: CultCacheEnvelope[] } {
return {
entries,
async pullAll() { return entries.map((entry) => ({ ...entry })); },
async push(entry) { entries.push(entry); },
async delete() {},
};
}
test("arm keys span Int32 and are listed ascending; slot keys are non-negative Int32", () => {
const keys = union<{ kind: "High" | "Zero" | "Neg" | "Low" }>("U", {
2147483647: ["High", unit],
@ -915,3 +725,114 @@ test("a cache load refuses a store whose record is past the limit, naming the ty
const refusing = CultCache.builder().withDocumentType(document).withGenericStore(memoryStore([envelopeOf(await read("depth-past-limit.msgpack"))])).build();
await assert.rejects(() => refusing.pullAllBackingStores(), (error: Error) => error instanceof CultCachePayloadDepthError && !error.message.includes(CANARY));
});
// ---- bytes-after vectors: a payload is exactly one MessagePack value (tests/vectors/union-parity/README.md) ----
// The C# reference writes the vectors: union-surface.msgpack followed by a tail the first value does not own. The one
// owner refuses by count, naming the type, from the walk's end; it never walks or echoes the tail.
async function bytesAfterEntries() {
const raw = decode(await read("bytes-after-outcomes.msgpack")) as unknown[][];
return raw.map(([file, outcome, unionName, key, detail]) => ({ file: file as string, outcome: outcome as string, unionName: unionName as string, key: key as number | null, detail: detail as string }));
}
// The C# vector writer's canary (BytesAfterVectorTests): it sits in the deep tail, so an echo of the tail would carry it.
const TAIL_CANARY = "CANARY-bytes-after-7d1e";
const valueLength = async () => (await read("union-surface.msgpack")).length;
const plainDocument = () => defineDocumentType({ type: "tests.plain_surface", schema: z.unknown() });
const codecDocument = () => defineDocumentType({ type: "tests.codec_surface", schema: surfaceSchema, codec: surfaceCodec });
async function assertBytesAfterRefused(file: string, tail: number, run: () => unknown | Promise<unknown>, type: string) {
const clause = `${type}`;
await assert.rejects(
async () => run(),
(error: Error) => {
assert.ok(!(error instanceof CultCachePayloadDepthError), `${file}: the tail was walked`);
assert.ok(!error.message.includes(CANARY) && !error.message.includes(TAIL_CANARY), `${file}: the refusal echoed the tail`);
assert.ok(!error.message.includes("the limit is"), `${file}: the depth refusal`);
assert.ok(error.message.includes(clause), `${file}: names the type: ${error.message}`);
assert.ok(error.message.includes(`${tail} bytes after its first MessagePack value`), `${file}: names the count: ${error.message}`);
return true;
},
file,
);
}
test("every bytes-after vector is refused by the one owner, by count, on the codec, plain and store paths", async () => {
const entries = await bytesAfterEntries();
assert.equal(entries.length, 4);
const length = await valueLength();
const surface = surfaceDocument();
const plain = plainDocument();
const codecOnly = codecDocument();
const dir = await mkdtemp(join(tmpdir(), "bytes-after-"));
for (const { file, outcome, unionName, detail } of entries) {
assert.equal(outcome, "refused");
assert.equal(unionName, "UnionSurface");
assert.equal(detail, "bytes-after");
const bytes = await read(file);
const tail = bytes.length - length;
assert.ok(tail > 0, file);
if (file === "bytes-after-deep-tail.msgpack") assert.ok(Buffer.from(bytes).includes(TAIL_CANARY), "the deep tail carries the canary the assertions look for");
const payload = documentPayloadFromBytes(bytes);
await assertBytesAfterRefused(file, tail, () => decodeDocumentPayload(surface, payload), surface.type);
await assertBytesAfterRefused(file, tail, () => decodeDocumentPayload(plain, payload), plain.type);
await assertBytesAfterRefused(file, tail, () => decodeDocumentPayload(codecOnly, payload), codecOnly.type);
// The store path: a single-file store holding the vector as a record payload refuses on load.
const path = join(dir, `${file}.cc`);
await new SingleFileMessagePackBackingStore(path).push(envelopeOf(bytes));
const cache = CultCache.builder().withDocumentType(surface).withGenericStore(new SingleFileMessagePackBackingStore(path)).build();
await assertBytesAfterRefused(file, tail, () => cache.pullAllBackingStores(), surface.type);
// The put path: an envelope arriving with the tail is refused and held nowhere.
const written: CultCacheEnvelope[] = [];
const held = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore(written)).build();
await assertBytesAfterRefused(file, tail, () => held.putEnvelope(surface, envelopeOf(bytes)), surface.type);
assert.equal(written.length, 0, `${file}: nothing reached the store`);
assert.equal(held.get(surface, "surface"), undefined, `${file}: nothing is held in memory`);
}
});
test("a payload with an unread slot and a tail is refused by count before any slot is marked or the record held", async () => {
const withSlot = await read("extra-slot-record.msgpack");
const surface = surfaceDocument();
const tailed = Uint8Array.from([...withSlot, 0xc0]);
const whole = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore([envelopeOf(withSlot)])).build();
await whole.pullAllBackingStores();
assert.ok(whole.unreadSlots(surface, "surface"), "without a tail the record is held with its unread slots");
const loading = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore([envelopeOf(tailed)])).build();
await assertBytesAfterRefused("extra-slot-record+tail", 1, () => loading.pullAllBackingStores(), surface.type);
assert.equal(loading.get(surface, "surface"), undefined);
assert.equal(loading.unreadSlots(surface, "surface"), undefined);
const written: CultCacheEnvelope[] = [];
const putting = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore(written)).build();
await assertBytesAfterRefused("extra-slot-record+tail", 1, () => putting.putEnvelope(surface, envelopeOf(tailed)), surface.type);
assert.equal(written.length, 0);
assert.equal(putting.get(surface, "surface"), undefined);
assert.equal(putting.unreadSlots(surface, "surface"), undefined);
});
test("a payload that ends early is not bytes after its value: the decoder refuses it, and a user formatter's bytes are not walked", async () => {
const whole = await read("union-surface.msgpack");
const surface = surfaceDocument();
for (const cut of [0, 1, 10, whole.length - 1]) {
assert.throws(
() => decodeDocumentPayload(surface, documentPayloadFromBytes(whole.slice(0, cut))),
(error: Error) => !error.message.includes("bytes after") && !(error instanceof CultCachePayloadDepthError),
`truncated at ${cut}`,
);
}
// A header cut short of its length is an early end too, whatever width it declares.
for (const early of ["dc00", "dd000000", "de00", "c4", "d9", "a5616263", "9291"]) {
assert.throws(
() => decodeDocumentPayload(surface, documentPayloadFromBytes(fromHex(early))),
(error: Error) => !error.message.includes("bytes after") && !(error instanceof CultCachePayloadDepthError),
`early end ${early}`,
);
}
const opaque = defineDocumentType({ type: "tests.opaque", schema: z.unknown(), formatter: { encode: (value: Uint8Array) => value, decode: (bytes: Uint8Array) => bytes } });
const tailed = await read("bytes-after-nil.msgpack");
assert.equal((decodeDocumentPayload(opaque, documentPayloadFromBytes(tailed)) as Uint8Array).length, tailed.length);
});

View file

@ -0,0 +1,240 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { z } from "zod";
import { codecs } from "../src";
import type { Codec } from "../src/codec";
import { defineDocumentType, documentPayloadFromBytes } from "../src/document";
import type { CacheBackingStore, CultCacheEnvelope } from "../src/types";
// The union-surface fixture: the codecs, schema and value tests/vectors/union-parity was written from, shared by the
// union-parity walker and the slot-readonly walker so the shape is spelled once.
const { list, int32, lazy, slots, string, union, unit } = codecs;
// The names the C# reference gives the fixture's types (CultSchemaTypeNames.FromType), as union-surface.cc carries them.
export const CS = "GameCult.Caching.Tests.UnionParityVectorTests+";
// The golden is tests/vectors/union-parity, written by the C# reference. Everything below that is bytes or an expected
// outcome is read from those committed files; only the value the payload must decode to is spelled here.
export function findVectors(from: string): string {
for (let dir = from; ; dir = resolve(dir, "..")) {
if (existsSync(join(dir, "tests", "vectors", "union-parity"))) return join(dir, "tests", "vectors", "union-parity");
if (resolve(dir, "..") === dir) throw new Error("tests/vectors/union-parity not found above the test.");
}
}
export const vectors = findVectors(__dirname);
export const read = async (name: string) => new Uint8Array(await readFile(join(vectors, name)));
// The string the C# writer put inside every refusal payload, to prove a refusal never echoes the payload.
export const CANARY = "CANARY-union-golden-4c2a";
export type Node =
| { kind: "Box"; id: string; children: (Node | null)[] }
| { kind: "Text"; id: string; content: string }
| { kind: "Use"; id: string; composition: Composition; slots: (Node | null)[] }
| { kind: "Image"; id: string; uri: string; alt: string }
| { kind: "Wrap"; inner: Node | null; id: string };
export interface Composition { name: string; body: Node | null }
export type Outcome = { kind: "Pending"; message: string } | { kind: "Completed"; sourceVersion: number } | { kind: "Denied" } | { kind: "Floor" } | { kind: "Ceiling" };
// The golden's Outcome: the arms of a list carry an element id, so Denied, Floor and Ceiling are one-slot records.
export type GoldenOutcome =
| { kind: "Pending"; message: string; id: string }
| { kind: "Completed"; sourceVersion: number; id: string }
| { kind: "Denied"; id: string }
| { kind: "Floor"; id: string }
| { kind: "Ceiling"; id: string };
export type Item = { kind: "Gem"; name: string } | { kind: "Tool"; name: string; uses: number } | { kind: "Part"; name: string; count: number };
export type CraftedItem = { kind: "Tool"; name: string; uses: number } | { kind: "Part"; name: string; count: number };
export interface Surface {
id: string;
version: number;
root: Node | null;
outcomes: (GoldenOutcome | null)[];
part: Item | null;
crafted: CraftedItem | null;
missing: Node | null;
}
export const composition = slots<Composition>({ 0: ["name", string], 1: ["body", lazy(() => node)] }, `${CS}Composition`);
export const node: Codec<Node | null> = union<Node>("Node", {
0: ["Box", slots<{ id: string; children: (Node | null)[] }>({ 0: ["id", string], 1: ["children", list(lazy(() => node))] }, `${CS}Box`)],
1: ["Text", slots<{ id: string; content: string }>({ 0: ["id", string], 1: ["content", string] }, `${CS}Text`)],
2: ["Use", slots<{ id: string; composition: Composition; slots: (Node | null)[] }>({
0: ["id", string], 1: ["composition", composition], 2: ["slots", list(lazy(() => node))],
})],
5: ["Image", slots<{ id: string; uri: string; alt: string }>({ 0: ["id", string], 1: ["uri", string], 3: ["alt", string] })],
6: ["Wrap", slots<{ inner: Node | null; id: string }>({ 0: ["inner", lazy(() => node)], 1: ["id", string] })],
}, `${CS}Node`);
export const outcome = union<Outcome>("Outcome", {
0: ["Pending", slots<{ message: string }>({ 0: ["message", string] })],
1: ["Completed", slots<{ sourceVersion: number }>({ 0: ["sourceVersion", int32] })],
2: ["Denied", unit],
[-2147483648]: ["Floor", unit],
2147483647: ["Ceiling", unit],
}, `${CS}Outcome`);
export const idOnly = slots<{ id: string }>({ 0: ["id", string] });
export const goldenOutcome = union<GoldenOutcome>("Outcome", {
0: ["Pending", slots<{ message: string; id: string }>({ 0: ["message", string], 1: ["id", string] })],
1: ["Completed", slots<{ sourceVersion: number; id: string }>({ 0: ["sourceVersion", int32], 1: ["id", string] })],
2: ["Denied", idOnly],
[-2147483648]: ["Floor", idOnly],
2147483647: ["Ceiling", idOnly],
}, `${CS}Outcome`);
export const toolArm = slots<{ name: string; uses: number }>({ 0: ["name", string], 1: ["uses", int32] });
export const partArm = slots<{ name: string; count: number }>({ 0: ["name", string], 1: ["count", int32] });
export const item = union<Item>("Item", {
0: ["Gem", slots<{ name: string }>({ 0: ["name", string] })],
1: ["Tool", toolArm],
2: ["Part", partArm],
}, `${CS}Item`);
export const craftedItem = union<CraftedItem>("CraftedItem", { 0: ["Tool", toolArm], 1: ["Part", partArm] }, `${CS}CraftedItem`);
export const surfaceCodecOf = (typeName: string) => slots<Surface>({
0: ["id", string],
1: ["version", int32],
2: ["root", node],
3: ["outcomes", list(goldenOutcome)],
4: ["part", item],
5: ["crafted", craftedItem],
6: ["missing", node, null],
}, typeName);
export const surfaceCodec = surfaceCodecOf(`${CS}UnionSurface`);
export const nodeSchema: z.ZodType<Node | null> = z.lazy(() =>
z.union([
z.null(),
z.discriminatedUnion("kind", [
z.object({ kind: z.literal("Box"), id: z.string(), children: z.array(nodeSchema) }),
z.object({ kind: z.literal("Text"), id: z.string(), content: z.string() }),
z.object({
kind: z.literal("Use"),
id: z.string(),
composition: z.object({ name: z.string(), body: nodeSchema }),
slots: z.array(nodeSchema),
}),
z.object({ kind: z.literal("Image"), id: z.string(), uri: z.string(), alt: z.string() }),
z.object({ kind: z.literal("Wrap"), inner: nodeSchema, id: z.string() }),
]),
]));
export const surfaceSchema = z.object({
id: z.string(),
version: z.number(),
root: nodeSchema,
outcomes: z.array(z.union([
z.null(),
z.discriminatedUnion("kind", [
z.object({ kind: z.literal("Pending"), message: z.string(), id: z.string() }),
z.object({ kind: z.literal("Completed"), sourceVersion: z.number(), id: z.string() }),
z.object({ kind: z.literal("Denied"), id: z.string() }),
z.object({ kind: z.literal("Floor"), id: z.string() }),
z.object({ kind: z.literal("Ceiling"), id: z.string() }),
]),
])),
part: z.union([z.null(), z.discriminatedUnion("kind", [
z.object({ kind: z.literal("Gem"), name: z.string() }),
z.object({ kind: z.literal("Tool"), name: z.string(), uses: z.number() }),
z.object({ kind: z.literal("Part"), name: z.string(), count: z.number() }),
])]),
crafted: z.union([z.null(), z.discriminatedUnion("kind", [
z.object({ kind: z.literal("Tool"), name: z.string(), uses: z.number() }),
z.object({ kind: z.literal("Part"), name: z.string(), count: z.number() }),
])]),
missing: nodeSchema,
}) as unknown as z.ZodType<Surface>;
// The reference's SurfaceDeclaring: the same layout, schema tests.union_surface.v2, listing the older wide id compatible.
export function surfaceDeclaringDocument() {
return defineDocumentType({
type: "tests.union_surface",
schemaId: "tests.union_surface.v2",
schemaName: "tests.union_surface",
schemaVersion: "tests.union_surface.v2",
compatibleSchemaIds: ["tests.union_surface.wide"],
schema: surfaceSchema,
codec: surfaceCodecOf(`${CS}SurfaceDeclaring`),
name: "id",
});
}
export function surfaceDocument(schemaId?: string, contentHash?: string) {
return defineDocumentType({
...(contentHash === undefined ? {} : { contentHash }),
type: "tests.union_surface",
schemaId: schemaId ?? "tests.union_surface.v1",
schemaName: "tests.union_surface",
schemaVersion: "tests.union_surface.v1",
schema: surfaceSchema,
codec: surfaceCodec,
name: "id",
});
}
export const golden: Surface = {
id: "surface",
version: 3,
root: {
kind: "Box",
id: "0000000000c0",
children: [
{ kind: "Text", id: "0000000000c1", content: "hello" },
{
kind: "Use",
id: "0000000000c2",
composition: {
name: "comp",
body: {
kind: "Box",
id: "0000000000d0",
children: [
{ kind: "Text", id: "0000000000d1", content: "x" },
{ kind: "Image", id: "0000000000d2", uri: "uri://a", alt: "alt" },
],
},
},
slots: [{ kind: "Text", id: "0000000000e1", content: "slot" }, null, { kind: "Box", id: "0000000000e2", children: [] }],
},
{ kind: "Image", id: "0000000000c3", uri: "uri://b", alt: "" },
],
},
outcomes: [
{ kind: "Pending", message: "wait", id: "0000000000a1" },
{ kind: "Completed", sourceVersion: 7, id: "0000000000a2" },
{ kind: "Denied", id: "0000000000a3" },
],
part: { kind: "Tool", name: "hammer", uses: 12 },
crafted: { kind: "Part", name: "gear", count: 4 },
missing: null,
};
export const hex = (data: Uint8Array) => Buffer.from(data).toString("hex");
// tests.slot_unit: a document [Name, Outcome: UnitOutcome], whose one arm Denied2 is a unit arm (extra-slot-store.cc, record
// unit-arm). The reader's names are the reference's: a mark names Denied2.
export const unitOutcome = union<{ kind: "Denied2" }>("UnitOutcome", { 2: ["Denied2", unit] }, `${CS}UnitOutcome`);
export function slotUnitDocument(schemaId: string, contentHash?: string) {
return defineDocumentType({
...(contentHash === undefined ? {} : { contentHash }),
type: "tests.slot_unit",
schemaId,
schemaName: "tests.slot_unit",
schemaVersion: "tests.slot_unit.v1",
schema: z.object({ name: z.string(), outcome: z.union([z.null(), z.object({ kind: z.literal("Denied2") })]) }),
codec: slots<{ name: string; outcome: { kind: "Denied2" } | null }>({ 0: ["name", string], 1: ["outcome", unitOutcome] }, `${CS}SlotUnit`),
name: "name",
});
}
export function envelopeOf(payload: Uint8Array): CultCacheEnvelope {
return { key: "surface", type: "tests.union_surface", payload: documentPayloadFromBytes(payload), storedAt: new Date().toISOString() };
}
export function memoryStore(entries: CultCacheEnvelope[]): CacheBackingStore & { entries: CultCacheEnvelope[] } {
return {
entries,
async pullAll() { return entries.map((entry) => ({ ...entry })); },
async push(entry) { entries.push(entry); },
async delete() {},
};
}

View file

@ -16,7 +16,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0", "cryptography>=41"]
dependencies = ["cultcache-py>=0.4.0", "cultnet-py>=0.1.0", "cryptography>=41"]
[project.scripts]
cultmesh-py-daemon = "cultmesh_py.daemon:main"
cultmesh-py-verify = "cultmesh_py.verify:main"

View file

@ -732,21 +732,37 @@ impl CultMeshNode {
let cultnet_rs::CultNetMessage::SnapshotResponseRaw { documents, .. } = response else {
anyhow::bail!("expected cultnet.snapshot_response_raw.v0");
};
let mut applied = 0usize;
self.apply_raw_snapshot_documents(documents)
}
/// Applies the snapshot documents as one cache commit, as C# does: a refusal (a key this node holds read-only because
/// of unread slots from a newer peer or a payload this build cannot read, or any other the cache judges) applies
/// nothing. A schema id the registry does not know is skipped, except one with a `.vN` suffix, which is taken as
/// registered and refuses the commit when its type is not (C# throws on an unknown schema too). The commit lands in
/// one backing store, so a snapshot spanning stores is refused whole; C# `CultCache.Commit` refuses a cross-store batch too
/// ("The batch spans A and B; a commit lands in one home store"), so the two agree.
fn apply_raw_snapshot_documents(
&mut self,
documents: Vec<cultnet_rs::CultNetRawDocumentRecord>,
) -> Result<usize> {
let mut staged = Vec::new();
for document in documents {
let Some(document_type) =
registered_document_type_for_schema(&self.documents, &document.schema_id)
else {
continue;
};
self.cache.put_raw_envelope(CultCacheEnvelope {
staged.push(CultCacheEnvelope {
key: document.record_key,
r#type: document_type,
payload: document.payload,
stored_at: document.stored_at,
schema_id: Some(document.schema_id),
})?;
applied += 1;
});
}
let applied = staged.len();
if applied > 0 {
self.cache.put_prepared_batch(staged)?;
}
Ok(applied)
}
@ -1195,10 +1211,111 @@ mod tests {
body: String,
}
#[derive(Clone, Debug, PartialEq, Eq, cultcache_rs::DatabaseEntry)]
#[cultcache(type = "cultmesh.test.memo", schema = "CultMeshTestMemo")]
struct Memo {
#[cultcache(key = 0)]
body: String,
}
cultmesh_documents!(TestDocuments {
Note => "cultmesh.test.note.v0",
});
cultmesh_documents!(TwoTypeDocuments {
Note => "cultmesh.test.note.v0",
Memo => "cultmesh.test.memo.v0",
});
fn raw_note(key: &str, payload: Vec<u8>) -> cultnet_rs::CultNetRawDocumentRecord {
cultnet_rs::CultNetRawDocumentRecord {
schema_id: "cultmesh.test.note.v0".to_string(),
record_key: key.to_string(),
stored_at: "2026-01-01T00:00:00Z".to_string(),
payload_encoding: cultnet_rs::CultNetRawPayloadEncoding::Messagepack,
payload,
source_runtime_id: None,
source_agent_id: None,
source_role: None,
tags: None,
}
}
#[test]
fn a_snapshot_document_for_a_read_only_key_refuses_the_whole_pull_as_csharp_does() -> Result<()> {
let temp = tempfile::tempdir()?;
let mut node = CultMesh::create_node(temp.path().join("m.cc"), TestDocuments, Default::default())?;
// A newer peer's note: one slot past the declared one.
let newer = rmp_serde::to_vec(&("newer body", "extra slot"))?;
let plain = |body: &str| rmp_serde::to_vec(&(body,));
assert_eq!(node.apply_raw_snapshot_documents(vec![raw_note("held", newer)])?, 1);
assert_eq!(node.get_required::<Note>("held")?.body, "newer body");
let error = node
.apply_raw_snapshot_documents(vec![
raw_note("before", plain("b")?),
raw_note("held", plain("overwrite")?),
raw_note("after", plain("a")?),
])
.unwrap_err();
let conflict = error.downcast_ref::<cultcache_rs::SchemaConflictError>().expect("typed refusal");
assert_eq!(conflict.record_key, "held");
assert!(conflict.unread_slots.is_some());
assert!(!error.to_string().contains("extra slot"));
assert!(node.get::<Note>("before")?.is_none());
assert_eq!(node.get_required::<Note>("held")?.body, "newer body");
assert!(node.get::<Note>("after")?.is_none());
Ok(())
}
#[test]
fn a_snapshot_spanning_two_backing_stores_is_refused_whole_and_applies_nothing() -> Result<()> {
let temp = tempfile::tempdir()?;
let mut node = CultMesh::create_node(temp.path().join("m.cc"), TwoTypeDocuments, Default::default())?;
// A node has one store through the public API; route Memo to a second one by hand (C# refuses the same commit).
node.cache.add_backing_store(
SingleFileMessagePackBackingStore::new(temp.path().join("memo.cc")),
[Memo::TYPE],
)?;
node.cache.pull_all_backing_stores()?;
let plain = |body: &str| rmp_serde::to_vec(&(body,));
let memo = |key: &str, payload| {
let mut record = raw_note(key, payload);
record.schema_id = "cultmesh.test.memo.v0".to_string();
record
};
let error = node
.apply_raw_snapshot_documents(vec![
raw_note("n", plain("note")?),
memo("m", plain("memo")?),
])
.unwrap_err();
assert!(error.to_string().contains("cannot span backing stores"), "{error}");
assert!(node.get::<Note>("n")?.is_none());
assert!(node.get::<Memo>("m")?.is_none());
assert!(!temp.path().join("m.cc").exists() && !temp.path().join("memo.cc").exists());
// Each store alone still takes its own snapshot.
assert_eq!(node.apply_raw_snapshot_documents(vec![memo("m", plain("memo")?)])?, 1);
assert_eq!(node.get_required::<Memo>("m")?.body, "memo");
Ok(())
}
#[test]
fn a_snapshot_applied_to_a_stale_node_keeps_the_records_another_writer_added() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("m.cc");
let mut node = CultMesh::create_node(&path, TestDocuments, Default::default())?;
let mut other = CultMesh::create_node(&path, TestDocuments, Default::default())?;
other.put("added", &Note { body: "other writer".to_string() })?;
let plain = |body: &str| rmp_serde::to_vec(&(body,));
assert_eq!(node.apply_raw_snapshot_documents(vec![raw_note("snap", plain("s")?)])?, 1);
let reopened = CultMesh::create_node(&path, TestDocuments, Default::default())?;
assert_eq!(reopened.get_required::<Note>("added")?.body, "other writer");
assert_eq!(reopened.get_required::<Note>("snap")?.body, "s");
Ok(())
}
#[test]
fn node_round_trips_registered_documents() -> Result<()> {
let temp = tempfile::tempdir()?;

View file

@ -3933,7 +3933,7 @@ export class CultMeshNode {
}
public async flush(soft = false): Promise<void> {
await this.store.pushAll?.(this.cache.snapshot(), { soft });
await this.store.pushAll?.(this.cache.storedSnapshot(), { soft });
}
}

View file

@ -6,7 +6,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { z } from "zod";
import { SchemaConflictError, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts";
import { SchemaConflictError, SingleFileMessagePackBackingStore, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts";
import {
CultNetDocumentRegistry,
CultNetPeer,
@ -109,6 +109,34 @@ test("CultMesh TS opens a durable local node and registers document bindings", a
assert.ok(reopened.documents.getBySchemaId("cultmesh.note.v0"));
});
test("CultMesh TS flush from a node that never pulled leaves the records the store holds", async () => {
const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-flush-")), "node.ccmp");
const first = await CultMesh.startNode(filePath, { documents: [noteDocument] });
await first.put(noteDocument, "note:held", { noteId: "note:held", body: "already in the store" });
await first.flush();
const unpulled = await CultMesh.startNode(filePath, { documents: [noteDocument], pullOnStart: false });
await unpulled.flush();
const reopened = await CultMesh.startNode(filePath, { documents: [noteDocument] });
assert.equal(reopened.getRequired(noteDocument, "note:held").body, "already in the store");
});
test("CultMesh TS flush of a node that loaded a legacy-key global leaves the store reopenable", async () => {
const settings = defineDocumentType({ type: "cultmesh.settings", schema: z.object({ theme: z.string() }), global: true });
const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-legacy-flush-")), "node.ccmp");
const seed = await CultMesh.startNode(join(await mkdtemp(join(tmpdir(), "cultmesh-ts-seed-")), "seed.ccmp"), { documents: [settings] });
await seed.cache.putGlobal(settings, { theme: "iron" });
const legacy = { ...seed.cache.getRequiredEnvelope(settings, "__global__"), key: "legacy" };
await new SingleFileMessagePackBackingStore(filePath).push(legacy);
const node = await CultMesh.startNode(filePath, { documents: [settings] });
await node.flush();
const reopened = await CultMesh.startNode(filePath, { documents: [settings] });
assert.deepEqual(reopened.cache.getRequiredGlobal(settings), { theme: "iron" });
});
test("CultMesh TS document handles hide local cache plumbing behind typed reactive reads", async () => {
const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-doc-")), "node.ccmp");
const node = await CultMesh.startNode(filePath, {

View file

@ -83,3 +83,16 @@ test("a provider command that is not a map, or lacks a required field, is refuse
(error: Error) => error.message === "CultMesh provider command must be a MessagePack map.",
);
});
test("a provider command payload with bytes after its first value is refused by count, without echoing the tail", () => {
const command = { commandId: "c1", commandKind: "k", providerId: "p", serviceInstanceId: "s", payload: { n: 1 } };
const whole = documentPayloadBytes(encodeDocumentPayload(providerCommandDocument, command));
const tailed = documentPayloadFromBytes(Uint8Array.from([...whole, ...Buffer.from("canary-tail-8c1f")]));
assert.throws(
() => decodeProviderCommandDocument({
schemaId: cultMeshProviderSessionSchemas.command, recordKey: "p/s/c1", storedAt: "2026-10-11T00:00:00Z",
payloadEncoding: "messagepack" as const, payload: tailed,
}),
(error: Error) => error.message.includes("16 bytes after its first MessagePack value") && !error.message.includes("canary-tail-8c1f"),
);
});

View file

@ -2,6 +2,8 @@
## Unreleased (breaking; version 0.x, so the next minor)
- `decode_witness_artifact_bundle_payload` (and `CultNetWitnessArtifactBundle.from_payload`) read the payload through `cultcache_py.read_payload_tree`: bytes after its first MessagePack value raise `ValueError` `cultnet.witness_artifact_bundle payload has N bytes after its first MessagePack value (refused on read).` instead of `msgpack.ExtraData`, and a payload nested past 256 raises `CultCachePayloadDepthError`. Needs `cultcache-py` 0.4.0, the release that exports `read_payload_tree`: the dependency is now `cultcache-py>=0.4.0` (it was `>=0.2.0`, which admits releases whose `import cultnet_py` fails), and `tests/test_declared_dependency_ranges.py` fails when a declared bound admits a tagged release lacking a name the package imports. The same check found `cultmesh-py` importing `cache.index_text`, which `cultcache-py` 0.2.0 lacks; its bound is `cultcache-py>=0.4.0` too (it has no changelog of its own). A raw record with bytes after its first value gives no schema hint to `resolve_document_for_raw_record`, so an unknown schema id stays `KeyError` rather than being routed; a registered one is refused by count when applied.
- `apply_raw_document_record` and `apply_raw_snapshot` raise `SchemaConflictError` when a remote record is of another type than the one the cache holds at its key (`cultcache-py` holds one record per key whatever its type); the cache is unchanged, and the caller decides whether to drop the record or the peer. `cultmesh-py`'s server answers such a put with `message_invalid`; its client's `read_next_change` lets the error out.
- `CultNetDatabaseChange` carries `commit_id` and `commit_count` (`commitId`/`commitCount`; None outside a multi-record commit, as C# writes nil): `from_wire` refuses an empty or non-string id and a count that is not an integer of at least 2, `to_wire` writes them when set, and the published `database_change_raw` schema lists both. Python applies no batch. The `simulation_fact` document's `shard`, `frame`, `subject` and `claim_kind` aliases are `roles` (many facts share each); the catalog bytes do not move.
- Decodes the write receipt frames of `contracts/cultnet/interop/write-receipt.md`: `parse_message` validates `cultnet.write_admitted.v0` and `cultnet.error.v1` against their closed schemas (unknown keys, empty or non-string ids, an unknown operation or code are refused; `error.v1` requires `messageId`, string or null; `limit_reached` is `error.v1` only). Added `CULTNET_ERROR_V1_CODES`, `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS`, `write_content_hash` and `hello_advertises_write_receipts`; `hello` takes `supported_message_versions`. `tests/test_write_receipt.py` replays the vectors' `contentHash`, `wire` and `schemaInvalid` sections. Not implemented here: the client write tracker (`scenarios`) and the server rules (`dedupe`, `blankFields`); this package has no receipt server and no write client.
- Added `message_invalid` to `CULTNET_ERROR_CODES` and `message_invalid_error(field, reason)`, byte-identical to the C# `ForMessageInvalid`. `cultmesh-py` refuses a malformed put or delete with it instead of the out-of-schema `malformed_document_put` and `malformed_document_delete`, which are removed.

View file

@ -16,7 +16,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = ["cultcache-py>=0.2.0", "msgpack>=1.0"]
dependencies = ["cultcache-py>=0.4.0", "msgpack>=1.0"]
[project.optional-dependencies]
crypto = ["cryptography>=42"]

View file

@ -5,6 +5,7 @@ from dataclasses import dataclass
from typing import Any
import msgpack
from cultcache_py import read_payload_tree
from .unicode_messageid_tables import DEFAULT_IGNORABLE, LETTER_OR_NUMBER
@ -544,7 +545,7 @@ def encode_witness_artifact_bundle_payload(bundle: dict[str, Any] | CultNetWitne
def decode_witness_artifact_bundle_payload(payload: bytes) -> dict[str, Any]:
slots = msgpack.unpackb(payload, raw=False)
slots = read_payload_tree("cultnet.witness_artifact_bundle", payload)
if not isinstance(slots, list) or len(slots) < 8:
raise ValueError("CultNet witness artifact bundle payload must be an 8-slot MessagePack array")
return {

View file

@ -171,9 +171,13 @@ def resolve_document_for_raw_record(
def _infer_schema_version_from_payload(payload: bytes) -> str | None:
import msgpack # type: ignore
try:
import msgpack # type: ignore
decoded = msgpack.unpackb(payload, raw=False)
except msgpack.ExtraData:
# A payload with bytes after its value gives no routing hint: the record is unroutable by inference, and a
# registered schema id routes it to a definition whose decode refuses the tail by count.
return None
except Exception:
return None
if isinstance(decoded, list) and decoded and isinstance(decoded[0], str):

View file

@ -14,7 +14,7 @@ from pathlib import Path
from typing import Callable
from uuid import uuid4
import msgpack
from cultcache_py import CultCache, define_database_entry_type
from cultcache_py import CultCache, SchemaConflictError, define_database_entry_type
from cultnet_py.benchmark import run_benchmark
from cultnet_py.compare_csharp import (
DEFAULT_PARITY_THRESHOLD,
@ -1942,6 +1942,63 @@ class CultNetTests(unittest.TestCase):
self.assertEqual(raised.exception.response["messageId"], "subscription-error")
thread.join(2.0)
def test_replication_refuses_a_remote_record_of_another_type_at_a_held_key_and_changes_nothing(self) -> None:
# variants:ruling:cache-refuses-retype: a cache holds one record per key whatever its type, so a peer that reuses a
# key across types is refused where the record is applied; replication does not catch it.
first = define_database_entry_type("replica.first", [("name", 0), ("category", 1), ("value", 2, 0)], cls=Item)
second = define_database_entry_type("replica.second", [("name", 0), ("category", 1), ("value", 2, 0)], cls=Item)
cache = CultCache()
cache.register_document_type(first)
cache.register_document_type(second)
cache.put(first, "shared", Item("held", "", 0))
def raw(document):
return {
"schemaId": document.catalog_entry().schema_id,
"recordKey": "shared",
"storedAt": "2026-06-13T00:00:00Z",
"payloadEncoding": "messagepack",
"payload": document.encode_payload(Item("remote", "", 0)),
}
with self.assertRaises(SchemaConflictError):
apply_raw_document_record(cache, schema_document_map([first, second]), raw(second))
snapshot = {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": "s", "documents": [raw(second)]}
with self.assertRaises(SchemaConflictError):
apply_raw_snapshot(cache, [first, second], snapshot)
self.assertEqual(cache.get_required(first, "shared").name, "held")
self.assertIsNone(cache.get(second, "shared"))
apply_raw_document_record(cache, schema_document_map([first, second]), raw(first)) # the same type still replaces
self.assertEqual(cache.get_required(first, "shared").name, "remote")
def test_a_raw_record_with_a_tail_is_unroutable_by_inference_and_refused_by_count_when_routed(self) -> None:
import msgpack # type: ignore
from cultcache_py import define_document_type
from cultnet_py import resolve_document_for_raw_record
document = define_document_type("tests.tail_note", schema_id="tests.tail_note", schema_version="tests.tail_note.v1",
payload_encoder=msgpack.packb, payload_decoder=lambda payload: msgpack.unpackb(payload, raw=False))
known = document.catalog_entry().schema_id
head = msgpack.packb(["tests.tail_note.v1", "x"], use_bin_type=True)
def raw(schema_id: str, payload: bytes) -> dict:
return {"schemaId": schema_id, "recordKey": "k", "storedAt": "2026-06-13T00:00:00Z",
"payloadEncoding": "messagepack", "payload": payload}
documents = schema_document_map([document])
# Routing by inference reads a hint, not a payload: a payload with a tail gives no hint, so an unknown schema id
# stays the unknown-schema error (KeyError) even when the first value names a registered schema.
self.assertIs(resolve_document_for_raw_record(documents, "unknown", raw("unknown", head)), document)
with self.assertRaises(KeyError):
resolve_document_for_raw_record(documents, "unknown", raw("unknown", head + b"\xc0"))
# A registered schema id routes, and the tail is refused by count when the record is applied.
cache = CultCache()
cache.register_document_type(document)
with self.assertRaises(ValueError) as caught:
apply_raw_document_record(cache, documents, raw(known, head + b"\xc0"))
self.assertIn("1 bytes after its first MessagePack value", str(caught.exception))
self.assertIsNone(cache.get(document, "k"))
def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None:
document = define_database_entry_type(
"replica.item",
@ -2632,6 +2689,23 @@ class CultNetTests(unittest.TestCase):
self.assertEqual(responses[0]["details"]["value"], schema_version)
self.assertNotIn("messageId", responses[0])
def test_witness_artifact_bundle_payload_is_one_value_and_depth_wins_over_a_tail(self) -> None:
import msgpack # type: ignore
from cultcache_py import CultCachePayloadDepthError
payload = msgpack.packb(["b", "k", "t", {}, [], [], [], {}], use_bin_type=True)
decode_witness_artifact_bundle_payload(payload)
for tail, count in ((b"\xc0", 1), (b"\xc1\xc1", 2)):
with self.assertRaises(ValueError) as caught:
decode_witness_artifact_bundle_payload(payload + tail)
self.assertIs(type(caught.exception), ValueError)
self.assertEqual(
str(caught.exception),
f"cultnet.witness_artifact_bundle payload has {count} bytes after its first MessagePack value (refused on read).",
)
with self.assertRaises(CultCachePayloadDepthError):
decode_witness_artifact_bundle_payload(b"\x91" * 300 + b"\xc0\xc0")
def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None:
from cultnet_py import CultNetSimulationConsensus

View file

@ -0,0 +1,94 @@
"""A sibling package resolves through the workspace's editable install, so a package that starts importing a new name from
cultcache-py passes whatever range it declares. A consumer outside the workspace installs the newest release the range
admits. This is the Python form of scripts/test-declared-dependency-ranges.mjs: for every tagged cultcache-py release
(`cultcache-py-vX.Y.Z`) that the `cultcache-py>=` bound of cultnet-py or cultmesh-py admits, every name those packages
import from cultcache_py must be defined by that release's source. It needs git with the release tags and skips without
them. Only `>=` bounds are read; star imports and names reached by attribute access are not checked."""
from __future__ import annotations
import ast
import re
import subprocess
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
PACKAGES = ROOT / "packages"
CONSUMERS = ("cultnet-py", "cultmesh-py")
def _version(text: str) -> tuple[int, ...]:
return tuple(int(part) for part in text.split("."))
def _git(*args: str) -> str | None:
try:
done = subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, check=True)
except (OSError, subprocess.CalledProcessError):
return None
return done.stdout
def _lower_bound(package: str) -> tuple[int, ...]:
manifest = (PACKAGES / package / "pyproject.toml").read_text(encoding="utf-8")
match = re.search(r'"cultcache-py>=(\d+\.\d+\.\d+)"', manifest)
assert match, f"{package} declares no cultcache-py>= bound"
return _version(match.group(1))
def _imports(package: str) -> dict[str, set[str]]:
"""module -> names imported from it, for every `from cultcache_py[.x] import names` under the package's src."""
found: dict[str, set[str]] = {}
for source in (PACKAGES / package / "src").rglob("*.py"):
for node in ast.walk(ast.parse(source.read_text(encoding="utf-8-sig"))):
if isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
if node.module == "cultcache_py" or node.module.startswith("cultcache_py."):
found.setdefault(node.module, set()).update(alias.name for alias in node.names if alias.name != "*")
return found
def _defined(tag: str, module: str) -> set[str] | None:
base = "packages/cultcache-py/src/" + module.replace(".", "/")
for path in (base + ".py", base + "/__init__.py"):
text = _git("show", f"{tag}:{path}")
if text is None:
continue
names: set[str] = set()
for node in ast.parse(text.lstrip("\ufeff")).body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
names.add(node.name)
elif isinstance(node, (ast.Import, ast.ImportFrom)):
names.update((alias.asname or alias.name).split(".")[0] for alias in node.names)
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
for target in node.targets if isinstance(node, ast.Assign) else [node.target]:
if isinstance(target, ast.Name):
names.add(target.id)
return names
return None
class DeclaredDependencyRangeTests(unittest.TestCase):
def test_every_admitted_cultcache_release_defines_what_the_consumers_import(self) -> None:
listed = _git("tag", "--list", "cultcache-py-v*")
tags = sorted((t for t in (listed or "").split() if re.fullmatch(r"cultcache-py-v\d+\.\d+\.\d+", t)),
key=lambda t: _version(t.rsplit("v", 1)[1]))
if not tags:
self.skipTest("git with the cultcache-py release tags is not available")
current = re.search(r'^version = "(\d+\.\d+\.\d+)"', (PACKAGES / "cultcache-py" / "pyproject.toml").read_text(encoding="utf-8"), re.M)
assert current
for package in CONSUMERS:
bound = _lower_bound(package)
self.assertLessEqual(bound, _version(current.group(1)), f"{package}'s bound is above the workspace cultcache-py")
imports = _imports(package)
for tag in tags:
if _version(tag.rsplit("v", 1)[1]) < bound:
continue
for module, names in imports.items():
defined = _defined(tag, module)
self.assertIsNotNone(defined, f"{package} imports {module}, which {tag} does not have")
self.assertEqual(sorted(names - defined), [], f"{package} imports names {tag} lacks from {module}")
if __name__ == "__main__":
unittest.main()

View file

@ -15,5 +15,6 @@ only in git history.
### Breaking
- A CultNet frame with bytes after its message is refused, by count (through `cultcache_rs::decode_payload`); the decoder used to read the message and drop the tail. No producer emits one. Behaviour change: the next release is a minor bump (0.x breaking).
- `CultNetDocumentRegistry::register` returns `Result<&mut Self>` where it returned `&mut Self`. A binding that claims a document type or a schema id another binding already holds is refused with `cultcache_rs::SchemaConflictError` (naming the new binding's schema id and both document types) and nothing is bound; binding a type to the schema id it already holds still replaces that binding. Callers chain `register(...)?`.
- `CultMeshCdnChunkRef` gains a public `id` field, the decimal text of `offset` (the reference's element id, slot 4). A manifest written before ids reads `""`; encoding writes the slot.

View file

@ -146,6 +146,7 @@ impl CultNetDocumentRegistry {
schema_id: binding.schema_id.clone(),
schema_names: vec![holder.document_type.clone(), binding.document_type.clone()],
record_key: String::new(),
unread_slots: None,
}));
}
self.bindings_by_schema_id

View file

@ -1810,12 +1810,10 @@ fn a_replicated_payload_nested_past_the_bound_is_refused_typed_on_apply() -> Res
};
assert_eq!(documents.len(), 1);
backing.push(&envelope("past", limit + 1))?;
let mut cache = CultCache::new();
cache.register_entry_type::<GhostlightAgentStateFixture>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store))?;
cache.pull_all_backing_stores()?;
let past = registry.create_raw_snapshot_response(&cache, "past", None, Some(&["past".to_string()]))?;
// The sender cannot load a payload past the bound, so the frame is the at-limit one with its bytes swapped.
let mut past = at_limit.clone();
let CultNetMessage::SnapshotResponseRaw { documents, .. } = &mut past else { unreachable!() };
documents[0].payload = envelope("past", limit + 1).payload;
let mut target = CultCache::new();
target.register_entry_type::<GhostlightAgentStateFixture>()?;
let error = registry

View file

@ -190,6 +190,16 @@ fn message_past_the_bound_is_refused_typed() {
});
}
// A frame is one message: cultcache's payload rule refuses bytes after it, by count.
#[test]
fn frame_with_bytes_after_is_refused() {
let mut bytes = hello_with(&nested(0));
assert!(decode_message(bytes.clone(), BIG).is_ok());
bytes.push(0xc0);
let error = format!("{:#}", decode_message(bytes, BIG).unwrap_err());
assert!(error.contains("1 bytes after its first MessagePack value"), "{error}");
}
fn raw_v1_bytes(header_unknown: Option<&[u8]>) -> Vec<u8> {
let mut header: Vec<(rmpv::Value, rmpv::Value)> = vec![
("schemaId".into(), "s".into()),

View file

@ -5,6 +5,10 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
## Unreleased
### Breaking
- `decodeManifest` refuses a manifest with bytes after its first MessagePack value (`CDN artifact manifest has N bytes after its first MessagePack value.`), as the C# reference does; before, the tail was ignored. `contracts/cultmesh/content-acceptance-vectors.json` now holds `trailing_nil`, `trailing_reserved_code` and `trailing_truncated_array` as refused.
### Added
- Fork card (`contracts/cultnet/governance/fork-card.md`): `raised_at`, a required key holding an RFC3339 instant string or nil (the question's first admission time), outside `options_hash`; the card vectors are re-pinned with the key, three negatives (epoch integer, date-only string, missing key) and a nil-`raised_at` valid card; every `optionsHash` digest is unchanged.

View file

@ -144,8 +144,8 @@ imports no `node:` module, so a browser bundle can take it.
past the shared depth limit are refused.
- The decoder reads what MessagePack-CSharp reads and refuses what it refuses:
nil in a string, tag or metadata slot is `null` and is written back as nil,
a missing trailing slot takes the reference's default, extra slots and trailing
bytes are skipped (a chunk reference's fifth slot is its element id, a string
a missing trailing slot takes the reference's default, extra slots are skipped and bytes
after the manifest's first value are refused (a chunk reference's fifth slot is its element id, a string
or nil, `""` when absent; `packContent` writes the decimal offset as the id, as the
reference does), a float-encoded integer is refused, and invalid UTF-8
decodes to U+FFFD. A nil `chunks` is refused, as the reference's

View file

@ -249,7 +249,7 @@ class Reader {
export function decodeManifest(bytes: Uint8Array): CultMeshCdnArtifactManifest {
checkPayloadDepth(bytes, "CDN artifact manifest");
const reader = new Reader(bytes);
// A slot the array does not reach keeps the property initialiser; a slot past the tenth is skipped. Trailing bytes are not read.
// A slot the array does not reach keeps the property initialiser; a slot past the tenth is skipped.
const count = reader.array("manifest");
let index = 0;
const slot = <T>(read: () => T, initial: T): T => index++ < count ? read() : initial;
@ -267,6 +267,8 @@ export function decodeManifest(bytes: Uint8Array): CultMeshCdnArtifactManifest {
[reader.string("metadata") ?? wrongType("metadata"), reader.string("metadata")] as const), []),
};
for (; index < count; index += 1) reader.skip();
// A manifest is one record payload: exactly one MessagePack value, as the reference's one-value rule has it.
if (reader.position < bytes.length) throw new Error(`CDN artifact manifest has ${bytes.length - reader.position} bytes after its first MessagePack value.`);
validateManifest(manifest);
return manifest;
}

View file

@ -392,6 +392,13 @@ test("decodeManifest accepts and refuses exactly what the reference does, and re
}
});
test("a manifest with bytes after its first value is refused by count, whatever the tail is", () => {
const whole = encodeManifest(manifestOf());
for (const tail of [[0xc0], [0xc1, 0xc1], [0x90]]) {
assert.throws(() => decodeManifest(Uint8Array.from([...whole, ...tail])), new RegExp(`${tail.length} bytes after its first MessagePack value`));
}
});
test("a manifest written as a map names the manifest, not a field inside it", () => {
assert.throws(() => decodeManifest(encode({ 0: "a", 9: { k: "v" } })), /field 'manifest' has the wrong type/);
});

View file

@ -14,6 +14,8 @@ import {
SchemaConflictError,
SingleFileMessagePackBackingStore,
defineDocumentType,
documentPayloadBytes,
documentPayloadFromBytes,
schemaIdentityOf,
} from "@gamecult/cultcache-ts";
@ -3140,3 +3142,24 @@ test("CultNet registry names the canonical schema id of a compatible alias, and
assert.equal(registry.canonicalSchemaId("p3.nobody"), undefined);
assert.equal(registry.canonicalSchemaId("p3.shared"), undefined, "an id several bindings claim resolves to none");
});
test("a replicated record with bytes after its first MessagePack value is refused by count and held nowhere", async () => {
const definition = defineDocumentType({ type: "tails.note", schemaId: "tails.note.v1", schema: z.object({ body: z.string() }) });
const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition })]);
const origin = CultCache.builder().withDocumentType(definition).build();
await origin.put(definition, "k", { body: "x" });
const document = registry.createRawSnapshotResponse(origin, "tails").documents[0]!;
const whole = documentPayloadBytes(document.payload);
const target = CultCache.builder().withDocumentType(definition).build();
await assert.rejects(
registry.applyRawDocumentPutMessage(target, {
schemaVersion: "cultnet.document_put_raw.v0",
messageId: "tails",
document: { ...document, payload: documentPayloadFromBytes(Uint8Array.from([...whole, 0xc0, 0xc1])) },
}),
(error: Error) => error.message.includes('document type "tails.note" payload has 2 bytes after its first MessagePack value'),
);
assert.deepEqual(target.snapshot(), []);
await registry.applyRawDocumentPutMessage(target, { schemaVersion: "cultnet.document_put_raw.v0", messageId: "whole", document });
assert.deepEqual(target.getRequired(definition, "k"), { body: "x" });
});

View file

@ -166,7 +166,7 @@ public static class CultDocumentMessagePackSerialization
private static (MessagePackSerializerOptions Options, IFormatterResolver[] Ahead) Compose(IFormatterResolver[] consumerResolvers)
{
// The guard goes first and wraps whatever formatter the rest of the composite would hand out, so a consumer
// The guard goes first and wraps whatever formatter the rest of the composite would hand out (a union, or a concrete int-keyed object for its slot count), so a consumer
// resolver still encodes an arm while the key check runs.
var ahead = consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(CultMathExprResolver.Instance).ToArray();
var inner = CompositeResolver.Create(ahead.Append(StandardResolver.Instance).ToArray());
@ -214,6 +214,19 @@ public static class CultDocumentMessagePackSerialization
?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}.");
}
// The same decode, with the slots it read past noted (CultSlotScope). Opened around the one decode and after the depth check,
// so a payload past the nesting limit is refused, never marked.
internal static CultDecoded DeserializeMarked(Type type, byte[] payload, CultDocumentRegistry registry)
{
if (registry == null) throw new ArgumentNullException(nameof(registry));
registry.GetRequired(type);
CultPayloadDepth.Check(payload, type, PayloadDirection.Read);
using var scope = CultSlotScope.Open();
var document = MessagePackSerializer.Deserialize(type, payload, OptionsForDocument(type))
?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}.");
return new CultDecoded(document, scope.Finding());
}
/// <summary>
/// Replaces slots of a serialized document payload (a MessagePack array indexed by slot) with already-encoded member
/// values, leaving every other slot byte for byte as it was. A slot past the end extends the array with nil.
@ -823,4 +836,7 @@ public class SingleFileMessagePackBackingStore : SingleFileBackingStore
{
return CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry);
}
internal override CultDecoded DeserializeMarked(Type documentType, byte[] payload) =>
CultDocumentMessagePackSerialization.DeserializeMarked(documentType, payload, Registry);
}

View file

@ -7,11 +7,12 @@ namespace GameCult.Caching.MessagePack;
internal enum PayloadDirection { Read, Write }
/// <summary>
/// The one payload nesting count. Walks the MessagePack headers of a document payload iteratively, counting every array and
/// The one walk of a payload's shape: its depth and its one value. Walks the MessagePack headers of a document payload iteratively, counting every array and
/// map (read by a formatter or skipped past it; the outermost array is depth 1) and refusing the first container past
/// <see cref="CultMessagePackSecurity.MaximumPayloadDepth"/>. Scalars are skipped by length, nothing is allocated per
/// element, and the count stack is bounded by the limit. A refusal names the type, the depth reached and the limit,
/// never payload bytes. A payload that ends early is the deserializer's to refuse.
/// never payload bytes. A payload that ends early is the deserializer's to refuse; bytes after the first value are refused
/// with their count and never walked.
/// </summary>
internal static class CultPayloadDepth
{
@ -73,7 +74,13 @@ internal static class CultPayloadDepth
// One element is complete; close every container it was the last element of.
while (depth > 0 && --remaining[depth - 1] == 0) depth--;
if (depth == 0) return;
if (depth == 0)
{
if (position < bytes.Length)
throw new MessagePackSerializationException(
$"{type.FullName} payload has {bytes.Length - position} bytes after its first MessagePack value (refused on {(direction == PayloadDirection.Read ? "read" : "write")}).");
return;
}
}
}

View file

@ -0,0 +1,47 @@
using System;
using GameCult.Caching;
namespace GameCult.Caching.MessagePack;
/// <summary>
/// The marks one payload decode noted: the guard's slot formatter reports every object whose array held more slots than its type
/// declares, with the nesting depth it sat at, and DeserializeMarked reads the finding out. Thread-bound and open only around the one
/// decode, so a decode outside it (a CultMesh read, a codec) notes nothing.
/// </summary>
internal sealed class CultSlotScope : IDisposable
{
[ThreadStatic] private static CultSlotScope? current;
private readonly CultSlotScope? previous;
private string? typeName;
private int slotCount;
private int declaredCount;
private int occurrences;
private bool allAtDepthZero = true;
private CultSlotScope() => previous = current;
internal static CultSlotScope? Current => current;
internal static CultSlotScope Open() => current = new CultSlotScope();
// 0 for the document's own object; each object nested inside it adds one (a union adds none, its arm is the object).
internal int Depth { get; set; }
internal void Note(string name, int slots, int declared)
{
if (occurrences++ == 0)
{
typeName = name;
slotCount = slots;
declaredCount = declared;
}
if (Depth > 0) allAtDepthZero = false;
}
internal CultUnreadSlots? Finding() =>
occurrences == 0 ? null : new CultUnreadSlots(typeName!, slotCount, declaredCount, occurrences, allAtDepthZero, explained: false);
public void Dispose() => current = previous;
}

View file

@ -1,14 +1,17 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using MessagePack;
using MessagePack.Formatters;
using MessagePack.Resolvers;
namespace GameCult.Caching.MessagePack;
/// <summary>
/// The one place an unknown union arm or an unnamed subtype is decided. A union is an abstract class or interface carrying
/// The one sensor of an unknown union arm, an unnamed subtype and a slot past the reader's declared slots. A union is an abstract class or interface carrying
/// its own [Union] attributes (never inherited: a union whose arms are also a union has its own key set). MessagePack-CSharp
/// still encodes the [key, armSlots] shape; this wraps its formatter so that a key the union does not declare is refused on
/// read, and a value whose runtime type is not a declared arm is refused on write, where MessagePack alone yields null and nil.
@ -18,11 +21,117 @@ internal sealed class CultUnionGuard(IFormatterResolver inner) : IFormatterResol
public IMessagePackFormatter<T>? GetFormatter<T>()
{
var arms = typeof(T).GetCustomAttributes<UnionAttribute>(inherit: false).ToArray();
return arms.Length == 0 || inner.GetFormatter<T>() is not { } formatter ? null : new Formatter<T>(formatter, arms);
if (arms.Length == 0)
return inner.GetFormatter<T>() is { } objectFormatter && KeyLayout(objectFormatter) ? new SlotFormatter<T>(objectFormatter) : null;
return inner.GetFormatter<T>() is not { } formatter ? null : new Formatter<T>(formatter, arms);
}
private sealed class Formatter<T>(IMessagePackFormatter<T> inner, UnionAttribute[] arms) : IMessagePackFormatter<T>
// The formatter that writes and reads the bytes: the one a guard wrapper delegates to, else the formatter itself. A check that a
// generated formatter is the one in use asks this (tests/GameCult.Caching.GeneratorConsumer).
internal static object Inner(object formatter) => formatter is IWrapper wrapper ? wrapper.Inner : formatter;
private interface IWrapper
{
object Inner { get; }
}
// The one decision of whether a type is measured: the formatter the chain hands out for an int-keyed [MessagePackObject] type is
// MessagePack's own [Key]-layout formatter, the very instance StandardResolver (or its AllowPrivate twin) builds for it. A formatter a
// consumer resolver or a [MessagePackFormatter] attribute supplies owns its layout and its tolerance, and a [MessagePackObject]
// collection is written as its items by the generic resolver: none of those is measured. Identity, as ResolverClaim asks it.
private static bool KeyLayout<T>(IMessagePackFormatter<T> formatter)
{
if (typeof(T).IsAbstract || typeof(T).IsInterface ||
typeof(T).GetCustomAttribute<MessagePackObjectAttribute>(inherit: true) is not { KeyAsPropertyName: false })
return false;
if (ReferenceEquals(formatter, Of<T>(AttributeFormatterResolver.Instance)) || ReferenceEquals(formatter, Of<T>(DynamicGenericResolver.Instance)))
return false;
return ReferenceEquals(formatter, Of<T>(StandardResolver.Instance)) || ReferenceEquals(formatter, Of<T>(StandardResolverAllowPrivate.Instance));
}
private static IMessagePackFormatter<T>? Of<T>(IFormatterResolver resolver)
{
try
{
return resolver.GetFormatter<T>();
}
catch (Exception)
{
return null;
}
}
// Counts a concrete object's array header against its declared slots and notes the excess into the open CultSlotScope, then lets the
// inner formatter read it unchanged: no byte scan, no second decode. With no scope open it is a depth-free pass-through.
// Declared slots are what the type's own formatter writes: the array header of one instance written through it, measured on the
// first read inside a scope (not in GetFormatter, so a self-referential type never recurses into its own resolver cache) and kept.
private sealed class SlotFormatter<T>(IMessagePackFormatter<T> inner) : IMessagePackFormatter<T>, IWrapper
{
object IWrapper.Inner => inner;
private const int NotMeasured = -1;
// A type no instance of which can be written is not guarded: no count can exceed this.
private const int Unguarded = int.MaxValue;
private int declared = NotMeasured;
public void Serialize(ref MessagePackWriter writer, T value, MessagePackSerializerOptions options) =>
inner.Serialize(ref writer, value, options);
public T Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
var scope = CultSlotScope.Current;
if (scope == null) return inner.Deserialize(ref reader, options);
var peek = reader.CreatePeekReader();
if (!peek.TryReadNil() && peek.NextMessagePackType == MessagePackType.Array)
{
var count = peek.ReadArrayHeader();
if (declared == NotMeasured) declared = Measure(options);
if (count > declared) scope.Note(typeof(T).Name, count, declared);
}
scope.Depth++;
try
{
return inner.Deserialize(ref reader, options);
}
finally
{
scope.Depth--;
}
}
// The uninitialized instance, else one from a parameterless constructor (public or not) when that write throws.
private int Measure(MessagePackSerializerOptions options)
{
if (Written(() => (T)RuntimeHelpers.GetUninitializedObject(typeof(T)), options) is { } slots) return slots;
var constructor = typeof(T).GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, Type.EmptyTypes, null);
return constructor != null && Written(() => (T)constructor.Invoke(null), options) is { } constructed ? constructed : Unguarded;
}
private int? Written(Func<T> instance, MessagePackSerializerOptions options)
{
try
{
var buffer = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
inner.Serialize(ref writer, instance(), options);
writer.Flush();
var written = new MessagePackReader(buffer.WrittenMemory);
return written.NextMessagePackType == MessagePackType.Array ? written.ReadArrayHeader() : null;
}
catch (Exception)
{
return null;
}
}
}
private sealed class Formatter<T>(IMessagePackFormatter<T> inner, UnionAttribute[] arms) : IMessagePackFormatter<T>, IWrapper
{
object IWrapper.Inner => inner;
private readonly HashSet<int> keys = arms.Select(arm => arm.Key).ToHashSet();
private readonly HashSet<Type> types = arms.Select(arm => arm.SubType).ToHashSet();

View file

@ -636,7 +636,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
record,
VersionOf(metadata.Payload),
catalog,
(type, payload) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, Registry),
(type, payload) => CultDocumentMessagePackSerialization.DeserializeMarked(type, payload, Registry),
out report);
// The manifest is this store's record of what is on disk: the stored id and the version a commit condition compares with.
stored.StoredSchemaId = metadata.SchemaId;

View file

@ -8,9 +8,10 @@
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="..\..\packages\cultmath\src\CultMath\CultMath.csproj" />
<PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="System.Text.Json" Version="9.0.10" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
<InternalsVisibleTo Include="GameCult.Caching.GeneratorConsumer" />
<None Include="buildTransitive\GameCult.Caching.MessagePack.targets" Pack="true" PackagePath="buildTransitive\" />
</ItemGroup>
</Project>

View file

@ -202,6 +202,20 @@ flatten, remove, commit) is refused with the typed schema conflict before anythi
Declaring its id compatible on any registered type makes it writable and is the consent to the loss:
the next write stores it under the writing type's own id and sheds the slots.
A record whose payload carries a slot past the slots its reader declares, at any depth (nested object, list
element, union arm, unit arm), is read-only the same way, whatever id it is stored under. The one consent
is the declaration above, and it reaches only depth-0 slots the schema comparison reports ignored under a
listed compatible id, up to the highest slot it reports: a mark anywhere else, past that slot, or at depth 0
under the reader's own id, is not consented to. Declared slots are what the type's own formatter writes; only
a type MessagePack writes by its `[Key]` layout is checked, and one no instance of which can be written is not
(`cultlib-gaps:ruling:slot-unmeasurable-type`; see the schema-compatibility contract for both exemptions and
for the user code the measure runs).
A flatten of a variant whose base chain holds a record that is not owned is refused with that record's
refusal, because it would write the base's decoded view; the variant's own writes persist only its delta and
stay allowed. A plain write to another key of a value read from a read-only record is a copy this rule does
not police. `CultCache.IsWritable(key)` answers what the staging refusal answers for a removal or a write of
the record's type, read-only and missing home stores included; it does not predict that flatten.
The rule is checked again where the write lands, under the store's lock: a write or removal at a key
whose durable record is not owned, because another writer rewrote it after this cache
loaded, is refused typed, and the record is reloaded as the store holds it, read-only when a type can
@ -389,6 +403,8 @@ runtime enforces the same bound on read and on write, and the refusal names the
the depth and the limit, never payload bytes. The vectors are in
`tests/vectors/union-parity` (`bounds-outcomes.msgpack`).
A record's payload is exactly one MessagePack value: every runtime refuses a payload with bytes after its first value wherever it reads a payload under the nesting bound, and on write where a write takes bytes the library did not encode itself (a C# type's own formatter, a Rust raw envelope), without walking those bytes, so a tail past the limit is refused as bytes after the value and not as depth; the refusal names the type and the count of bytes after the value, never the bytes (vectors: `tests/vectors/union-parity/bytes-after-outcomes.msgpack`). A CDN manifest is stored and keyed as a record and follows the same rule (`contracts/cultmesh/content-acceptance-vectors.json`: `trailing_nil`, `trailing_reserved_code` and `trailing_truncated_array` are refused). Streams of values (CultNet frames, a store's record array) are different contracts and are not covered.
## Variant Records
A variant record is a record whose document is a base record's document with some
@ -535,15 +551,14 @@ Read directly when:
Allowed when:
- persisted schema has missing slots that the local schema can default
- persisted schema has extra slots the local reader can safely ignore or preserve
- slot meanings remain compatible
Soft migration should emit warnings, not silent shrugs.
A persisted schema with extra slots the local reader lacks is not soft: the record is read-only (see the ownership
rule above). Soft migration should emit warnings, not silent shrugs.
Typical warnings:
- missing slot defaulted
- unknown extra slot ignored
- nullable/default behavior widened
- field preserved but not surfaced natively

View file

@ -107,10 +107,48 @@ persisted slots honestly:
- persisted schema id differs, but the embedded catalog points to a compatible
local schema id
- persisted slot is missing locally and can be ignored
- persisted slot is missing locally and the record is read-only until its id is declared compatible
(see Extra slots are read-only, below)
- local slot is missing in persisted data and can fall back to the local
default value
Extra slots are read-only. A record whose payload carries a slot past the slots its reader declares, at any
depth (the record, a nested object, a list element, a union arm, a unit arm), is loaded read-only: it is
served, `CultStoredDocument.UnreadSlots` names the first type read with extra slots and both counts, and
`CultCache.IsWritable(key)` is false. A staged write or removal of it is refused with
`CultSchemaConflictException` (`UnreadSlots` set; the message names the type and the counts, never a slot
value). A missing trailing slot keeps its default and stays writable; a non-nil value in a retired gap below
the highest key is not extra. Declaring an id compatible consents only to depth-0 slots the schema comparison
reports ignored under that id, and only up to the highest slot it reports: a payload carrying a slot past that
one is not consented to. Any other mark (nested, list element, arm, unit arm, or depth 0 under the reader's
own id) keeps the record read-only whatever is declared.
A type's declared slots are the array its own formatter writes for one instance, never a reflected member
list, so a non-public `[Key]` member of an `AllowPrivate` type counts. Only a concrete int-keyed
`[MessagePackObject]` type whose formatter is MessagePack's own `[Key]`-layout formatter is checked. Not
checked, and so read past with any extra slots dropped on the next write, as before:
- a type formatted by anything else: its own `[MessagePackFormatter]`, a formatter of its own that a
`CultCacheFormatterResolver` (or any resolver ahead of MessagePack's) hands out, or the collection formatter
of a `[MessagePackObject]` collection type such as a `List<T>` subclass. Such a formatter owns its layout and
its tolerance. A resolver that hands out MessagePack's own `[Key]`-layout formatter (StandardResolver's, or
StandardResolverAllowPrivate's) does not exempt the type: it is checked and marks;
- a type no instance of which can be written: no write of an uninitialized instance and no write of one
from a parameterless constructor succeeds (for example a `[Key]` getter that throws on an uninitialized
instance and no parameterless constructor). This gap is ruled: `cultlib-gaps:ruling:slot-unmeasurable-type`.
Measuring runs user code once per type per process, on the first read of the type in a store load: the
type's `[Key]` getters on an uninitialized instance and, if that write throws, its parameterless
constructor (public or not) and the getters on that instance. Any failure of both leaves the type unchecked
for the rest of the process, and nothing else: it does not fail the read.
`Flatten` of a variant writes its base chain's decoded view into a plain record, so it is refused when any
record on that chain is not owned by its home store (a mark nothing explains, or undeclared drift that drops
slots), with that record's own refusal naming its key. Writing or removing the variant itself stays allowed:
it persists only its delta. `CultCache.IsWritable(key)` is the write's own answer for a removal or a write of
the record's type: it is also false when the record's home store is read-only or no attached store is home to
its type. The one write it does not predict is that flatten.
The cache emits a typed migration report with:
- exact vs compatible-drift classification

View file

@ -516,14 +516,22 @@ namespace GameCult.Caching
// resolved to Descriptor, until a write of this record puts it under Descriptor's id (a write of an owned record moves it).
public string StoredSchemaId { get; internal set; }
// True when the schema this record was loaded under left none of its persisted slots unread by Descriptor, so the type reads
// it whole and owns it (CultDocumentRegistry.Owns). Set by ToStoredDocument from the schema comparison's ignored slots,
// True when the schema this record was loaded under left none of its persisted slots unread by Descriptor and its payload
// carried none past Descriptor's, so the type reads it whole and owns it (CultDocumentRegistry.Owns). Set by ToStoredDocument from the schema comparison's ignored slots,
// before any variant override is shed. A record this cache constructed or wrote is stored under Descriptor's own id, which
// the registry declares, so only a loaded record under another id is ever asked. The staging half asks it;
// ConditionsHold never reads it. Only the store's loaded view carries it: the cache's resolved copies and flattens are never
// asked.
internal bool StoredSchemaReadWhole { get; set; }
/// <summary>
/// What the store's decode of this record's payload found past the slots this build declares, at any depth; null when none. Set by
/// ToStoredDocument on the store's loaded view only (the cache's resolved copy of a variant does not carry it; ask
/// <see cref="CultCache.IsWritable"/>). A mark that is not <see cref="CultUnreadSlots.Explained"/> keeps the record
/// unowned even under a declared id: it cannot be written or removed (HeldOwned).
/// </summary>
public CultUnreadSlots? UnreadSlots { get; internal set; }
// The catalog entry that publishes StoredSchemaId in the store this record was loaded from, with the id it was published for,
// so staging can ask WrittenOwns the question the store's judge asks, over the catalog the record came with. Null for a record
// this cache constructed or wrote, which is stored under Descriptor's own id and published by Descriptor's entry. A write that
@ -2300,8 +2308,9 @@ namespace GameCult.Caching
// The one-shot rewrite for stores written before element ids existed: upserts every record whose ids exist only in
// memory (minted when it loaded), through the normal write path, one commit per home store. Returns how many records
// it rewrote; a second call finds none. A selected record that is neither declared nor read whole is read-only,
// so its home store's commit is refused with CultSchemaConflictException; commits to earlier stores stand.
// it rewrote; a second call finds none. A selected record that is not owned (neither declared nor read whole, or held
// read-only for slots past this build's, see CultStoredDocument.UnreadSlots) is read-only, so its home store's commit is
// refused with CultSchemaConflictException until a build that reads those slots rewrites it; commits to earlier stores stand.
public int MintElementIds()
{
var groups = Held(() => _entries.Values
@ -2656,6 +2665,22 @@ namespace GameCult.Caching
return _entries.TryGetValue(key.Value, out var stored) ? stored : null;
}
/// <summary>
/// Whether a removal of the key, or a write of the record's own type, would be let through: the write's own decision, asked of
/// the record the cache holds. False when no attached store is home to the record's type, when its home store is read-only, or
/// when that store holds it unowned (undeclared drift that drops persisted slots, or a payload carrying slots past this build's,
/// see <see cref="CultStoredDocument.UnreadSlots"/>). For a key the cache does not hold, the removal check every store makes of it
/// (false when a store lists it foreign). One write it does not predict: a Flatten of a variant whose base chain holds an unowned record is refused, though
/// the variant itself is writable. Live and advisory: the write still decides.
/// </summary>
public bool IsWritable(CultRecordKey key)
{
lock (Gate())
return _entries.TryGetValue(key.Value, out var held)
? WriteRefusal(held, upsert: false, null, out _) == null
: _stores.All(entry => entry.Store.Refusal(key.Value, null) == null);
}
public object? Get(CultRecordKey key)
{
lock (Gate())
@ -3151,6 +3176,8 @@ namespace GameCult.Caching
IReadOnlyCollection<string>? removed)
{
var removedKeys = removed?.ToHashSet(StringComparer.Ordinal) ?? new HashSet<string>(StringComparer.Ordinal);
var batch = new Dictionary<string, CultStoredDocument>(StringComparer.Ordinal);
foreach (var stored in admitted) batch[stored.Key.Value] = stored;
var homes = new List<CacheBackingStore>();
var globals = new Dictionary<Type, string>(_globals);
var evictedKeys = evicted.Select(stored => stored.Key.Value).ToHashSet(StringComparer.Ordinal);
@ -3199,23 +3226,57 @@ namespace GameCult.Caching
void Claim(CultStoredDocument stored, bool upsert)
{
var home = Home(stored.Descriptor.DocumentType);
if (home == null)
if (WriteRefusal(stored, upsert, batch, out var home) is { } refusal) throw refusal;
if (home != null) homes.Add(home);
}
}
// The one answer to whether a record may be written (upsert: a write of its type) or its key removed, before anything is staged.
// Claim throws it; IsWritable asks it of the held record's removal form. Null when the write may land; home is where it lands,
// null for a store-less cache. batch holds the records the same batch admits, by key.
private Exception? WriteRefusal(CultStoredDocument stored, bool upsert, IReadOnlyDictionary<string, CultStoredDocument>? batch, out CacheBackingStore? home)
{
home = Home(stored.Descriptor.DocumentType);
if (home == null)
{
if (_stores.Count > 0)
return new InvalidOperationException($"No backing store is home to {stored.Descriptor.SchemaName}.");
// No store, no file to judge, but the same rule: a record is replaced only by its own type's write.
return upsert && _entries.TryGetValue(stored.Key.Value, out var inMemory) && !CacheBackingStore.UpsertOwned(inMemory, stored.Descriptor)
? CacheBackingStore.Retyped(inMemory, stored.Key.Value, stored.Descriptor)
: null;
}
if (home.IsReadOnly)
return new InvalidOperationException(
$"{stored.Descriptor.SchemaName} record {stored.Key.Value} routes to read-only backing store {home}.");
return home.Refusal(stored.Key.Value, upsert ? stored.Descriptor : null)
?? (upsert && stored.Flattens ? FlattenRefusal(stored, batch) : null);
}
// A flatten writes its base chain's decoded view into a plain record, so it is refused when a record on that chain is not owned
// by its home store (the slots or drift that record's decode dropped would be lost): that ancestor's own refusal. The chain starts
// at the staged delta's base when the variant is flattened as it lands, else at the held variant's; a key the batch admits is
// judged by its own claim, and the walk follows its staged form.
private CultSchemaConflictException? FlattenRefusal(CultStoredDocument flatten, IReadOnlyDictionary<string, CultStoredDocument>? batch)
{
var delta = flatten.Variant ?? (_entries.TryGetValue(flatten.Key.Value, out var variant) ? variant.Variant : null);
var walked = new HashSet<string>(StringComparer.Ordinal) { flatten.Key.Value };
for (var key = delta?.BaseKey; key != null && walked.Add(key);)
{
CultStoredDocument? ancestor;
if (batch == null || !batch.TryGetValue(key, out ancestor))
{
if (_stores.Count > 0)
throw new InvalidOperationException($"No backing store is home to {stored.Descriptor.SchemaName}.");
// No store, no file to judge, but the same rule: a record is replaced only by its own type's write.
if (upsert && _entries.TryGetValue(stored.Key.Value, out var inMemory) && !CacheBackingStore.UpsertOwned(inMemory, stored.Descriptor))
throw CacheBackingStore.Retyped(inMemory, stored.Key.Value, stored.Descriptor);
return;
if (!_entries.TryGetValue(key, out ancestor))
return null;
if (Home(ancestor.Descriptor.DocumentType)?.Refusal(key, null) is { } refusal)
return refusal;
}
if (home.IsReadOnly)
throw new InvalidOperationException(
$"{stored.Descriptor.SchemaName} record {stored.Key.Value} routes to read-only backing store {home}.");
home.RefuseUndeclared(stored.Key.Value, upsert ? stored.Descriptor : null);
homes.Add(home);
key = ancestor.Variant?.BaseKey;
}
return null;
}
private sealed class VariantPlan
@ -3937,11 +3998,21 @@ namespace GameCult.Caching
string version,
IReadOnlyCollection<CultSchemaCatalogEntry> catalog,
Func<Type, byte[], object> deserializePayload,
out CultSchemaMigrationReport report) =>
ToStoredDocument(record, version, catalog, (type, payload) => new CultDecoded(deserializePayload(type, payload), null), out report);
// The same, for a decode that notes the extra slots it read past (DeserializeMarked): the mark is turned into
// CultStoredDocument.UnreadSlots here, the one place that knows both the payload's marks and the catalog comparison.
internal CultStoredDocument ToStoredDocument(
CultPersistedRecord record,
string version,
IReadOnlyCollection<CultSchemaCatalogEntry> catalog,
Func<Type, byte[], CultDecoded> deserializePayload,
out CultSchemaMigrationReport report)
{
var resolution = Registry.ResolvePersistedSchemaDetailed(record.SchemaId, catalog);
report = resolution.Report;
var readWhole = report.IgnoredExtraSlots.Length == 0;
CultUnreadSlots? unread = null;
if (record.Variant != null)
{
var descriptor = resolution.Descriptor;
@ -3955,13 +4026,22 @@ namespace GameCult.Caching
.ToArray();
if (shed.Length > 0)
{
// A slot the schema diff already reported is not reported twice.
// A slot the schema diff already reported is not reported twice. One it did not report is a mark nothing explains:
// the variant is read-only and is never rewritten.
var unreported = shed.Except(report.IgnoredExtraSlots).ToArray();
if (unreported.Length > 0)
unread = new CultUnreadSlots(
descriptor.DocumentType.Name,
unreported.Max() + 1,
descriptor.RichMembers.Select(member => member.Slot + 1).DefaultIfEmpty(0).Max(),
1,
allAtDepthZero: true,
explained: false);
report.IgnoredExtraSlots = report.IgnoredExtraSlots.Concat(unreported).OrderBy(slot => slot).ToArray();
report.Warnings = report.Warnings.Concat(unreported.Select(slot => new CultSchemaMigrationWarning
{
Code = "ignored_extra_slot",
Message = $"Variant {record.Key} overrides slot {slot}, which {descriptor.DocumentType.Name} does not have; the override is ignored and shed on flush."
Message = $"Variant {record.Key} overrides slot {slot}, which {descriptor.DocumentType.Name} does not have; the variant is read-only."
})).ToArray();
}
@ -3977,16 +4057,17 @@ namespace GameCult.Caching
null)
{
StoredSchemaId = record.SchemaId,
StoredSchemaReadWhole = readWhole,
StoredSchemaReadWhole = report.IgnoredExtraSlots.Length == 0,
UnreadSlots = unread,
StoredPublishedBy = PublishedByIn(record.SchemaId, catalog),
StoredVersion = version
};
}
object document;
CultDecoded decoded;
try
{
document = deserializePayload(resolution.Descriptor.DocumentType, record.Payload);
decoded = deserializePayload(resolution.Descriptor.DocumentType, record.Payload);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
@ -3995,14 +4076,20 @@ namespace GameCult.Caching
for (var cause = exception; cause != null; cause = cause.InnerException) causes.Add(cause.Message);
throw new InvalidDataException($"Record '{record.Key}' (schema '{record.SchemaId}') cannot be decoded: {string.Join(" <- ", causes.Distinct())}", exception);
}
// Explained only when every mark is at depth 0 and the schema comparison under this id reported every slot the mark carries
// past the reader: consent reaches no slot above the highest reported (none when nothing was reported).
unread = decoded.UnreadSlots is { } marked
? marked.Explaining(marked.AllAtDepthZero && marked.SlotCount <= report.IgnoredExtraSlots.DefaultIfEmpty(-1).Max() + 1)
: null;
return new CultStoredDocument(
new CultRecordKey(record.Key),
record.StoredAt,
resolution.Descriptor,
document)
decoded.Document)
{
StoredSchemaId = record.SchemaId,
StoredSchemaReadWhole = readWhole,
StoredSchemaReadWhole = report.IgnoredExtraSlots.Length == 0 && unread == null,
UnreadSlots = unread,
StoredPublishedBy = PublishedByIn(record.SchemaId, catalog),
StoredVersion = version
};
@ -4041,22 +4128,37 @@ namespace GameCult.Caching
// catalog entry that published the held record's id when it was loaded, the question the store's judge asks of the file.
internal void RefuseUndeclared(string key, CultDocumentDescriptor? written)
{
var foreign = _foreignRecords.FirstOrDefault(record => string.Equals(record.Key, key, StringComparison.Ordinal));
if (foreign != null)
throw Overwrites(null, new[] { (foreign.Key, foreign.SchemaId, foreign.SchemaName) });
if (Entries.TryGetValue(key, out var held) && !HeldOwned(held, written))
throw Overwrites(null, new[] { (key, held.StoredSchemaId, held.Descriptor.SchemaName) });
if (Refusal(key, written) is { } refusal) throw refusal;
}
// The refusal of a write (of the written type) or a removal (written null) of the key, or null when this store's view lets it
// through. The one answer behind RefuseUndeclared and CultCache.IsWritable, so the query and the write cannot disagree.
internal CultSchemaConflictException? Refusal(string key, CultDocumentDescriptor? written)
{
var foreign = _foreignRecords.FirstOrDefault(record => string.Equals(record.Key, key, StringComparison.Ordinal));
if (foreign != null)
return Overwrites(null, new[] { (foreign.Key, foreign.SchemaId, foreign.SchemaName) });
if (!Entries.TryGetValue(key, out var held) || HeldOwned(held, written))
return null;
return held.UnreadSlots is { Explained: false } unread
? UnreadSlotsRefusal(held, key, unread)
: Overwrites(null, new[] { (key, held.StoredSchemaId, held.Descriptor.SchemaName) });
}
// The one ownership predicate of a held record. A mark nothing explains (anything but a depth-0 slot the catalog comparison
// reported ignored) keeps the record unowned whatever is declared: declaring the reader's own id consents to nothing.
private bool HeldOwned(CultStoredDocument held, CultDocumentDescriptor? written) =>
written == null
? Registry.Declared(held.StoredSchemaId) || held.StoredSchemaReadWhole
? !Unexplained(held) && (Registry.Declared(held.StoredSchemaId) || held.StoredSchemaReadWhole)
: UpsertOwned(held, written);
private static bool Unexplained(CultStoredDocument held) => held.UnreadSlots is { Explained: false };
// Whether a write of the written type may replace the record a cache holds: WrittenOwns over the entry that published the held
// record's id when it was loaded (a record this cache wrote is published by its own type). The one answer for a store-backed
// cache's staging and a store-less cache's.
internal static bool UpsertOwned(CultStoredDocument held, CultDocumentDescriptor written) =>
!Unexplained(held) &&
CultDocumentRegistry.WrittenOwns(
written.ToCatalogEntry(),
held.StoredSchemaId,
@ -4358,6 +4460,16 @@ namespace GameCult.Caching
refused[0].Key,
inner);
// The refusal of a write or removal of a record held read-only for the slots its payload carries past this build's. It names the
// type and the counts, never a slot value.
private CultSchemaConflictException UnreadSlotsRefusal(CultStoredDocument held, string key, CultUnreadSlots unread) => new(
$"{held.Descriptor.SchemaName} record {key} is read-only: {unread.TypeName} was read with {unread.SlotCount} slots where this build declares {unread.DeclaredCount} " +
$"({unread.Occurrences} object(s) in the record). Writing it would drop data a newer writer stored; read it with types that declare those slots, or write it from that build.",
held.StoredSchemaId,
new[] { held.Descriptor.SchemaName },
key,
unread);
// A flush or commit succeeded: nothing staged is left, writes or checks.
protected void MarkFlushSucceeded()
{
@ -4461,6 +4573,9 @@ namespace GameCult.Caching
protected abstract byte[] SerializePayload(object document, Type documentType);
protected abstract object DeserializePayload(Type documentType, byte[] payload);
// A decode that also notes the slots it read past. A store kind that does not override it never marks.
internal virtual CultDecoded DeserializeMarked(Type documentType, byte[] payload) => new(DeserializePayload(documentType, payload), null);
public override string ToString() => FileInfo.FullName;
public override void PullAll() => Held(PullAllCore);
@ -4495,7 +4610,7 @@ namespace GameCult.Caching
continue;
}
var stored = ToStoredDocument(record, version, snapshot.SchemaCatalog, DeserializePayload, out var report);
var stored = ToStoredDocument(record, version, snapshot.SchemaCatalog, DeserializeMarked, out var report);
reports.Add(report);
persisted[stored.Key.Value] = stored;
}
@ -4963,7 +5078,7 @@ namespace GameCult.Caching
var record = durable?.Snapshot.Records.FirstOrDefault(candidate => string.Equals(candidate.Key, key, StringComparison.Ordinal));
if (record == null || Foreign(record, durable!.Snapshot.SchemaCatalog, durable.Version(key)) != null)
return null;
return ToStoredDocument(record, durable.Version(key), durable.Snapshot.SchemaCatalog, DeserializePayload, out _);
return ToStoredDocument(record, durable.Version(key), durable.Snapshot.SchemaCatalog, DeserializeMarked, out _);
}
// The file now holds every record written, under the id and at the storedAt the write gave it, and the version of those bytes:

View file

@ -5,7 +5,8 @@ namespace GameCult.Caching
/// <summary>
/// A write the catalog cannot describe: two schemas that share a schema id and disagree on the schema name (both arrived with the
/// records, neither registered), a record whose schema id no chosen entry publishes, or a write that would replace or remove a
/// <see cref="CultForeignRecord"/>. Nothing is written, so the store stays as it was. It names the id, the schema names involved
/// <see cref="CultForeignRecord"/>, or a write or removal of a record held read-only because its payload carries slots past the
/// reader's declared slots (<see cref="UnreadSlots"/> is set). Nothing is written, so the store stays as it was. It names the id, the schema names involved
/// and a record key.
/// </summary>
public sealed class CultSchemaConflictException : InvalidOperationException
@ -27,8 +28,18 @@ namespace GameCult.Caching
RecordKey = recordKey;
}
/// <summary>The refusal of a record held read-only for the slots it carries past this build's: the same refusal, with the finding.</summary>
public CultSchemaConflictException(string message, string schemaId, string[] schemaNames, string recordKey, CultUnreadSlots unreadSlots)
: this(message, schemaId, schemaNames, recordKey)
{
UnreadSlots = unreadSlots;
}
public string SchemaId { get; }
public string[] SchemaNames { get; }
public string RecordKey { get; }
/// <summary>Set when the refusal is for unread slots; null for every other refusal.</summary>
public CultUnreadSlots? UnreadSlots { get; }
}
}

View file

@ -0,0 +1,54 @@
namespace GameCult.Caching
{
/// <summary>
/// What a store load found in a record's payload past the slots this build declares, at any depth: the first type read with extra
/// slots and how many slots it carried against how many it declares, and how many objects in the record did. It holds no slot
/// value, so it can be named in a refusal. A record carrying it is read-only (<see cref="CultCache.IsWritable"/>) unless every
/// mark is explained.
/// </summary>
public sealed class CultUnreadSlots
{
internal CultUnreadSlots(string typeName, int slotCount, int declaredCount, int occurrences, bool allAtDepthZero, bool explained)
{
TypeName = typeName;
SlotCount = slotCount;
DeclaredCount = declaredCount;
Occurrences = occurrences;
AllAtDepthZero = allAtDepthZero;
Explained = explained;
}
/// <summary>The first type read with extra slots.</summary>
public string TypeName { get; }
/// <summary>The slots that object carried.</summary>
public int SlotCount { get; }
/// <summary>The slots this build declares for that type: the array its own formatter writes for one instance.</summary>
public int DeclaredCount { get; }
/// <summary>The objects in the record that carried extra slots.</summary>
public int Occurrences { get; }
/// <summary>
/// True only when every mark is at depth 0 and the schema comparison under the id the record was stored under reported every
/// slot the mark carries past the reader (<see cref="SlotCount"/> is at most the highest reported slot plus one): the loss a
/// compatible-id declaration consents to (cultlib-gaps:ruling:slot-mark-consent). Any other mark leaves the record read-only
/// whatever is declared.
/// </summary>
public bool Explained { get; }
// Every mark the decode noted sat in the record's own object, the half of "explained" the payload decides.
internal bool AllAtDepthZero { get; }
internal CultUnreadSlots Explaining(bool explained) =>
new(TypeName, SlotCount, DeclaredCount, Occurrences, AllAtDepthZero, explained);
}
// A payload decoded by a store kind, with the extra slots the decode noted. A store kind that does not note them reports none.
internal readonly struct CultDecoded(object document, CultUnreadSlots? unreadSlots)
{
public object Document { get; } = document;
public CultUnreadSlots? UnreadSlots { get; } = unreadSlots;
}
}

View file

@ -12,7 +12,7 @@
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="..\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="System.Text.Json" Version="9.0.10" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>
<Import Project="..\GameCult.Caching.MessagePack\buildTransitive\GameCult.Caching.MessagePack.targets" />

View file

@ -284,7 +284,10 @@ namespace GameCult.Mesh
throw new ArgumentException($"Expected provider command schema '{CultMeshProviderSessionWireContract.CommandSchema}'.", nameof(document));
if (!string.Equals(document.PayloadEncoding, "messagepack", StringComparison.Ordinal))
throw new ArgumentException("Provider command document must use MessagePack.", nameof(document));
var command = MessagePackSerializer.Deserialize<CultMeshProviderCommandWire>(document.Payload, Options);
// A raw record's payload is one MessagePack value (ruling trailing-bytes-refuse): a tail is refused by count, never walked.
var command = MessagePackSerializer.Deserialize<CultMeshProviderCommandWire>(new ReadOnlyMemory<byte>(document.Payload), Options, out var read);
if (read < document.Payload.Length)
throw new MessagePackSerializationException($"Provider command document: {document.Payload.Length - read} bytes after its first MessagePack value (refused on read).");
if (command == null) throw new MessagePackSerializationException("Provider command decoded to null.");
Validate(command);
return command;

View file

@ -641,23 +641,19 @@ namespace GameCult.Networking
string storedAt,
CultNetDocumentMessageOptions? options = null)
{
var binding = GetByDocumentType(descriptor.DocumentType) ??
new CultNetDocumentBinding(
descriptor.DocumentType,
descriptor.SchemaId,
value => CultDocumentMessagePackSerialization.SerializeUntyped(value, value.GetType(), _documents),
payload => CultDocumentMessagePackSerialization.DeserializeUntyped(descriptor.DocumentType, payload, _documents));
var binding = GetByDocumentType(descriptor.DocumentType);
return new CultNetRawDocumentRecord
{
SchemaId = binding.SchemaId,
SchemaId = binding?.SchemaId ?? descriptor.SchemaId,
SchemaName = descriptor.SchemaName,
SchemaVersion = descriptor.SchemaVersion,
SchemaContentHash = descriptor.ContentHash,
RecordKey = key.Value,
StoredAt = storedAt,
PayloadEncoding = "messagepack",
Payload = binding.PayloadSerializer(document),
Payload = binding?.PayloadSerializer(document) ??
CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType(), _documents),
SourceRuntimeId = options?.SourceRuntimeId,
SourceAgentId = options?.SourceAgentId,
SourceRole = options?.SourceRole,

View file

@ -9,7 +9,7 @@
<ProjectReference Include="..\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<PackageReference Include="MessagePack" Version="3.1.7" />
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
<PackageReference Include="System.Text.Json" Version="9.0.10" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="R3" Version="1.3.0" />
<PackageReference Include="LiteNetLib" Version="2.1.2" />
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />

View file

@ -4,6 +4,9 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- GameCult.Caching.MessagePack lets this check see CultUnionGuard.Inner (InternalsVisibleTo), which also shows it that
assembly's internal GeneratedMessagePackResolver; this project's own generated one is the type in use. -->
<NoWarn>$(NoWarn);CS0436</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(Aot)' == 'true'">

View file

@ -20,7 +20,8 @@ var documents = new (Type Type, object Value)[]
foreach (var (type, _) in documents)
{
var formatter = options.Resolver.GetFormatterDynamic(type)!.GetType();
// The guard wraps a measured type's formatter; the generated formatter it delegates to is the one that runs.
var formatter = CultUnionGuard.Inner(options.Resolver.GetFormatterDynamic(type)!).GetType();
if (formatter.Assembly.IsDynamic || !(formatter.FullName ?? "").Contains("GeneratedMessagePackResolver"))
return Fail($"{type.Name} is formatted by {formatter.FullName}, not a generated formatter");
}

View file

@ -51,6 +51,12 @@ static async Task<int> ProgramMainAsync(string[] args)
case "edit-in-place":
await EditInPlaceAsync(file, RequireArg(options, "kind"));
return 0;
case "write-shapes":
await WriteShapesAsync(file);
return 0;
case "read-shapes":
await ReadShapesAsync(file);
return 0;
case "hold-lock":
HoldLock(file, int.Parse(RequireArg(options, "ms"), CultureInfo.InvariantCulture));
return 0;
@ -96,6 +102,49 @@ static async Task WriteAsync(string file, string runtimeId)
WriteJsonLine(note);
}
// Records a reader of a Bifrost-shaped store must open: a union field (two arms) and nested bytes (a bin member, a bin list).
static async Task WriteShapesAsync(string file)
{
var cache = BuildCache(file);
await cache.PullAllBackingStoresAsync();
foreach (var shape in InteropShapes())
await cache.AddAsync(shape, new CultRecordHandle<CultCacheInteropShape>(new CultRecordKey(shape.DocumentId)));
cache.FlushAllBackingStores();
WriteShapesJsonLine(InteropShapes());
}
static async Task ReadShapesAsync(string file)
{
var cache = BuildCache(file);
await cache.PullAllBackingStoresAsync();
WriteShapesJsonLine(cache.AllEntries.OfType<CultCacheInteropShape>());
}
static CultCacheInteropShape[] InteropShapes() =>
[
new() { DocumentId = "shape:text", Arm = new InteropTextArm { Text = "union arm 0" }, Raw = Enumerable.Range(0, 256).Select(i => (byte)i).ToArray() },
new()
{
DocumentId = "shape:pack",
Arm = new InteropPackArm { Label = "union arm 1", Chunks = [new byte[] { 0xc0, 0xc1, 0x00 }, [], [0x92, 0x01, 0x02]] },
Raw = [0xde, 0xad, 0xbe, 0xef]
}
];
// One line, ordered by document id, in the form the Rust peer prints.
static void WriteShapesJsonLine(IEnumerable<CultCacheInteropShape> shapes) =>
Console.Out.WriteLine(JsonSerializer.Serialize(shapes.OrderBy(shape => shape.DocumentId, StringComparer.Ordinal).Select(shape => new
{
documentId = shape.DocumentId,
arm = shape.Arm switch
{
InteropTextArm text => (object)new { kind = "text", text = text.Text },
InteropPackArm pack => new { kind = "pack", label = pack.Label, chunks = pack.Chunks.Select(Convert.ToHexString).ToArray() },
_ => throw new InvalidOperationException("Unknown arm.")
},
raw = Convert.ToHexString(shape.Raw)
})));
static async Task ReadAsync(string file)
{
var cache = BuildCache(file);
@ -328,3 +377,28 @@ public sealed class CultCacheInteropMark
[Key(0)] public string Label { get; set; } = string.Empty;
[Key(1)] [CultElementId] public string Id { get; set; } = string.Empty;
}
[CultDocument("cultcache.interop-shape", "cultcache.interop_shape.v1")]
[MessagePackObject]
public sealed class CultCacheInteropShape
{
[Key(0)] [CultName] public string DocumentId { get; set; } = string.Empty;
[Key(1)] public InteropArm Arm { get; set; } = new InteropTextArm();
[Key(2)] public byte[] Raw { get; set; } = Array.Empty<byte>();
}
[Union(0, typeof(InteropTextArm)), Union(1, typeof(InteropPackArm))]
public abstract class InteropArm { }
[MessagePackObject]
public sealed class InteropTextArm : InteropArm
{
[Key(0)] public string Text { get; set; } = string.Empty;
}
[MessagePackObject]
public sealed class InteropPackArm : InteropArm
{
[Key(0)] public string Label { get; set; } = string.Empty;
[Key(1)] public List<byte[]> Chunks { get; set; } = new();
}

View file

@ -10,6 +10,8 @@ using MessagePack.Formatters;
[assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.OrderResolver))]
[assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.ListClaimResolver))]
[assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.FbLaterClaimResolver))]
[assembly: CultCacheFormatterResolver(typeof(SlotReadOnlyTests.ResolvedBagResolver))]
[assembly: CultCacheFormatterResolver(typeof(SlotReadOnlyTests.RelayResolver))]
namespace GameCult.Caching.Tests
{

View file

@ -0,0 +1,211 @@
#nullable enable
using System;
using System.IO;
using System.Linq;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
using UnionSurface = GameCult.Caching.Tests.UnionParityVectorTests.UnionSurface;
namespace GameCult.Caching.Tests
{
// A record's payload is exactly one MessagePack value. The vectors are union-surface.msgpack followed by a tail; the C#
// reference is their only writer (VectorFiles, CULTCACHE_WRITE_VECTORS=1) and every other runtime is held to bytes-after-outcomes.msgpack.
public class BytesAfterVectorTests
{
private const string Vector = "union-parity";
private const string Canary = "CANARY-bytes-after-7d1e";
private const string Clause = "bytes after its first MessagePack value";
private const int Limit = CultMessagePackSecurity.MaximumPayloadDepth;
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(UnionSurface) });
private static byte[] Base => VectorFiles.Committed(Vector, "union-surface.msgpack");
private static byte[] DeepTail()
{
var text = System.Text.Encoding.ASCII.GetBytes(Canary);
return Enumerable.Repeat((byte)0x91, 300).Concat(new[] { (byte)(0xa0 | text.Length) }).Concat(text).ToArray();
}
// file, tail
private static (string File, byte[] Tail)[] Cases() => new (string, byte[])[]
{
("bytes-after-nil.msgpack", new byte[] { 0xc0 }),
("bytes-after-never-used.msgpack", new byte[] { 0xc1, 0xc1 }),
("bytes-after-deep-tail.msgpack", DeepTail()),
("bytes-after-second-document.msgpack", Base),
};
// The bounds row shape: [file, outcome, union, key|nil, detail].
private static byte[] OutcomesFile()
{
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
var w = new MessagePackWriter(buffer);
var cases = Cases();
w.WriteArrayHeader(cases.Length);
foreach (var (file, _) in cases)
{
w.WriteArrayHeader(5);
w.Write(file);
w.Write("refused");
w.Write("UnionSurface");
w.WriteNil();
w.Write("bytes-after");
}
w.Flush();
return buffer.WrittenSpan.ToArray();
}
private static string Message(Action action)
{
try { action(); }
catch (Exception exception)
{
var message = "";
for (var e = exception; e != null; e = e.InnerException) message += e.Message + "\n";
return message;
}
throw new AssertionException("the payload was accepted");
}
[Test]
public void VectorsMatchAndRefuse()
{
var baseBytes = Base;
foreach (var (file, tail) in Cases()) VectorFiles.Check(Vector, file, baseBytes.Concat(tail).ToArray());
VectorFiles.Check(Vector, "bytes-after-outcomes.msgpack", OutcomesFile());
// From here only the committed files speak.
var reader = new MessagePackReader(VectorFiles.Committed(Vector, "bytes-after-outcomes.msgpack"));
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(4));
for (var i = 0; i < 4; i++)
{
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(5));
var file = reader.ReadString()!;
Assert.That(reader.ReadString(), Is.EqualTo("refused"));
Assert.That(reader.ReadString(), Is.EqualTo("UnionSurface"));
Assert.That(reader.TryReadNil(), Is.True);
Assert.That(reader.ReadString(), Is.EqualTo("bytes-after"));
var payload = VectorFiles.Committed(Vector, file);
Assert.That(payload.Take(baseBytes.Length), Is.EqualTo(baseBytes), $"{file} begins with union-surface.msgpack");
var count = payload.Length - baseBytes.Length;
var message = Message(() => CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(UnionSurface), payload, Registry));
Assert.That(message, Does.Contain(typeof(UnionSurface).FullName).And.Contain($"{count} {Clause}").And.Contain("refused on read"), file);
Assert.That(message, Does.Not.Contain(Canary).And.Not.Contain("the limit is"), file);
}
}
// The codec the store and the inspector read through is a path of its own: DecodeDocument<T> deserializes by the codec's
// delegate, so a tail it did not refuse would be read as a document.
[Test]
public void CodecDecodeDocumentRefusesATail()
{
var codec = CultCacheMessagePack.CreateCodec(Registry);
Assert.That(codec.DecodeDocument<UnionSurface>(new CultRecordKey("surface"), Base), Is.Not.Null);
var message = Message(() => codec.DecodeDocument<UnionSurface>(new CultRecordKey("surface"), Base.Concat(new byte[] { 0xc0 }).ToArray()));
Assert.That(message, Does.Contain(typeof(UnionSurface).FullName).And.Contain($"1 {Clause}").And.Contain("refused on read"));
}
[Test]
public void StoreLoadRefuses()
{
var work = Path.Combine(Path.GetTempPath(), $"cultlib-bytesafter-{Guid.NewGuid():N}");
Directory.CreateDirectory(work);
try
{
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(VectorFiles.Committed(Vector, "union-surface.cc"));
snapshot.Records[0].Payload = Base.Concat(new byte[] { 0xc0 }).ToArray();
var scratch = Path.Combine(work, "tail.cc");
File.WriteAllBytes(scratch, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
var load = Message(() => CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry }).Dispose());
Assert.That(load, Does.Contain("surface").And.Contain($"1 {Clause}"));
}
finally
{
Directory.Delete(work, recursive: true);
}
}
// A directory store names a page by the SHA-256 of its bytes and its manifest commits to that hash: the page holding the
// payload is rewritten with a tail, renamed, and the manifest told its new hash, so the store is well formed and only the payload is wrong.
[Test]
public async System.Threading.Tasks.Task DirectoryLoadPageRefuses()
{
var folder = Path.Combine(Path.GetTempPath(), $"cultlib-bytesafter-dir-{Guid.NewGuid():N}");
Directory.CreateDirectory(folder);
try
{
var path = Path.Combine(folder, "tail.cc");
var options = new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true, StoreFlushOnDispose = true };
using (var writer = CultCacheMessagePack.Create(path, options))
{
var value = CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(UnionSurface), Base, Registry);
await writer.UpsertAsync(typeof(UnionSurface), value, new CultRecordKey("surface"));
await writer.FlushAsync();
}
var rehashed = new System.Collections.Generic.List<(byte[] Old, byte[] New)>();
foreach (var file in Directory.EnumerateFiles(folder, "*.msgpack", SearchOption.AllDirectories).ToArray())
{
var bytes = File.ReadAllBytes(file);
if (bytes.AsSpan().IndexOf(Base) < 0) continue;
var page = CultDocumentMessagePackSerialization.DeserializePersistedRecord(bytes);
page.Payload = Base.Concat(new byte[] { 0xc0 }).ToArray();
var forged = CultDocumentMessagePackSerialization.SerializePersistedRecord(page);
File.Delete(file);
File.WriteAllBytes(Path.Combine(Path.GetDirectoryName(file)!, Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(forged)).ToLowerInvariant() + ".msgpack"), forged);
rehashed.Add((System.Security.Cryptography.SHA256.HashData(bytes), System.Security.Cryptography.SHA256.HashData(forged)));
}
Assert.That(rehashed, Is.Not.Empty, "the record page was found");
foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).Where(f => !f.EndsWith(".msgpack", StringComparison.Ordinal)).ToArray())
{
var bytes = File.ReadAllBytes(file);
foreach (var (old, @new) in rehashed)
for (var at = bytes.AsSpan().IndexOf(old); at >= 0; at = bytes.AsSpan().IndexOf(old)) @new.CopyTo(bytes, at);
File.WriteAllBytes(file, bytes);
}
var thrown = Assert.CatchAsync(async () => { using var cache = await CultCacheMessagePack.OpenAsync(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true, ReadOnly = true }); });
var chain = "";
for (var e = thrown; e != null; e = e.InnerException) chain += e.Message + "\n";
Assert.That(chain, Does.Contain($"1 {Clause}"));
}
finally
{
Directory.Delete(folder, recursive: true);
}
}
public sealed class TwoValueFormatter : global::MessagePack.Formatters.IMessagePackFormatter<TwoValues>
{
public void Serialize(ref MessagePackWriter writer, TwoValues value, MessagePackSerializerOptions options)
{
writer.WriteArrayHeader(0);
writer.Write(7);
}
public TwoValues Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { reader.Skip(); return new TwoValues(); }
}
[CultDocument("tests.two_values", "tests.two_values.v1")]
[MessagePackObject]
[MessagePackFormatter(typeof(TwoValueFormatter))]
public sealed class TwoValues
{
[Key(0)] [CultName] public string Id = string.Empty;
}
[Test]
public void OwnFormatterWritingTwoValuesRefusedOnWrite()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TwoValues) });
var message = Message(() => CultDocumentMessagePackSerialization.SerializeUntyped(new TwoValues(), typeof(TwoValues), registry));
Assert.That(message, Does.Contain(typeof(TwoValues).FullName).And.Contain($"1 {Clause}").And.Contain("refused on write"));
}
}
}

View file

@ -0,0 +1,371 @@
#nullable enable
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// tests/vectors/key-identity: a cache holds one record per key whatever its type, and a store holding a record no registered
// type claims keeps it (variants:ruling:cache-refuses-retype, write-ownership-by-written-type, unheld-removal-refused-at-commit).
// The C# reference is the only writer: under CULTCACHE_WRITE_VECTORS=1 the rows are run and what the reference answers is
// written; every other run walks the committed rows against the reference and compares the files byte for byte.
public class KeyIdentityVectorTests
{
private const string Vector = "key-identity";
private static readonly Type Alpha = Kind("KiAlpha", "tests.ki_alpha");
private static readonly Type Beta = Kind("KiBeta", "tests.ki_beta");
private static readonly Type Gamma = Kind("KiGamma", "tests.ki_gamma");
private static Type Kind(string type, string schema) =>
EmittedDocumentTypes.Emit(type, schema, schema + ".v1", new[] { new EmittedDocumentTypes.Field("Text", typeof(string), 0) });
private static readonly Dictionary<string, Type> Types = new() { ["alpha"] = Alpha, ["beta"] = Beta, ["gamma"] = Gamma };
private sealed record Op(string Kind, string? File = null, string[]? Types = null, string? Type = null, string? Key = null, string? Text = null);
private sealed record Row(string Label, string[] Registered, Op[] Ops);
// [refusal key or nil, held [type, key, text], file [key, schema name] or nil, foreign keys, kept foreign keys], all sorted.
private sealed record Expect(string? Refusal, string[][] Held, string[][]? File, string[] Foreign, string[] Kept);
private static Op Open(string? file) => new("open", file);
private static Op Attach(string file, params string[] home) => new("attach", file, home);
private static Op Reopen() => new("reopen");
private static Op Put(string type, string key, string text) => new("put", Type: type, Key: key, Text: text);
private static Op Delete(string type, string key) => new("delete", Type: type, Key: key);
private static readonly string[] AB = { "alpha", "beta" };
private static readonly string[] A = { "alpha" };
// Only the ops are typed here; every verdict is what the reference answers.
private static readonly Row[] Table =
{
new("retype-refused-held-in-file", AB, new[] { Open(null), Put("alpha", "k", "a"), Put("beta", "k", "b") }),
new("retype-by-remove-then-add", AB, new[] { Open(null), Put("alpha", "k", "a"), Delete("alpha", "k"), Put("beta", "k", "b") }),
new("retype-refused-memory-cache", AB, new[] { Open("memory"), Put("alpha", "k", "a"), Put("beta", "k", "b") }),
new("same-type-replaces", A, new[] { Open(null), Put("alpha", "k", "a"), Put("alpha", "k", "b") }),
new("retype-refused-after-reload", AB, new[] { Open(null), Put("alpha", "k", "a"), Reopen(), Put("beta", "k", "b") }),
new("foreign-loads", A, new[] { Open("foreign-store.cc") }),
new("foreign-kept-by-a-write", A, new[] { Open("foreign-store.cc"), Put("alpha", "m", "m") }),
new("foreign-key-write-refused", A, new[] { Open("foreign-store.cc"), Put("alpha", "k", "x") }),
new("foreign-key-delete-refused", A, new[] { Open("foreign-store.cc"), Delete("alpha", "k") }),
new("foreign-claimed-by-a-later-registration", new[] { "alpha", "gamma" }, new[] { Open("foreign-store.cc") }),
new("key-in-two-stores-refused", AB, new[] { Open("memory"), Attach("catalog-store.cc", "alpha"), Attach("run-store.cc", "beta") }),
new("same-type-replaces-memory-cache", A, new[] { Open("memory"), Put("alpha", "k", "a"), Put("alpha", "k", "b") }),
};
// The stores C# writes: [file name, registered types, (type, key, text) records]. Never typed by a runtime.
private static readonly (string File, string[] Registered, (string Type, string Key, string Text)[] Records)[] Stores =
{
("foreign-store.cc", new[] { "alpha", "gamma" }, new[] { ("alpha", "j", "a"), ("gamma", "k", "g") }),
("catalog-store.cc", new[] { "alpha" }, new[] { ("alpha", "qx", "a") }),
("run-store.cc", new[] { "beta" }, new[] { ("beta", "qx", "b") }),
};
private static CultDocumentRegistry RegistryOf(IEnumerable<string> names) => CultDocumentRegistry.ForTypes(names.Select(name => Types[name]).ToArray());
private static object Doc(string type, string text) => EmittedDocumentTypes.New(Types[type], ("Text", text));
private static byte[] StoreBytes(string[] registered, (string Type, string Key, string Text)[] records)
{
var directory = Path.Combine(Path.GetTempPath(), $"cultlib-kiv-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
var path = Path.Combine(directory, "store.cc");
using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = RegistryOf(registered), StoreFlushOnDispose = true }))
{
cache.Clock = () => VectorFiles.StoredAt;
foreach (var record in records)
cache.UpsertAsync(Types[record.Type], Doc(record.Type, record.Text), new CultRecordKey(record.Key)).GetAwaiter().GetResult();
cache.FlushAsync().GetAwaiter().GetResult();
}
return File.ReadAllBytes(path);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
private static CultPersistedStoreSnapshot Snapshot(byte[] bytes) => CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
private static string Name(CultPersistedStoreSnapshot snapshot, CultPersistedRecord record) =>
snapshot.SchemaCatalog.Single(entry => entry.SchemaId == record.SchemaId).SchemaName;
private static string Short(string schemaName) => Types.Single(pair => pair.Key == schemaName.Replace("tests.ki_", "")).Key;
// The key as a delimited token: not a piece of a longer word, schema name or path.
private static bool Delimited(string message, string key) => Regex.IsMatch(message, $@"(?<![A-Za-z0-9_]){Regex.Escape(key)}(?![A-Za-z0-9_])");
private static string RecordBytes(CultPersistedRecord record) => $"{record.SchemaId}|{record.StoredAt}|{Convert.ToBase64String(record.Payload)}";
// Runs the ops and returns what the reference answers. Every op but the last must succeed; the last may refuse.
private static Expect Observe(Row row)
{
var directory = Path.Combine(Path.GetTempPath(), $"cultlib-kiw-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
var registry = RegistryOf(row.Registered);
string? path = null;
byte[]? before = null;
CultCache? cache = null;
try
{
Exception? refusal = null;
string? refusedKey = null;
for (var i = 0; i < row.Ops.Length; i++)
{
var op = row.Ops[i];
try
{
switch (op.Kind)
{
case "open" or "reopen":
cache?.Dispose();
if (op.Kind == "open" && op.File == "memory")
{
cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry));
break;
}
if (op.Kind == "open")
{
path = Path.Combine(directory, "store.cc");
if (op.File != null) File.WriteAllBytes(path, VectorFiles.Committed(Vector, op.File));
before = File.Exists(path) ? File.ReadAllBytes(path) : null;
}
cache = CultCacheMessagePack.Create(path!, new CultCacheOpenOptions { Registry = registry, StoreFlushOnDispose = true });
break;
case "attach":
{
var attached = Path.Combine(directory, op.File!);
File.WriteAllBytes(attached, VectorFiles.Committed(Vector, op.File!));
var held = cache!.AllStoredDocuments.Select(stored => stored.Key.Value).ToHashSet(StringComparer.Ordinal);
var shared = Snapshot(File.ReadAllBytes(attached)).Records.Select(record => record.Key).Where(held.Contains).ToArray();
try
{
cache.AddBackingStore(new SingleFileMessagePackBackingStore(attached), op.Types!.Select(name => Types[name]).ToArray());
cache.PullAllBackingStoresAsync().GetAwaiter().GetResult();
}
catch (InvalidOperationException) when (shared.Length == 1)
{
Assert.That(File.ReadAllBytes(attached), Is.EqualTo(VectorFiles.Committed(Vector, op.File!)), "a refused attach leaves the store as it was");
throw;
}
break;
}
case "put":
cache!.Commit(batch => batch.Upsert(Types[op.Type!], Doc(op.Type!, op.Text!), new CultRecordKey(op.Key!)));
break;
case "delete":
cache!.Commit(batch => batch.Remove(new CultRecordKey(op.Key!)));
break;
default:
throw new InvalidOperationException(op.Kind);
}
}
catch (Exception failure) when (i == row.Ops.Length - 1 &&
(failure is CultSchemaConflictException || (op.Kind == "attach" && failure is InvalidOperationException)))
{
refusal = failure;
}
}
if (refusal is CultSchemaConflictException conflict)
{
refusedKey = conflict.RecordKey;
Assert.That(row.Ops[^1].Key, Is.EqualTo(conflict.RecordKey), $"{row.Label}: the refusal names the key written or removed");
}
if (refusal is InvalidOperationException && refusal is not CultSchemaConflictException)
{
// The key is read out of the refusal's own text, not taken from the row: the second-store refusal opens "Record <key> is held by".
var named = Regex.Match(refusal.Message, @"^Record (\S+) is held by");
Assert.That(named.Success, Is.True, $"{row.Label}: the refusal opens by naming a key: {refusal.Message}");
refusedKey = named.Groups[1].Value;
var shared = Snapshot(VectorFiles.Committed(Vector, "run-store.cc")).Records.Select(record => record.Key).ToArray();
Assert.That(shared, Does.Contain(refusedKey), $"{row.Label}: the key the refusal names is the shared one");
Assert.That(Regex.Matches(refusal.Message, Regex.Escape(refusedKey)).Count, Is.EqualTo(Regex.Matches(refusal.Message, $@"(?<![A-Za-z0-9_]){Regex.Escape(refusedKey)}(?![A-Za-z0-9_])").Count),
$"{row.Label}: the key occurs in the refusal only as a delimited token, never inside a schema name or other text");
}
if (refusal != null)
Assert.That(Delimited(refusal.Message, refusedKey ?? "\0"), Is.True, $"{row.Label}: the refusal names the key as a token");
var heldNow = cache!.AllStoredDocuments
.Select(stored => new[] { Short(stored.Descriptor.SchemaName), stored.Key.Value, (string)EmittedDocumentTypes.Read(stored.Document, "Text")! })
.OrderBy(entry => entry[0], StringComparer.Ordinal).ThenBy(entry => entry[1], StringComparer.Ordinal).ToArray();
var foreign = cache.BackingStores.SelectMany(store => store.ForeignRecords).Select(record => record.Key).OrderBy(key => key, StringComparer.Ordinal).ToArray();
string[][]? file = null;
var kept = Array.Empty<string>();
if (path != null)
{
cache.FlushAsync().GetAwaiter().GetResult();
var after = Snapshot(File.ReadAllBytes(path));
file = after.Records.OrderBy(record => record.Key, StringComparer.Ordinal).Select(record => new[] { record.Key, Name(after, record) }).ToArray();
var start = before == null ? null : Snapshot(before);
kept = foreign.Where(key =>
{
var original = start?.Records.SingleOrDefault(record => record.Key == key);
return original != null && RecordBytes(original) == RecordBytes(after.Records.Single(record => record.Key == key));
}).ToArray();
}
return new Expect(refusedKey, heldNow, file, foreign, kept);
}
finally
{
cache?.Dispose();
Directory.Delete(directory, recursive: true);
}
}
// ---- rows.msgpack: [label, registered, ops, expect] ----
private static void WriteStrings(ref MessagePackWriter writer, IEnumerable<string?> values)
{
var list = values.ToArray();
writer.WriteArrayHeader(list.Length);
foreach (var value in list)
if (value == null) writer.WriteNil(); else writer.Write(value);
}
private static void WriteRows(ref MessagePackWriter writer, Row row, Expect expect)
{
writer.WriteArrayHeader(4);
writer.Write(row.Label);
WriteStrings(ref writer, row.Registered);
writer.WriteArrayHeader(row.Ops.Length);
foreach (var op in row.Ops)
{
var fields = op.Kind switch
{
"open" => new object?[] { op.Kind, op.File },
"attach" => new object?[] { op.Kind, op.File, op.Types },
"reopen" => new object?[] { op.Kind },
"put" => new object?[] { op.Kind, op.Type, op.Key, op.Text },
_ => new object?[] { op.Kind, op.Type, op.Key },
};
writer.WriteArrayHeader(fields.Length);
foreach (var field in fields)
switch (field)
{
case null: writer.WriteNil(); break;
case string[] list: WriteStrings(ref writer, list); break;
default: writer.Write((string)field); break;
}
}
writer.WriteArrayHeader(5);
if (expect.Refusal == null) writer.WriteNil(); else WriteStrings(ref writer, new[] { expect.Refusal });
writer.WriteArrayHeader(expect.Held.Length);
foreach (var held in expect.Held) WriteStrings(ref writer, held);
if (expect.File == null) writer.WriteNil();
else
{
writer.WriteArrayHeader(expect.File.Length);
foreach (var record in expect.File) WriteStrings(ref writer, record);
}
WriteStrings(ref writer, expect.Foreign);
WriteStrings(ref writer, expect.Kept);
}
private static byte[] RowsFile()
{
var buffer = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
writer.WriteArrayHeader(Table.Length);
foreach (var row in Table)
WriteRows(ref writer, row, Observe(row));
writer.Flush();
return buffer.WrittenSpan.ToArray();
}
private static string[] ReadStrings(ref MessagePackReader reader)
{
var values = new string[reader.ReadArrayHeader()];
for (var i = 0; i < values.Length; i++) values[i] = reader.ReadString()!;
return values;
}
private static (Row Row, Expect Expect) ReadRow(ref MessagePackReader reader)
{
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(4));
var label = reader.ReadString()!;
var registered = ReadStrings(ref reader);
var ops = new Op[reader.ReadArrayHeader()];
for (var i = 0; i < ops.Length; i++)
{
var length = reader.ReadArrayHeader();
var kind = reader.ReadString()!;
ops[i] = kind switch
{
"open" => new Op(kind, reader.TryReadNil() ? null : reader.ReadString()),
"attach" => new Op(kind, reader.ReadString(), ReadStrings(ref reader)),
"reopen" => new Op(kind),
"put" => new Op(kind, Type: reader.ReadString(), Key: reader.ReadString(), Text: reader.ReadString()),
"delete" => new Op(kind, Type: reader.ReadString(), Key: reader.ReadString()),
_ => throw new InvalidOperationException(kind),
};
Assert.That(length, Is.EqualTo(kind switch { "reopen" => 1, "open" => 2, "delete" => 3, "attach" => 3, _ => 4 }), $"{label}: op {kind}");
}
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(5));
var refusal = reader.TryReadNil() ? null : ReadStrings(ref reader).Single();
var held = new string[reader.ReadArrayHeader()][];
for (var i = 0; i < held.Length; i++) held[i] = ReadStrings(ref reader);
string[][]? file = null;
if (!reader.TryReadNil())
{
file = new string[reader.ReadArrayHeader()][];
for (var i = 0; i < file.Length; i++) file[i] = ReadStrings(ref reader);
}
var foreign = ReadStrings(ref reader);
var kept = ReadStrings(ref reader);
return (new Row(label, registered, ops), new Expect(refusal, held, file, foreign, kept));
}
private static string Render(Expect expect) =>
$"refusal={expect.Refusal ?? "-"} held={string.Join(";", expect.Held.Select(h => string.Join(",", h)))} " +
$"file={(expect.File == null ? "-" : string.Join(";", expect.File.Select(f => string.Join(",", f))))} " +
$"foreign={string.Join(",", expect.Foreign)} kept={string.Join(",", expect.Kept)}";
[Test]
public void KeyIdentityRowsHoldTheirVerdicts()
{
foreach (var store in Stores)
VectorFiles.Check(Vector, store.File, StoreBytes(store.Registered, store.Records));
VectorFiles.Check(Vector, "rows.msgpack", RowsFile());
var reader = new MessagePackReader(VectorFiles.Committed(Vector, "rows.msgpack"));
var count = reader.ReadArrayHeader();
Assert.That(count, Is.EqualTo(Table.Length));
var walked = 0;
var failed = new List<string>();
for (var i = 0; i < count; i++)
{
var (row, expect) = ReadRow(ref reader);
var observed = Observe(row);
if (Render(observed) == Render(expect)) walked++;
else failed.Add($"{row.Label}: committed {Render(expect)} but the reference answers {Render(observed)}");
}
Assert.That(failed, Is.Empty);
Assert.That(walked, Is.EqualTo(12), "rows walked");
}
}
}

View file

@ -148,10 +148,25 @@ namespace GameCult.Caching.Tests
}
[Test]
public void OnlyTheFirstValueIsThePayload()
public void OnlyOneValueIsAPayload()
{
Accepts(Cat(Bytes(0x90), Chain(Limit + 10)), "bytes after the first value are not walked");
Accepts(Cat(Bytes(0x01), Chain(Limit + 10)), "nor after a scalar");
var tails = new[]
{
(Cat(Bytes(0x90), Chain(Limit + 10)), Chain(Limit + 10).Length),
(Cat(Bytes(0x01), Chain(Limit + 10)), Chain(Limit + 10).Length),
(Bytes(0x90, 0xc1, 0xc1), 2),
};
foreach (var (payload, count) in tails)
foreach (var direction in new[] { PayloadDirection.Read, PayloadDirection.Write })
{
var message = Assert.Throws<MessagePackSerializationException>(() => CultPayloadDepth.Check(payload, typeof(PayloadDepthTests), direction))!.Message;
var word = direction == PayloadDirection.Read ? "read" : "write";
Assert.That(message, Is.EqualTo($"{typeof(PayloadDepthTests).FullName} payload has {count} bytes after its first MessagePack value (refused on {word})."));
}
// The tail is not walked: a tail nested past the limit is refused as bytes after the value, not as depth.
var deep = Assert.Throws<MessagePackSerializationException>(() => CultPayloadDepth.Check(Cat(Bytes(0x90), Chain(Limit + 10)), typeof(object), PayloadDirection.Read))!;
Assert.That(deep.Message, Does.Not.Contain("the limit is"));
}
}
}

View file

@ -0,0 +1,639 @@
#nullable enable
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using GameCult.Caching.MessagePack;
using MessagePack;
using MessagePack.Formatters;
using NUnit.Framework;
using Surface = GameCult.Caching.Tests.UnionParityVectorTests.UnionSurface;
namespace GameCult.Caching.Tests
{
// cultlib-gaps:ruling:extra-trailing-slots and :slot-mark-consent. A record whose payload carries a slot past its reader's declared
// slots, at any depth, is held read-only: every staged write or removal of it is refused before anything is staged. A compatible-id
// declaration consents only to depth-0 slots the catalog comparison reports ignored under a listed compatible id.
public class SlotReadOnlyTests
{
private const string Extra = "extra-slot";
// Slot 1 and 2 are retired gaps.
[CultDocument("tests.slot_gap", "tests.slot_gap.v1")]
[MessagePackObject]
public sealed class GapDoc
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(3)] public string Tail = string.Empty;
}
private static readonly CultDocumentRegistry SurfaceRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(Surface) });
private static readonly CultDocumentRegistry GapRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(GapDoc) });
private static readonly CultRecordKey Marked = new("marked");
private static readonly CultRecordKey Clean = new("clean");
private static readonly CultRecordKey Unheld = new("unheld");
private string _directory = string.Empty;
[SetUp]
public void CreateDirectory()
{
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-slotro-{Guid.NewGuid():N}");
Directory.CreateDirectory(_directory);
}
[TearDown]
public void DeleteDirectory()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
private static CultCache Open(string path, CultDocumentRegistry registry) =>
CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry });
private static byte[] Emit(WriterAction action)
{
var buffer = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
action(ref writer);
writer.Flush();
return buffer.WrittenSpan.ToArray();
}
private delegate void WriterAction(ref MessagePackWriter writer);
// A store of two records of the given type, the marked one carrying the payload and the clean one an ordinary value.
private string Seed(CultDocumentRegistry registry, Type type, Func<object> clean, byte[] markedPayload, string name = "slots.cc")
{
var path = Path.Combine(_directory, name);
using (var cache = Open(path, registry))
{
cache.UpsertAsync(type, clean(), Marked).GetAwaiter().GetResult();
cache.UpsertAsync(type, clean(), Clean).GetAwaiter().GetResult();
cache.FlushAsync().GetAwaiter().GetResult();
}
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
snapshot.Records.Single(record => record.Key == Marked.Value).Payload = markedPayload;
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
return path;
}
private string SeedSurface(string vector) =>
Seed(SurfaceRegistry, typeof(Surface), () => new Surface { Id = "x" }, VectorFiles.Committed("union-parity", vector));
private static CultStoredDocument Stored(CultCache cache, CultRecordKey key) => cache.GetStored(key)!;
// ---- a mark at every depth, with the type and both counts ----
[TestCase("extra-slot-record.msgpack", "UnionSurface", 8, 7, 1)]
[TestCase("extra-slot-nested-record.msgpack", nameof(UnionParityVectorTests.Composition), 3, 2, 1)]
[TestCase("extra-slot-arm.msgpack", nameof(UnionParityVectorTests.Box), 3, 2, 1)]
[TestCase("extra-slot-list-element.msgpack", nameof(UnionParityVectorTests.Text), 3, 2, 1)]
[TestCase("depth-skipped-at-limit.msgpack", "UnionSurface", 8, 7, 1)]
public void AMarkAtAnyDepthHoldsTheRecordReadOnly(string vector, string typeName, int slots, int declared, int occurrences)
{
var path = SeedSurface(vector);
using var cache = Open(path, SurfaceRegistry);
var unread = Stored(cache, Marked).UnreadSlots;
Assert.That(unread, Is.Not.Null, vector);
Assert.That(unread!.TypeName, Is.EqualTo(typeName), vector);
Assert.That(unread.SlotCount, Is.EqualTo(slots), vector);
Assert.That(unread.DeclaredCount, Is.EqualTo(declared), vector);
Assert.That(unread.Occurrences, Is.EqualTo(occurrences), vector);
Assert.That(unread.Explained, Is.False, "the reader's own id is not a compatible-id declaration: nothing explains the mark");
Assert.That(cache.IsWritable(Marked), Is.False, vector);
Assert.That(cache.Get<Surface>(Marked), Is.Not.Null, "a read-only record is still served");
Assert.That(Stored(cache, Clean).UnreadSlots, Is.Null);
Assert.That(cache.IsWritable(Clean), Is.True);
Assert.That(cache.IsWritable(Unheld), Is.True, "a key no store holds is writable");
}
// ---- every staged write and removal of a marked key is refused, nothing else is ----
private static void AssertSlotRefusal(TestDelegate act, string what, string type, int slots, int declared)
{
var refusal = Assert.Throws<CultSchemaConflictException>(act, what)!;
Assert.That(refusal.UnreadSlots, Is.Not.Null, what);
Assert.That(refusal.RecordKey, Is.EqualTo("marked"), what);
Assert.That(refusal.Message, Does.Contain("is read-only: " + type + " was read with " + slots + " slots where this build declares " + declared)
.And.Contain("record marked"), what);
Assert.That(refusal.Message, Does.Not.Contain(Extra), $"{what}: the refusal never echoes a slot value");
}
[Test]
public void EveryWriteAndRemovalOfAMarkedRecordIsRefusedAndAnotherKeyStillWrites()
{
var path = SeedSurface("extra-slot-nested-record.msgpack");
var before = File.ReadAllBytes(path);
using (var cache = Open(path, SurfaceRegistry))
{
var composition = nameof(UnionParityVectorTests.Composition);
AssertSlotRefusal(() => cache.Commit(batch => batch.Upsert(new Surface { Id = "new" }, new CultRecordHandle<Surface>(Marked))), "upsert", composition, 3, 2);
AssertSlotRefusal(() => cache.Commit(batch => batch.UpsertVariant(Marked, Clean)), "variant", composition, 3, 2);
AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "remove", composition, 3, 2);
AssertSlotRefusal(() => cache.Commit(batch => { batch.Upsert(new Surface { Id = "other" }, new CultRecordHandle<Surface>(Clean)); batch.Remove(Marked); }), "mixed batch", composition, 3, 2);
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "refused writes leave the store as it was");
Assert.That(cache.Commit(batch => batch.Upsert(new Surface { Id = "changed" }, new CultRecordHandle<Surface>(Clean))), Is.True);
}
Assert.That(CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Count, Is.EqualTo(2));
}
// ---- consent ----
[Test]
public void ADeclaredIdDoesNotConsentToAnUnexplainedMark()
{
// The reader's own id is declared; the mark at depth 0 is in no compatible id's comparison: still read-only.
var path = SeedSurface("extra-slot-record.msgpack");
using var cache = Open(path, SurfaceRegistry);
AssertSlotRefusal(() => cache.Commit(batch => batch.Upsert(new Surface { Id = "new" }, new CultRecordHandle<Surface>(Marked))), "depth 0 under the reader's own id", "UnionSurface", 8, 7);
AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "removal", "UnionSurface", 8, 7);
}
// Every row of slot-outcomes.msgpack, from the committed files alone: the mark, Explained and IsWritable of each record of
// extra-slot-store.cc and extra-slot-consent.cc, and a removal of the key refused exactly where IsWritable is false.
[Test]
public void EverySlotOutcomeRowHoldsAndARemovalIsRefusedExactlyWhereTheRecordIsNotWritable()
{
var reader = new MessagePackReader(VectorFiles.Committed("union-parity", "slot-outcomes.msgpack"));
var rows = reader.ReadArrayHeader();
Assert.That(rows, Is.EqualTo(8));
for (var i = 0; i < rows; i++)
{
Assert.That(reader.ReadArrayHeader(), Is.EqualTo(9));
var store = reader.ReadString()!;
var key = new CultRecordKey(reader.ReadString()!);
var writable = reader.ReadBoolean();
bool? explained = reader.TryReadNil() ? null : reader.ReadBoolean();
var type = reader.TryReadNil() ? null : reader.ReadString();
var counts = new int?[3];
for (var c = 0; c < 3; c++) counts[c] = reader.TryReadNil() ? null : reader.ReadInt32();
var (slots, declared, occurrences) = (counts[0], counts[1], counts[2]);
var flattenRefusedBy = reader.TryReadNil() ? null : reader.ReadString();
var row = $"{store} {key.Value}";
var path = Path.Combine(_directory, $"{i}-{store}");
File.WriteAllBytes(path, VectorFiles.Committed("union-parity", store));
var before = File.ReadAllBytes(path);
using var cache = Open(path, UnionParityVectorTests.SlotReader(store));
var unread = Stored(cache, key).UnreadSlots;
if (explained == null)
{
Assert.That(unread, Is.Null, row);
}
else
{
Assert.That(unread, Is.Not.Null, row);
Assert.That((unread!.Explained, unread.TypeName, unread.SlotCount, unread.DeclaredCount, unread.Occurrences),
Is.EqualTo((explained.Value, type, slots, declared, occurrences)), row);
}
Assert.That(cache.IsWritable(key), Is.EqualTo(writable), row);
Assert.That(cache.IsWritable(Unheld), Is.True, "a key no store holds is writable");
if (flattenRefusedBy != null)
{
var refusal = Assert.Throws<CultSchemaConflictException>(() => cache.Commit(batch => batch.Flatten(key)), $"{row}: flatten")!;
Assert.That(refusal.RecordKey, Is.EqualTo(flattenRefusedBy), $"{row}: the refusal is the base's own");
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), $"{row}: a refused flatten leaves the store as it was");
}
if (writable)
{
Assert.That(cache.Commit(batch => batch.Remove(key)), Is.True, row);
}
else
{
var refusal = Assert.Throws<CultSchemaConflictException>(() => cache.Commit(batch => batch.Remove(key)), row)!;
Assert.That(refusal.UnreadSlots, Is.Not.Null, row);
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), $"{row}: a refused removal leaves the store as it was");
}
}
}
// Consent reaches only the slots the catalog comparison reports: the wide id's catalog reports slot 7, so a payload carrying
// slots 7 and 8 at depth 0 is not consented to, and a payload carrying slot 7 alone is.
[Test]
public void ConsentStopsAtTheSlotsTheCatalogReports()
{
var path = Path.Combine(_directory, "extra-slot-consent.cc");
File.WriteAllBytes(path, VectorFiles.Committed("union-parity", "extra-slot-consent.cc"));
var before = File.ReadAllBytes(path);
using var cache = Open(path, UnionParityVectorTests.SlotReader("extra-slot-consent.cc"));
var over = new CultRecordHandle<UnionParityVectorTests.SurfaceDeclaring>(new CultRecordKey("over-consent"));
AssertWriteAgrees(cache, over.Key, () => cache.Commit(batch => batch.Upsert(new UnionParityVectorTests.SurfaceDeclaring { Id = "over" }, over)));
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the refused write leaves the store as it was");
var consented = new CultRecordHandle<UnionParityVectorTests.SurfaceDeclaring>(new CultRecordKey("consented"));
AssertWriteAgrees(cache, consented.Key, () => cache.Commit(batch => batch.Upsert(new UnionParityVectorTests.SurfaceDeclaring { Id = "consented" }, consented)));
Assert.That(cache.IsWritable(consented.Key), Is.True);
}
// ---- carried verbatim ----
[Test]
public void AMarkedRecordIsCarriedByteForByteWhenAnotherRecordIsWritten()
{
var path = Path.Combine(_directory, "extra-slot-store.cc");
File.WriteAllBytes(path, VectorFiles.Committed("union-parity", "extra-slot-store.cc"));
var before = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
var original = before.Records.Single(record => record.Key == Marked.Value);
using (var cache = Open(path, SurfaceRegistry))
{
Assert.That(cache.IsWritable(Marked), Is.False);
Assert.That(cache.Commit(batch => batch.Upsert(new Surface { Id = "changed" }, new CultRecordHandle<Surface>(Clean))), Is.True);
}
var after = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
var carried = after.Records.Single(record => record.Key == Marked.Value);
Assert.That(carried.Payload, Is.EqualTo(original.Payload));
Assert.That(carried.SchemaId, Is.EqualTo(original.SchemaId));
Assert.That(carried.StoredAt, Is.EqualTo(original.StoredAt));
Assert.That(after.SchemaCatalog.Select(CultDocumentMessagePackSerialization.SerializeCatalogEntry).Select(Convert.ToHexString),
Is.EqualTo(before.SchemaCatalog.Select(CultDocumentMessagePackSerialization.SerializeCatalogEntry).Select(Convert.ToHexString)));
Assert.That(after.Records.Single(record => record.Key == Clean.Value).Payload, Is.Not.EqualTo(before.Records.Single(record => record.Key == Clean.Value).Payload));
}
// ---- slots short of the reader's, and gaps below the highest key, do not mark ----
[Test]
public void MissingTrailingSlotsDefaultAndAGapSlotDoesNotMark()
{
var short1 = Emit((ref MessagePackWriter w) => { w.WriteArrayHeader(1); w.Write("short"); });
var path = Seed(GapRegistry, typeof(GapDoc), () => new GapDoc { Name = "g" }, short1);
using (var cache = Open(path, GapRegistry))
{
Assert.That(cache.Get<GapDoc>(Marked)!.Tail, Is.EqualTo(string.Empty), "a missing trailing slot keeps its default");
Assert.That(Stored(cache, Marked).UnreadSlots, Is.Null);
Assert.That(cache.IsWritable(Marked), Is.True);
Assert.That(cache.Commit(batch => batch.Upsert(new GapDoc { Name = "short", Tail = "t" }, new CultRecordHandle<GapDoc>(Marked))), Is.True);
}
var gapped = Emit((ref MessagePackWriter w) => { w.WriteArrayHeader(4); w.Write("gap"); w.WriteNil(); w.Write("in a retired slot"); w.Write("tail"); });
var gapPath = Seed(GapRegistry, typeof(GapDoc), () => new GapDoc { Name = "g" }, gapped, "gap.cc");
using (var cache = Open(gapPath, GapRegistry))
{
Assert.That(Stored(cache, Marked).UnreadSlots, Is.Null, "a non-nil value in a retired gap below the highest key does not mark");
Assert.That(cache.IsWritable(Marked), Is.True);
}
}
// ---- the catalog's consent still works, and is reported as explained ----
[CultDocument("tests.slot_wide", "tests.slot_wide.v1")]
[MessagePackObject]
public sealed class WideDoc
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public string Colour = string.Empty;
[Key(2)] public string Extra = string.Empty;
}
[CultDocument("tests.slot_wide", "tests.slot_wide.v2")]
[MessagePackObject]
public sealed class NarrowDoc
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public string Colour = string.Empty;
}
[Test]
public void ADepthZeroSlotTheCatalogReportsIsExplainedAndTheUndeclaredRecordStaysUnwritable([Values] bool directory)
{
var path = Path.Combine(_directory, directory ? "store" : "store.cc");
CacheBackingStore Store() => directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path);
using (var writer = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(WideDoc) })))
{
writer.AddBackingStore(Store());
Assert.That(writer.Commit(batch => batch.Upsert(new WideDoc { Name = "w", Extra = "kept?" }, new CultRecordHandle<WideDoc>(Marked))), Is.True);
}
using var reader = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(NarrowDoc) }));
reader.AddBackingStore(Store());
var unread = Stored(reader, Marked).UnreadSlots;
Assert.That(unread, Is.Not.Null, "the store kind decodes through the marking path");
Assert.That(unread!.Explained, Is.True, "depth 0 and reported by the catalog comparison");
Assert.That(unread.TypeName, Is.EqualTo(nameof(NarrowDoc)));
Assert.That(unread.SlotCount, Is.EqualTo(3));
Assert.That(unread.DeclaredCount, Is.EqualTo(2));
Assert.That(reader.IsWritable(Marked), Is.False, "the wide id is undeclared: unowned, though the mark is explained");
Assert.Throws<CultSchemaConflictException>(() => reader.Commit(batch => batch.Remove(Marked)));
}
// ---- declared slots are the slots the type's own formatter writes ----
// MessagePack writes a non-public [Key] member of an AllowPrivate type: 2 slots, where a public-member count says 1.
[MessagePackObject(AllowPrivate = true)]
public sealed class PrivInner
{
[Key(0)] public string A = string.Empty;
[Key(1)] internal string B = string.Empty;
}
// A [Key] getter that throws on an uninitialized instance; the parameterless constructor's instance is written instead.
[MessagePackObject]
public sealed class Counted
{
private List<string> _items = new();
[Key(0)] public int Count { get => _items.Count; set => _items = Enumerable.Repeat(string.Empty, value).ToList(); }
}
// The same getter and no parameterless constructor: no instance can be written, so the guard leaves the type alone.
[MessagePackObject]
public sealed class Uncounted
{
private readonly List<string> _items;
[SerializationConstructor] public Uncounted(int count) => _items = Enumerable.Repeat(string.Empty, count).ToList();
[Key(0)] public int Count => _items.Count;
}
// A type naming its own formatter owns its layout: one [Key] slot, but the formatter writes the item count and each item.
[MessagePackObject]
[MessagePackFormatter(typeof(BagFormatter))]
public sealed class Bag
{
[Key(0)] public List<string> Items = new();
}
public sealed class BagFormatter : IMessagePackFormatter<Bag?>
{
public void Serialize(ref MessagePackWriter writer, Bag? value, MessagePackSerializerOptions options)
{
if (value == null) { writer.WriteNil(); return; }
writer.WriteArrayHeader(1 + value.Items.Count);
writer.Write(value.Items.Count);
foreach (var item in value.Items) writer.Write(item);
}
public Bag? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
if (reader.TryReadNil()) return null;
var count = reader.ReadArrayHeader();
reader.ReadInt32();
var bag = new Bag();
for (var i = 1; i < count; i++) bag.Items.Add(reader.ReadString()!);
return bag;
}
}
// A [MessagePackObject] type an assembly resolver formats as [count, items...], with no [MessagePackFormatter] attribute: the
// resolver owns its layout.
[MessagePackObject]
public sealed class ResolvedBag
{
[Key(0)] public List<string> Items = new();
}
public sealed class ResolvedBagResolver : IFormatterResolver
{
public static readonly IFormatterResolver Instance = new ResolvedBagResolver();
private static readonly IMessagePackFormatter<ResolvedBag?> Formatter = new ResolvedBagFormatter();
public IMessagePackFormatter<T>? GetFormatter<T>() => typeof(T) == typeof(ResolvedBag) ? (IMessagePackFormatter<T>)Formatter : null;
private sealed class ResolvedBagFormatter : IMessagePackFormatter<ResolvedBag?>
{
private static readonly BagFormatter Bag = new();
public void Serialize(ref MessagePackWriter writer, ResolvedBag? value, MessagePackSerializerOptions options) =>
Bag.Serialize(ref writer, value == null ? null : new Bag { Items = value.Items }, options);
public ResolvedBag? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) =>
Bag.Deserialize(ref reader, options) is { } bag ? new ResolvedBag { Items = bag.Items } : null;
}
}
// A type an assembly resolver claims by handing out MessagePack's own [Key]-layout formatter: still measured.
[MessagePackObject]
public sealed class Relayed
{
[Key(0)] public string A = string.Empty;
}
public sealed class RelayResolver : IFormatterResolver
{
public static readonly IFormatterResolver Instance = new RelayResolver();
public IMessagePackFormatter<T>? GetFormatter<T>() =>
typeof(T) == typeof(Relayed) ? global::MessagePack.Resolvers.StandardResolver.Instance.GetFormatter<T>() : null;
}
// A [MessagePackObject] collection: MessagePack writes it as its items, not by [Key].
[MessagePackObject]
public class TagList : List<string> { }
[CultDocument("tests.slot_measured", "tests.slot_measured.v1")]
[MessagePackObject]
public sealed class MeasuredDoc
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public PrivInner? Private;
[Key(2)] public Counted? Counted;
[Key(3)] public Uncounted? Uncounted;
[Key(4)] public Bag? Bag;
[Key(5)] public ResolvedBag? Resolved;
[Key(6)] public TagList? Tags;
[Key(7)] public Relayed? Relayed;
}
private static readonly CultDocumentRegistry MeasuredRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(MeasuredDoc) });
private static MeasuredDoc Measured(string member) => member switch
{
"private" => new MeasuredDoc { Name = member, Private = new PrivInner { A = "a", B = "bee" } },
"constructed" => new MeasuredDoc { Name = member, Counted = new Counted { Count = 2 } },
"unmeasurable" => new MeasuredDoc { Name = member, Uncounted = new Uncounted(2) },
"own-formatter" => new MeasuredDoc { Name = member, Bag = new Bag { Items = { "x", "y", "z" } } },
"resolver-formatted" => new MeasuredDoc { Name = member, Resolved = new ResolvedBag { Items = { "x", "y", "z" } } },
"collection" => new MeasuredDoc { Name = member, Tags = new TagList { "a", "b", "c" } },
_ => throw new ArgumentOutOfRangeException(nameof(member), member, null)
};
[TestCase("private", TestName = "APrivateKeyedMemberDoesNotMark")]
[TestCase("constructed", TestName = "ATypeMeasuredThroughItsConstructorDoesNotMark")]
[TestCase("unmeasurable", TestName = "AnUnmeasurableTypeIsNotGuarded")]
[TestCase("own-formatter", TestName = "ATypeWithItsOwnFormatterIsNotMeasuredByKeys")]
[TestCase("resolver-formatted", TestName = "ATypeAResolverFormatsIsNotMeasuredByKeys")]
[TestCase("collection", TestName = "AMessagePackObjectCollectionIsNotMeasuredByKeys")]
public void ARecordWrittenByItsOwnTypeDoesNotMark(string member)
{
var path = Path.Combine(_directory, "measured.cc");
using (var cache = Open(path, MeasuredRegistry))
{
Assert.That(cache.Commit(batch => batch.Upsert(Measured(member), new CultRecordHandle<MeasuredDoc>(Marked))), Is.True);
}
using (var cache = Open(path, MeasuredRegistry))
{
Assert.That(Stored(cache, Marked).UnreadSlots, Is.Null, member);
Assert.That(cache.IsWritable(Marked), Is.True, member);
Assert.That(cache.Commit(batch => batch.Upsert(Measured(member), new CultRecordHandle<MeasuredDoc>(Marked))), Is.True, member);
Assert.That(cache.Commit(batch => batch.Remove(Marked)), Is.True, member);
}
}
// One slot past what the formatter writes still marks, with the written count as declared.
[Test]
public void AnExtraSlotPastTheWrittenCountStillMarks()
{
var privatePayload = Emit((ref MessagePackWriter w) =>
{
w.WriteArrayHeader(5); w.Write("p");
w.WriteArrayHeader(3); w.Write("a"); w.Write("bee"); w.Write(Extra);
w.WriteNil(); w.WriteNil(); w.WriteNil();
});
var path = Seed(MeasuredRegistry, typeof(MeasuredDoc), () => new MeasuredDoc { Name = "c" }, privatePayload, "private.cc");
using (var cache = Open(path, MeasuredRegistry))
{
AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "AllowPrivate type", nameof(PrivInner), 3, 2);
Assert.That(cache.IsWritable(Clean), Is.True);
}
var countedPayload = Emit((ref MessagePackWriter w) =>
{
w.WriteArrayHeader(5); w.Write("c"); w.WriteNil();
w.WriteArrayHeader(2); w.Write(0); w.Write(Extra);
w.WriteNil(); w.WriteNil();
});
path = Seed(MeasuredRegistry, typeof(MeasuredDoc), () => new MeasuredDoc { Name = "c" }, countedPayload, "counted.cc");
using (var cache = Open(path, MeasuredRegistry))
AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "measured through its constructor", nameof(Counted), 2, 1);
// A resolver that hands out MessagePack's own [Key] formatter exempts nothing.
var relayedPayload = Emit((ref MessagePackWriter w) =>
{
w.WriteArrayHeader(8); w.Write("r");
for (var i = 0; i < 6; i++) w.WriteNil();
w.WriteArrayHeader(2); w.Write("a"); w.Write(Extra);
});
path = Seed(MeasuredRegistry, typeof(MeasuredDoc), () => new MeasuredDoc { Name = "c" }, relayedPayload, "relayed.cc");
using (var cache = Open(path, MeasuredRegistry))
AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "relayed by a consumer resolver", nameof(Relayed), 2, 1);
}
// ---- a flatten writes its base chain's decoded view: refused when a record on the chain is not owned ----
private static readonly CultRecordKey Base = new("base");
private static readonly CultRecordKey Var = new("var");
private static readonly CultRecordKey Fresh = new("fresh");
// A store holding plain record base and its variant var, base then given the payload.
private string SeedVariant(byte[]? basePayload, string name)
{
var path = Path.Combine(_directory, name);
using (var cache = Open(path, SurfaceRegistry))
{
Assert.That(cache.Commit(batch => batch.Upsert(new Surface { Id = "base" }, new CultRecordHandle<Surface>(Base))), Is.True);
Assert.That(cache.Commit(batch => batch.UpsertVariant(Var, Base, Named(cache, Var))), Is.True);
}
if (basePayload == null) return path;
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
snapshot.Records.Single(record => record.Key == Base.Value).Payload = basePayload;
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
return path;
}
// A variant overrides its name.
private static CultVariantOverride[] Named(CultCache cache, CultRecordKey key) => new[] { cache.Override<Surface>(nameof(Surface.Id), key.Value) };
private static void AssertBaseRefusal(TestDelegate act, string what, bool unreadSlots)
{
var refusal = Assert.Throws<CultSchemaConflictException>(act, what)!;
Assert.That(refusal.RecordKey, Is.EqualTo(Base.Value), $"{what}: the refusal is the base's own");
Assert.That(refusal.UnreadSlots, unreadSlots ? Is.Not.Null : Is.Null, what);
}
[Test]
public void AFlattenThroughABaseHeldForUnreadSlotsIsRefusedAndTheVariantStillWritesItsDelta()
{
var nested = VectorFiles.Committed("union-parity", "extra-slot-nested-record.msgpack");
var path = SeedVariant(nested, "flatten.cc");
var before = File.ReadAllBytes(path);
using (var cache = Open(path, SurfaceRegistry))
{
Assert.That(cache.IsWritable(Base), Is.False);
Assert.That(cache.IsWritable(Var), Is.True, "the variant persists only its delta");
AssertBaseRefusal(() => cache.Commit(batch => batch.Flatten(Var)), "Commit(Flatten)", unreadSlots: true);
AssertBaseRefusal(() => cache.FlattenAsync(Var).GetAwaiter().GetResult(), "FlattenAsync", unreadSlots: true);
AssertBaseRefusal(() => cache.Commit(batch => { batch.UpsertVariant(Fresh, Base, Named(cache, Fresh)); batch.Flatten(Fresh); }), "a new variant flattened as it lands", unreadSlots: true);
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "refused flattens leave the store as it was");
Assert.That(cache.Commit(batch => batch.UpsertVariant(Var, Base, Named(cache, Var))), Is.True, "a variant write persists only its delta");
Assert.That(cache.Commit(batch => batch.Remove(Var)), Is.True);
}
var after = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
Assert.That(after.Records.Single(record => record.Key == Base.Value).Payload, Is.EqualTo(nested), "the base's bytes stay");
}
[Test]
public void AFlattenThroughABaseUnderAnUndeclaredDroppingIdIsRefused()
{
var path = Path.Combine(_directory, "drift.cc");
using (var writer = Open(path, CultDocumentRegistry.ForTypes(new[] { typeof(WideDoc) })))
Assert.That(writer.Commit(batch => batch.Upsert(new WideDoc { Name = "base", Extra = "kept?" }, new CultRecordHandle<WideDoc>(Base))), Is.True);
using var reader = Open(path, CultDocumentRegistry.ForTypes(new[] { typeof(NarrowDoc) }));
Assert.That(reader.IsWritable(Base), Is.False);
Assert.That(reader.Commit(batch => batch.UpsertVariant(Var, Base, new[] { reader.Override<NarrowDoc>(nameof(NarrowDoc.Name), Var.Value) })), Is.True, "a variant over the unowned base persists only its delta");
var before = File.ReadAllBytes(path);
AssertBaseRefusal(() => reader.Commit(batch => batch.Flatten(Var)), "flatten over undeclared drift", unreadSlots: false);
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before));
Assert.That(reader.Commit(batch => batch.Remove(Var)), Is.True);
}
// ---- IsWritable is the write's own answer ----
// False exactly where the write throws.
private static void AssertWriteAgrees(CultCache cache, CultRecordKey key, Action write)
{
var writable = cache.IsWritable(key);
Exception? thrown = null;
try { write(); }
catch (InvalidOperationException exception) { thrown = exception; }
Assert.That(thrown == null, Is.EqualTo(writable), $"IsWritable({key.Value}) is {writable}; the write {(thrown == null ? "landed" : "threw: " + thrown.Message)}");
}
[Test]
public void IsWritableIsFalseInAReadOnlyStoreAndAFlattenThereIsNotRefusedForItsCleanBase()
{
var path = SeedVariant(null, "readonly.cc");
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = SurfaceRegistry, ReadOnly = true });
Assert.That(cache.IsWritable(Base), Is.False);
AssertWriteAgrees(cache, Base, () => cache.Commit(batch => batch.Upsert(new Surface { Id = "new" }, new CultRecordHandle<Surface>(Base))));
AssertWriteAgrees(cache, Var, () => cache.Commit(batch => batch.Remove(Var)));
var refusal = Assert.Throws<InvalidOperationException>(() => cache.Commit(batch => batch.Flatten(Var)))!;
Assert.That(refusal, Is.Not.InstanceOf<CultSchemaConflictException>(), "nothing on the chain is lost: the refusal is the store's read-only one");
Assert.That(refusal.Message, Does.Contain("read-only backing store"));
}
// A key the cache does not hold, but a store carries as a foreign record: the store's removal check refuses it.
[Test]
public void IsWritableIsFalseForAnUnheldKeyAStoreListsForeign()
{
var path = Path.Combine(_directory, "foreign.cc");
using (var writer = Open(path, GapRegistry))
Assert.That(writer.Commit(batch => batch.Upsert(new GapDoc { Name = "g" }, new CultRecordHandle<GapDoc>(Marked))), Is.True);
using var cache = Open(path, SurfaceRegistry);
Assert.That(cache.GetStored(Marked), Is.Null, "the cache does not hold a record no registered type reads");
Assert.That(cache.IsWritable(Marked), Is.False);
AssertWriteAgrees(cache, Marked, () => cache.Commit(batch => batch.Remove(Marked)));
Assert.That(cache.IsWritable(Unheld), Is.True);
}
[Test]
public void IsWritableIsFalseForARecordNoAttachedStoreIsHomeTo()
{
using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(Surface), typeof(GapDoc) }));
Assert.That(cache.Commit(batch => batch.Upsert(new Surface { Id = "memory" }, new CultRecordHandle<Surface>(Clean))), Is.True);
Assert.That(cache.IsWritable(Clean), Is.True, "a store-less cache writes its own records");
cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(_directory, "gaps.cc")), typeof(GapDoc));
Assert.That(cache.IsWritable(Clean), Is.False);
AssertWriteAgrees(cache, Clean, () => cache.Commit(batch => batch.Upsert(new Surface { Id = "x" }, new CultRecordHandle<Surface>(Clean))));
AssertWriteAgrees(cache, Clean, () => cache.Commit(batch => batch.Remove(Clean)));
Assert.That(cache.IsWritable(Unheld), Is.True, "an unheld key no store lists foreign is writable");
}
}
}

View file

@ -252,7 +252,7 @@ namespace GameCult.Caching.Tests
private static Emit ImageArm(string id, string uri, string alt) => ArmOf(5, Str(id), Str(uri), Nil, Str(alt));
// The constructed value's payload, with a part of the structure replaceable to build a refusal or an extra-slot case.
private static byte[] Surface(Emit? root = null, Emit? outcomes = null, Emit? crafted = null, Extra? extra = null, Emit? trailing = null)
private static byte[] Surface(Emit? root = null, Emit? outcomes = null, Emit? crafted = null, Extra? extra = null, params Emit[] trailing)
{
extra ??= new Extra();
root ??= BoxArm("0000000000c0", Slots(
@ -264,7 +264,7 @@ namespace GameCult.Caching.Tests
outcomes ??= Slots(ArmOf(0, Str("wait"), Str("0000000000a1")), ArmOf(1, Num(7), Str("0000000000a2")), ArmOf(2, Str("0000000000a3")));
crafted ??= ArmOf(1, Str("gear"), Num(4));
var slots = new[] { Str("surface"), Num(3), root, outcomes, ArmOf(1, Str("hammer"), Num(12)), crafted, Nil };
return Raw(Slots(trailing != null ? slots.Append(trailing).ToArray() : extra.Record ? slots.Append(ExtraSlot).ToArray() : slots));
return Raw(Slots(trailing.Length > 0 ? slots.Concat(trailing).ToArray() : extra.Record ? slots.Append(ExtraSlot).ToArray() : slots));
}
private static byte[] SerializeValue(UnionSurface value) => CultDocumentMessagePackSerialization.SerializeUntyped(value, typeof(UnionSurface), Registry);
@ -338,6 +338,73 @@ namespace GameCult.Caching.Tests
[MessagePackObject] public sealed class Floor2 : UnitOutcome { }
[MessagePackObject] public sealed class Ceiling2 : UnitOutcome { }
// The document a unit arm is placed in for a store: [Name, Outcome].
[CultDocument("tests.slot_unit", "tests.slot_unit.v1")]
[MessagePackObject]
public sealed class UnitDoc
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public UnitOutcome? Outcome;
}
// ---- the slot stores: extra-slot-store.cc, extra-slot-consent.cc and their outcomes as data ----
// The id extra-slot-consent.cc's records are stored under: UnionSurface's catalog entry with one more member, slot 7.
internal const string WideId = "tests.union_surface.wide";
// UnionSurface's members under a new version, listing the wide id compatible: the consent vector's reader.
[CultDocument("tests.union_surface", "tests.union_surface.v2", CompatibleSchemaIds = new[] { WideId })]
[MessagePackObject]
public sealed class SurfaceDeclaring
{
[Key(0)] [CultName] public string Id = string.Empty;
[Key(1)] public int Version;
[Key(2)] public Node? Root;
[Key(3)] public List<Outcome> Outcomes = new();
[Key(4)] public Item? Part;
[Key(5)] public CraftedItem? Crafted;
[Key(6)] public Node? Missing;
}
// The registry each slot store is read with.
internal static CultDocumentRegistry SlotReader(string store) => store switch
{
"extra-slot-store.cc" => CultDocumentRegistry.ForTypes(new[] { typeof(UnionSurface), typeof(UnitDoc) }),
"extra-slot-consent.cc" => CultDocumentRegistry.ForTypes(new[] { typeof(SurfaceDeclaring) }),
_ => throw new ArgumentOutOfRangeException(nameof(store), store, null)
};
// One row of slot-outcomes.msgpack: what the store's reader holds at the key. The mark fields are null for an unmarked record;
// FlattenRefusedBy is the key whose refusal refuses a Flatten of the record, null when the record is no variant or flattens.
private sealed record SlotOutcome(string Store, string Key, bool Writable, bool? Explained, string? Type, int? Slots, int? Declared, int? Occurrences, string? FlattenRefusedBy = null);
private static readonly SlotOutcome[] SlotOutcomes =
{
new("extra-slot-store.cc", "marked", false, false, nameof(Composition), 3, 2, 1),
new("extra-slot-store.cc", "clean", true, null, null, null, null, null),
new("extra-slot-store.cc", "unit-arm", false, false, nameof(Denied2), 1, 0, 1),
new("extra-slot-store.cc", "marked-variant", true, null, null, null, null, null, "marked"),
new("extra-slot-consent.cc", "consented", true, true, nameof(SurfaceDeclaring), 8, 7, 1),
new("extra-slot-consent.cc", "over-consent", false, false, nameof(SurfaceDeclaring), 9, 7, 1),
new("extra-slot-consent.cc", "nested", false, false, nameof(SurfaceDeclaring), 8, 7, 2),
new("extra-slot-consent.cc", "clean", true, null, null, null, null, null),
};
private static byte[] SlotOutcomesFile() => Raw((ref MessagePackWriter w) =>
{
w.WriteArrayHeader(SlotOutcomes.Length);
foreach (var row in SlotOutcomes)
{
w.WriteArrayHeader(9);
w.Write(row.Store); w.Write(row.Key); w.Write(row.Writable);
if (row.Explained is { } explained) w.Write(explained); else w.WriteNil();
if (row.Type is { } type) w.Write(type); else w.WriteNil();
foreach (var count in new[] { row.Slots, row.Declared, row.Occurrences })
if (count is { } value) w.Write(value); else w.WriteNil();
if (row.FlattenRefusedBy is { } refusedBy) w.Write(refusedBy); else w.WriteNil();
}
});
private static MessagePackSerializerOptions UnitOptions => CultDocumentMessagePackSerialization.OptionsFor(typeof(UnionSurface).Assembly);
// The reference writes a member-less arm as [key, []]; the other runtimes read these files and write the same bytes.
@ -413,6 +480,79 @@ namespace GameCult.Caching.Tests
}
}
// A single-file store of the records, each written as its type's value with the store's clock pinned as union-surface.cc's, then
// its payload replaced by bytes no current writer produces; each variant is written over its base (overriding the name to its
// key) before the payloads are replaced; edit changes the snapshot further before it is serialized.
private static byte[] SlotStore(CultDocumentRegistry registry, (string Key, object Value, byte[] Payload)[] records, Action<CultPersistedStoreSnapshot>? edit = null,
params (string Key, string Base)[] variants)
{
var work = Path.Combine(Path.GetTempPath(), $"cultlib-unionparity-{Guid.NewGuid():N}");
Directory.CreateDirectory(work);
try
{
var scratch = Path.Combine(work, "slots.cc");
using (var cache = CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = registry, StoreFlushOnDispose = true }))
{
cache.Clock = () => StoredAt;
foreach (var (key, value, _) in records)
cache.UpsertAsync(value.GetType(), value, new CultRecordKey(key)).GetAwaiter().GetResult();
foreach (var (key, baseKey) in variants)
Assert.That(cache.Commit(batch => batch.UpsertVariant(new CultRecordKey(key), new CultRecordKey(baseKey),
new[] { cache.Override(typeof(UnionSurface), nameof(UnionSurface.Id), key) })), Is.True);
cache.FlushAsync().GetAwaiter().GetResult();
}
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(scratch));
foreach (var (key, _, payload) in records)
snapshot.Records.Single(record => record.Key == key).Payload = payload;
edit?.Invoke(snapshot);
return CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot);
}
finally
{
Directory.Delete(work, recursive: true);
}
}
// The store the slot-readonly tests open and the other runtimes read: "marked" holds the extra-slot-nested-record payload,
// "clean" the ordinary value, "unit-arm" a tests.slot_unit document ["unit-arm", [2, ["extra-slot"]]], and "marked-variant" a
// variant of "marked" overriding only its Id: writable, but a flatten of it is refused for its base.
[Test]
public void ExtraSlotStoreVectorHoldsAMarkedACleanAndAUnitArmRecord()
{
Check("extra-slot-store.cc", SlotStore(SlotReader("extra-slot-store.cc"), new (string, object, byte[])[]
{
("marked", Constructed(), Committed("extra-slot-nested-record.msgpack")),
("clean", Constructed(), Surface()),
("unit-arm", new UnitDoc { Name = "unit-arm", Outcome = new Denied2() }, Raw(Slots(Str("unit-arm"), ArmOf(2, ExtraSlot)))),
}, null, ("marked-variant", "marked")));
}
// The consent store: every record under the wide id, whose catalog entry is UnionSurface's 7 members plus slot 7. Its reader
// lists the wide id compatible, so the comparison reports slot 7 ignored; only a depth-0 mark within that slot is consented to.
[Test]
public void ExtraSlotConsentVectorHoldsTheConsentCases()
{
Check("extra-slot-consent.cc", SlotStore(Registry, new (string, object, byte[])[]
{
("consented", Constructed(), Surface(trailing: ExtraSlot)),
("over-consent", Constructed(), Surface(trailing: new[] { ExtraSlot, ExtraSlot })),
("nested", Constructed(), Surface(extra: new Extra(Composition: true), trailing: ExtraSlot)),
("clean", Constructed(), Surface()),
}, snapshot =>
{
var entry = snapshot.SchemaCatalog.Single();
var last = entry.Members.OrderBy(member => member.Slot).Last();
entry.Members = entry.Members.Append(new CultSchemaMemberCatalogEntry { Slot = 7, MemberName = "Wider", TypeName = last.TypeName }).ToArray();
entry.SchemaId = WideId;
foreach (var record in snapshot.Records) record.SchemaId = WideId;
}));
}
// The outcomes of both slot stores as data; SlotReadOnlyTests asserts every row from the committed files.
[Test]
public void SlotOutcomesVectorMatches() => Check("slot-outcomes.msgpack", SlotOutcomesFile());
// The value names every arm shape the vectors exist to hold other runtimes to.
[Test]
public void ConstructedValueExercisesEveryArmShape()
@ -475,7 +615,7 @@ namespace GameCult.Caching.Tests
else
{
Assert.That(outcome, Is.EqualTo("read-only"), file);
// The record is read whole; what makes it read-only is the slot rule of the slot-tolerance cut, not asserted here.
// The extra slot is read past; that the record then loads read-only is asserted in SlotReadOnlyTests, store by store.
Assert.That(Describe(Decode(payload)), Is.EqualTo(golden), $"{file}: the extra slot is read past");
}
}
@ -781,7 +921,8 @@ namespace GameCult.Caching.Tests
Assert.That(File.ReadAllBytes(path), Is.EqualTo(new byte[] { 0x91 }), $"switch {off ?? "unset"} rewrites nothing");
File.Delete(path);
foreach (var body in new Action[] { UnionSurfaceVectorMatches, UnionRefusalVectorsRefused, BoundsOutcomesFromCommittedFiles })
foreach (var body in new Action[] { UnionSurfaceVectorMatches, UnionRefusalVectorsRefused, BoundsOutcomesFromCommittedFiles,
ExtraSlotStoreVectorHoldsAMarkedACleanAndAUnitArmRecord, ExtraSlotConsentVectorHoldsTheConsentCases, SlotOutcomesVectorMatches })
body();
}

View file

@ -18,43 +18,16 @@ namespace GameCult.Caching.Tests
// compares them byte for byte. Other runtimes are held to the same text and the same verdicts.
public class UniqueIndexVectorTests
{
private const string WriteVariable = "CULTCACHE_WRITE_VECTORS";
private static readonly DateTimeOffset StoredAt = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static bool Writing => Environment.GetEnvironmentVariable(WriteVariable) == "1";
private static readonly DateTimeOffset StoredAt = VectorFiles.StoredAt;
private static bool Writing => VectorFiles.Writing;
private static string VectorDirectory()
{
for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
{
var vectors = Path.Combine(dir.FullName, "tests", "vectors");
if (Directory.Exists(vectors)) return Path.Combine(vectors, "unique-index");
}
throw new DirectoryNotFoundException($"tests/vectors not found above {TestContext.CurrentContext.TestDirectory}.");
}
private static string VectorDirectory() => VectorFiles.Directory("unique-index");
private static string VectorPath(string name) => Path.Combine(VectorDirectory(), name);
private static void Check(string name, byte[] reference)
{
var path = VectorPath(name);
if (Writing)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, reference);
return;
}
private static void Check(string name, byte[] reference) => VectorFiles.Check("unique-index", name, reference);
Assert.That(File.Exists(path), Is.True, $"vector {name} is committed");
Assert.That(Convert.ToHexString(reference), Is.EqualTo(Convert.ToHexString(File.ReadAllBytes(path))), $"vector {name}");
}
private static byte[] Committed(string name)
{
var path = VectorPath(name);
Assert.That(File.Exists(path), Is.True, $"vector {name} is committed");
return File.ReadAllBytes(path);
}
private static byte[] Committed(string name) => VectorFiles.Committed("unique-index", name);
// ---- values.msgpack: [label, kind, value, text-or-nil] ----

View file

@ -513,7 +513,7 @@ namespace GameCult.Caching.Tests
}
[Test]
public void AnOverrideOfASlotTheTypeNeverHadIsIgnoredOnLoadAsAPlainRecordIgnoresAnExtraSlot()
public void AnOverrideOfASlotTheTypeNeverHadIsReadPastOnLoadAsAPlainRecordReadsPastAnExtraSlot()
{
var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base",
CultVariantOverride.Set(99, new byte[] { 0x01 }),
@ -1039,7 +1039,7 @@ namespace GameCult.Caching.Tests
}
[Test]
public void AnOverrideOfASlotTheTypeNeverHadIsReportedOnEveryLoadAndStaysStoredUntilTheVariantIsWritten()
public void AnOverrideOfASlotTheTypeNeverHadIsReportedOnEveryLoadAndHoldsTheVariantReadOnly()
{
var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base",
CultVariantOverride.Set(99, new byte[] { 0x01 }),
@ -1059,14 +1059,22 @@ namespace GameCult.Caching.Tests
{
Assert.That(reopened.BackingStores[0].LastSchemaMigrationReports.SelectMany(report => report.IgnoredExtraSlots), Is.EqualTo(new[] { 99 }));
Assert.That(reopened.Get<VariantGear>(new CultRecordKey("hand-variant"))!.Name, Is.EqualTo("hand variant"));
reopened.Commit(batch => batch.UpsertVariant(new CultRecordKey("hand-variant"), new CultRecordKey("hand-base"), new[]
var variant = new CultRecordKey("hand-variant");
Assert.That(reopened.IsWritable(variant), Is.False, "an override of a slot the type lacks, which the catalog did not report, is a mark nothing explains");
var overrides = new[]
{
reopened.Override<VariantGear>(nameof(VariantGear.Name), "hand variant"),
reopened.Override<VariantGear>(nameof(VariantGear.Code), "hand-code")
}));
};
var rewrite = Assert.Throws<CultSchemaConflictException>(() => reopened.Commit(batch => batch.UpsertVariant(variant, new CultRecordKey("hand-base"), overrides)))!;
Assert.That(rewrite.UnreadSlots, Is.Not.Null);
Assert.That(rewrite.UnreadSlots!.TypeName, Is.EqualTo(nameof(VariantGear)));
Assert.That(rewrite.UnreadSlots.SlotCount, Is.EqualTo(100));
Assert.Throws<CultSchemaConflictException>(() => reopened.Commit(batch => batch.Flatten(variant)));
Assert.Throws<CultSchemaConflictException>(() => reopened.Commit(batch => batch.Remove(variant)));
}
Assert.That(OverriddenSlotsOn(path, "hand-variant").OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 }), "a write of the variant stores only what it was given");
Assert.That(OverriddenSlotsOn(path, "hand-variant").OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2, 99 }), "the refused writes left the variant as stored");
}
private static int[] OverriddenSlotsOn(string path, string key) =>

View file

@ -0,0 +1,50 @@
#nullable enable
using System;
using System.IO;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// The one place a C#-written vector directory is found, written and compared. The C# reference is the only writer
// (CULTCACHE_WRITE_VECTORS=1, never set in CI); every other run reads the committed bytes and compares them byte for byte.
internal static class VectorFiles
{
private const string WriteVariable = "CULTCACHE_WRITE_VECTORS";
internal static readonly DateTimeOffset StoredAt = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
internal static bool Writing => Environment.GetEnvironmentVariable(WriteVariable) == "1";
internal static string Directory(string vector)
{
for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
{
var vectors = System.IO.Path.Combine(dir.FullName, "tests", "vectors");
if (System.IO.Directory.Exists(vectors)) return System.IO.Path.Combine(vectors, vector);
}
throw new DirectoryNotFoundException($"tests/vectors not found above {TestContext.CurrentContext.TestDirectory}.");
}
internal static void Check(string vector, string name, byte[] reference)
{
var path = System.IO.Path.Combine(Directory(vector), name);
if (Writing)
{
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, reference);
return;
}
Assert.That(File.Exists(path), Is.True, $"vector {name} is committed");
Assert.That(Convert.ToHexString(reference), Is.EqualTo(Convert.ToHexString(File.ReadAllBytes(path))), $"vector {name}");
}
internal static byte[] Committed(string vector, string name)
{
var path = System.IO.Path.Combine(Directory(vector), name);
Assert.That(File.Exists(path), Is.True, $"vector {name} is committed");
return File.ReadAllBytes(path);
}
}
}

View file

@ -0,0 +1,61 @@
using System;
using System.Linq;
using System.Reflection;
using FluentAssertions;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Networking;
using NUnit.Framework;
namespace GameCult.Mesh.Tests;
// The mesh's typed reads of a raw record decode the payload through the one-value rule: a tail is refused, never ignored.
public class BytesAfterPathTests
{
private const string Clause = "bytes after its first MessagePack value";
private static CultNetRawDocumentRecord WithTail(CultMeshCdnArtifactManifest manifest)
{
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(manifest, typeof(CultMeshCdnArtifactManifest)).Concat(new byte[] { 0xc0 }).ToArray();
return new CultNetRawDocumentRecord
{
SchemaId = CultDocumentRegistry.Shared.GetRequired<CultMeshCdnArtifactManifest>().SchemaId,
RecordKey = "k",
StoredAt = "1970-01-01T00:00:00.000Z",
Payload = payload,
};
}
private static CultMeshCdnArtifactManifest Manifest() => new() { ArtifactId = "a", Version = "1", ContentHash = "h" };
private static string Refusal(Action read)
{
var thrown = Assert.Catch(read)!;
return thrown is TargetInvocationException { InnerException: { } inner } ? inner.ToString() : thrown.ToString();
}
[Test]
public void ASnapshotDecodeThroughABindingRefusesATail()
{
var registry = CultMeshCdn.CreateDocumentRegistry();
var snapshot = new CultNetSnapshotResponseRawMessage { Documents = new[] { WithTail(Manifest()) } };
Refusal(() => CultMesh.DecodeSnapshotDocuments<CultMeshCdnArtifactManifest>(snapshot, registry)).Should().Contain(Clause);
}
[Test]
public void ASnapshotDecodeWithNoBindingRefusesATail()
{
var snapshot = new CultNetSnapshotResponseRawMessage { Documents = new[] { WithTail(Manifest()) } };
Refusal(() => CultMesh.DecodeSnapshotDocuments<CultMeshCdnArtifactManifest>(snapshot, new CultNetDocumentRegistry())).Should().Contain(Clause);
}
[Test]
public void ATypedReadOfOneSnapshotRecordRefusesATail()
{
var record = WithTail(Manifest());
var snapshot = new CultNetSnapshotResponseRawMessage { MessageId = "m", Documents = new[] { record } };
var read = typeof(CultMesh).GetMethod("ReadDocumentFromSnapshotResponse", BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(typeof(CultMeshCdnArtifactManifest));
Refusal(() => read.Invoke(null, new object[] { snapshot, record.SchemaId, record.RecordKey })).Should().Contain(Clause);
}
}

View file

@ -462,12 +462,12 @@ public sealed class CultMeshContentVectorTests
private static byte[] Reencode(CultMeshContentChunkResponseMessage message) =>
CultNetSchemaMessageSerialization.Serialize(message);
/// <summary>The one reader of manifest bytes: the document reader production stores and reads manifests with (CultPayloadDepth 256 included).</summary>
/// <summary>The one reader of manifest bytes: the production CultNet binding of the manifest (CultPayloadDepth 256 and the one-value rule included).</summary>
private static CultMeshCdnArtifactManifest ReadManifest(byte[] bytes) =>
(CultMeshCdnArtifactManifest)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultMeshCdnArtifactManifest), bytes);
(CultMeshCdnArtifactManifest)CultMeshCdn.CreateDocumentRegistry().GetByDocumentType(typeof(CultMeshCdnArtifactManifest))!.PayloadDeserializer(bytes);
private static byte[] Reencode(CultMeshCdnArtifactManifest manifest) =>
CultDocumentMessagePackSerialization.SerializeUntyped(manifest, typeof(CultMeshCdnArtifactManifest));
CultMeshCdn.CreateDocumentRegistry().GetByDocumentType(typeof(CultMeshCdnArtifactManifest))!.PayloadSerializer(manifest);
private static CultMeshContentChunkRequestMessage Request(string id, string hash, string key, int size) => new()
{

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Networking;
using MessagePack;
using NUnit.Framework;
@ -168,6 +169,32 @@ public sealed class CultMeshProviderSessionWireTests
});
}
[Test]
public void DecodeCommandDocumentRefusesBytesAfterItsFirstValue()
{
var command = new CultMeshProviderCommandWire
{
CommandId = "cmd-7", CommandKind = "pilot.thrust", ProviderId = "aetheria", ServiceInstanceId = "aetheria-42",
Payload = new Dictionary<string, object> { ["thrust"] = 1 }
};
var payload = Convert.FromBase64String(CultMeshProviderSessionWire.EncodePayload(command));
CultNetRawDocumentRecord Record(byte[] bytes) => new()
{
SchemaId = CultMeshProviderSessionWireContract.CommandSchema,
RecordKey = "provider-command:aetheria:aetheria-42:cmd-7",
PayloadEncoding = "messagepack",
Payload = bytes
};
Assert.That(CultMeshProviderSessionWire.DecodeCommandDocument(Record(payload)).CommandId, Is.EqualTo("cmd-7"));
var thrown = Assert.Throws<MessagePackSerializationException>(() =>
CultMeshProviderSessionWire.DecodeCommandDocument(Record(payload.Concat(new byte[] { 0xc0, 0xc0 }).ToArray())))!;
Assert.That(thrown.Message, Does.Contain("2 bytes after its first MessagePack value"));
var one = Assert.Throws<MessagePackSerializationException>(() =>
CultMeshProviderSessionWire.DecodeCommandDocument(Record(payload.Concat(new byte[] { 0xc0 }).ToArray())))!;
Assert.That(one.Message, Does.Contain("1 bytes after its first MessagePack value"));
}
[Test]
public void LeaseResponse_RoundTrips_WithApplicationAcceptance()
{

View file

@ -0,0 +1,62 @@
using System;
using System.Linq;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// A record's payload is one MessagePack value: each CultNet path that decodes a payload refuses a tail, by the one rule in
// CultPayloadDepth.Check. One test per path family, so that pointing a path at a tolerant reader turns it red.
public class BytesAfterPathTests
{
private const string Clause = "1 bytes after its first MessagePack value";
[CultDocument("tests.bytes_after_net", "tests.bytes_after_net.v1")]
[MessagePackObject]
public sealed class TailDoc
{
[Key(0)] [CultName] public string Id = "a";
[Key(1)] public int N = 3;
}
private static readonly CultDocumentRegistry Documents = CultDocumentRegistry.ForTypes(new[] { typeof(TailDoc) });
private static byte[] WithTail() =>
CultDocumentMessagePackSerialization.SerializeUntyped(new TailDoc(), typeof(TailDoc), Documents).Concat(new byte[] { 0xc0 }).ToArray();
private static CultNetRawDocumentRecord Raw(CultNetDocumentRegistry registry, byte[] payload) => new()
{
SchemaId = Documents.GetRequired(typeof(TailDoc)).SchemaId,
RecordKey = "a",
StoredAt = "1970-01-01T00:00:00.000Z",
Payload = payload,
};
private static void Refuses(TestDelegate read) =>
Assert.That(Assert.Catch(read)!.ToString(), Does.Contain(Clause));
[Test]
public void TheGenericBindingRefusesATail() =>
Refuses(() => CultNetDocumentBinding.ForDocument<TailDoc>(Documents).PayloadDeserializer(WithTail()));
[Test]
public void TheRuntimeTypeBindingRefusesATail() =>
Refuses(() => CultNetDocumentBinding.ForDocument(typeof(TailDoc), Documents).PayloadDeserializer(WithTail()));
[Test]
public void ARawEnvelopeThroughARegisteredBindingRefusesATail()
{
var registry = new CultNetDocumentRegistry(Documents).Register(CultNetDocumentBinding.ForDocument<TailDoc>(Documents));
Refuses(() => registry.DeserializeRawDocument(Raw(registry, WithTail())));
}
[Test]
public void ARawEnvelopeWithNoBindingRefusesATail()
{
var registry = new CultNetDocumentRegistry(Documents);
Refuses(() => registry.DeserializeRawDocument(Raw(registry, WithTail())));
}
}
}

View file

@ -0,0 +1,50 @@
# key-identity vectors
Written only by the C# reference, `tests/GameCult.Caching.Tests/KeyIdentityVectorTests.cs`, with
`CULTCACHE_WRITE_VECTORS=1` (never set in CI). Every other run reads these files, runs every row against the reference and
compares the files byte for byte; a changed vector is a reviewed commit. Other runtimes are held to the same verdicts
(`variants:ruling:cache-refuses-retype`, `variants:ruling:write-ownership-by-written-type`,
`variants:ruling:unheld-removal-refused-at-commit`).
RULE: a cache holds one record per key whatever its type; a write of a type that does not own the held record's schema id at
that key is refused as a schema conflict naming the key, with the cache, its store file and every foreign record
byte-identical; retyping is a delete then a put. A store that holds a record no registered type claims loads: the cache does
not serve it, lists its key as foreign, keeps its bytes through every write, and refuses a write or a removal at its key.
Registering a type that declares its schema id claims it at the next load.
## Files
- Schemas: `tests.ki_alpha`, `tests.ki_beta`, `tests.ki_gamma` (`alpha`, `beta`, `gamma` below), each one string slot
`[0: Text]`, version the name plus `.v1`. The schema ids C# writes in the store catalogs and on every record:
- `tests.ki_alpha`: `sha256:b20703a98f49c253898404ab50ba2c630720f0b8a5e1a41614d5650be22799fe`
- `tests.ki_beta`: `sha256:dee4b727cf2ee04e61ab635ff0c5f3dca7345a411dc5f1018e583ab16009cf94`
- `tests.ki_gamma`: `sha256:fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d`
The Rust writer names a catalog entry it writes by the record's type, which for these types is the id, where C# names it by
schema name; the Rust walker (`packages/cultcache-rs/tests/key_identity_vector.rs`) maps each of the three ids, and only
those, back to its schema name before comparing `file`, so the verdict is compared and the spelling divergence is not.
- `foreign-store.cc`: a single-file store, alpha `a` at key `j`, gamma `g` at key `k`, stored-at pinned to
`2026-01-01T00:00:00Z`. `catalog-store.cc`: alpha `a` at `qx`. `run-store.cc`: beta `b` at `qx` (row 11). The row-11 key is `qx` because it must
occur in the refusal's message only as the key, never inside a schema name (`k` is inside `ki_alpha`).
- `rows.msgpack`: an array of `[label, registered, ops, expect]`.
- `registered`: the types the cache registers, a subset of `[alpha, beta, gamma]`.
- `ops`, in order: `[open, file-or-nil-or-memory]` (a cache on a fresh copy of the vector file; nil is a fresh path with no
file; the string `memory` is a cache with no store), `[attach, file, [home types]]` (route one more store, a fresh copy
of the vector file, to those home types, and pull every store), `[reopen]` (dispose, then a new cache on the same path),
`[put, type, key, text]`, `[delete, type, key]` (each one commit).
- `expect` is `[refusal, held, file, foreign, kept]`. Every op but the last succeeds; the last succeeds or refuses, and in the generator only a schema conflict (or, for an attach, an invalid-operation refusal) counts as a refusal: any other exception fails the run.
`refusal` is nil or `[key]`: a typed schema-conflict refusal naming the key for a put or a delete (rows 1, 3, 5, 8, 9),
and for row 11 any refusal (an invalid-operation error, not a schema conflict) whose message contains the key as a delimited token (not inside a schema name or other word; the C# walker also pins its own `Record <key> is held by` opening, which a leaf need not copy); the key in `expect` is the one the stores share, the store file it was refused from left byte-identical.
`held` is the cache's records, sorted by type then key, as `[type, key, text]`. `file` is the store file's records after
the ops, sorted by key, as `[key, schema name]`, nil for a cache with no store. `foreign` is the keys the cache lists as
foreign, sorted. `kept` is the foreign keys whose stored record (schema id, stored-at, payload) after the ops equals the
file's before the first op.
## Rows
1 retype-refused-held-in-file, 2 retype-by-remove-then-add, 3 retype-refused-memory-cache, 4 same-type-replaces, 5
retype-refused-after-reload, 6 foreign-loads, 7 foreign-kept-by-a-write, 8 foreign-key-write-refused, 9
foreign-key-delete-refused, 10 foreign-claimed-by-a-later-registration (registered `[alpha, gamma]`: row 6's store loads
with nothing foreign), 11 key-in-two-stores-refused, 12 same-type-replaces-memory-cache (a cache with no store replaces a same-type record: admitted).
Every runtime walks 12 rows and asserts the count; none is exempt (every row is single-record ops). A leaf never registers
gamma in rows 6 and 8; only row 10 does.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
œ”»retype-refused-held-in-file’¥alpha¤beta“’¤openÀ”£put¥alpha¡k¡a”£put¤beta¡k¡b•‘¡k‘“¥alpha¡k¡a‘’¡k®tests.ki_alpha<68><61>”¹retype-by-remove-then-add’¥alpha¤beta”’¤openÀ”£put¥alpha¡k¡a“¦delete¥alpha¡k”£put¤beta¡k¡b•À‘“¤beta¡k¡b‘’¡k­tests.ki_beta<74><61>”»retype-refused-memory-cache’¥alpha¤beta“’¤open¦memory”£put¥alpha¡k¡a”£put¤beta¡k¡b•‘¡k‘“¥alpha¡k¡aÀ<61><C380>”²same-type-replaces‘¥alpha“’¤openÀ”£put¥alpha¡k¡a”£put¥alpha¡k¡b•À‘“¥alpha¡k¡b‘’¡k®tests.ki_alpha<68><61>”»retype-refused-after-reload’¥alpha¤beta”’¤openÀ”£put¥alpha¡k¡a‘¦reopen”£put¤beta¡k¡b•‘¡k‘“¥alpha¡k¡a‘’¡k®tests.ki_alpha<68><61>”­foreign-loads‘¥alpha‘’¤open°foreign-store.cc•À‘“¥alpha¡j¡a’’¡j®tests.ki_alpha’¡k®tests.ki_gamma‘¡k‘¡k”·foreign-kept-by-a-write‘¥alpha’’¤open°foreign-store.cc”£put¥alpha¡m¡m•À’“¥alpha¡j¡a“¥alpha¡m¡m“’¡j®tests.ki_alpha’¡k®tests.ki_gamma’¡m®tests.ki_alpha‘¡k‘¡k”¹foreign-key-write-refused‘¥alpha’’¤open°foreign-store.cc”£put¥alpha¡k¡x•‘¡k‘“¥alpha¡j¡a’’¡j®tests.ki_alpha’¡k®tests.ki_gamma‘¡k‘¡k”ºforeign-key-delete-refused‘¥alpha’’¤open°foreign-store.cc“¦delete¥alpha¡k•‘¡k‘“¥alpha¡j¡a’’¡j®tests.ki_alpha’¡k®tests.ki_gamma‘¡k‘¡k”Ù'foreign-claimed-by-a-later-registration’¥alpha¥gamma‘’¤open°foreign-store.cc•À’“¥alpha¡j¡a“¥gamma¡k¡g’’¡j®tests.ki_alpha’¡k®tests.ki_gamma<6D><61>”¹key-in-two-stores-refused’¥alpha¤beta“’¤open¦memory“¦attach°catalog-store.cc‘¥alpha“¦attach¬run-store.cc‘¤beta•‘¢qx‘“¥alpha¢qx¡aÀ<61><C380>”¿same-type-replaces-memory-cache‘¥alpha“’¤open¦memory”£put¥alpha¡k¡a”£put¥alpha¡k¡b•À‘“¥alpha¡k¡bÀ<62><C380>

Binary file not shown.

View file

@ -48,19 +48,93 @@ with one part replaced.
## Extra trailing slot (outcome `read-only`)
Each payload is `union-surface.msgpack` with one slot appended past the reader's last declared slot of one
record (ruling `cultlib-gaps:ruling:extra-trailing-slots`: the record is read, read-only at any depth, every write
of it refused). A reader decodes the value of `union-surface.msgpack` from each; `detail` says where the slot is.
Rulings `cultlib-gaps:ruling:extra-trailing-slots` and `cultlib-gaps:ruling:slot-mark-consent`: a payload carrying a
slot past its reader's declared slots, in any object at any depth, is read, held read-only, and every write or
removal of it is refused before anything is staged. The first four payloads are `union-surface.msgpack` with the
string `"extra-slot"` appended as one more slot of one object; a reader decodes the value of `union-surface.msgpack`
from each. A unit arm with an extra slot is pinned only as a store record, `unit-arm` in `extra-slot-store.cc`
(below): `outcomes.msgpack` keeps the nine rows the leaf walkers count.
| File | detail | Record with the extra slot |
|---|---|---|
| `extra-slot-record.msgpack` | record | the document (slot 7) |
| `extra-slot-nested-record.msgpack` | nested-record | Composition `comp` |
| `extra-slot-arm.msgpack` | arm | the Root Box arm |
| `extra-slot-list-element.msgpack` | list-element | Text `s1`, an element of Use.Slots |
An object's declared slots are the slots its type's own formatter writes for one instance (for these fixtures,
the highest key plus one); a reader derives them from the layout it writes, never from a list of public members.
Only a type the runtime writes by its own positional `[Key]` layout is checked. Exempt, as in the C# reference
(`cultlib-gaps:ruling:leaf-acceptance-follows-reference`): a type the runtime formats some other way (a
user codec or formatter, a hand-written serializer, a resolver ahead of the runtime's own that supplies a layout
of its own, a collection type written as its items; a resolver that hands out the runtime's own positional
layout exempts nothing), and a type no instance of which the runtime can write for measuring
(`cultlib-gaps:ruling:slot-unmeasurable-type`). Neither is ever marked. No vector carries an exempt type: each
runtime pins its own exemption with its own fixture.
Only an array longer than that marks: a short array
(missing trailing slots) keeps the defaults and is writable, and a non-nil value in a retired slot below the highest
key (Image's slot 2) does not mark. A union adds no depth: an arm's array is counted against the arm's own slots.
The C# reference reads these today and its slot-tolerance cut is what makes them read-only; this cut asserts only
that the value is read.
| File | detail | Object with the extra slot | Depth | Mark: type, slots carried, slots declared |
|---|---|---|---|---|
| `extra-slot-record.msgpack` | record | the document (slot 7) | 0 | UnionSurface, 8, 7 |
| `extra-slot-nested-record.msgpack` | nested-record | Composition `comp` | nested | Composition, 3, 2 |
| `extra-slot-arm.msgpack` | arm | the Root Box arm | nested | Box, 3, 2 |
| `extra-slot-list-element.msgpack` | list-element | Text `s1`, an element of Use.Slots | nested | Text, 3, 2 |
| record `unit-arm` of `extra-slot-store.cc` | unit-arm | Denied2 in `[2, ["extra-slot"]]` (union `UnitOutcome`, key 2), slot 1 of the document | 1 | Denied2, 1, 0 |
| `depth-skipped-at-limit.msgpack` (bounds file) | depth | the document (slot 7, a value of nested arrays) | 0 | UnionSurface, 8, 7 |
Each payload marks exactly one object. Four rows of `outcomes.msgpack` are `read-only` (record, nested-record, arm,
list-element); the fifth `read-only` payload is in `bounds-outcomes.msgpack`, and the unit arm's outcome is its row
in `slot-outcomes.msgpack`. The payload nesting check
runs before the mark: `depth-skipped-past-limit.msgpack` is refused, never marked.
### What a runtime asserts for a `read-only` payload
Load it as a store record under the reader's own schema id (the C# reference puts each UnionSurface payload in a
`tests.union_surface` store; the unit arm is already a store record, `unit-arm` of `extra-slot-store.cc`). Then:
1. the value is read past the extra slot;
2. the record reports the mark as in the table: the first type read with extra slots, the slots it carried and the
slots the reader declares, and the count of objects that carried extra slots (1 here);
3. the record is not writable (C#: `CultCache.IsWritable(key)` false), and a key no store holds is writable;
4. a replacing write, a variant write and a removal of the key, and a flatten of a variant whose base chain holds
the marked record, are refused before anything is staged,
with the runtime's schema conflict refusal carrying the mark; its message names the type and both counts and
never contains `extra-slot`, the slot's value;
5. a write to another key of the same store succeeds.
Consent: a mark is explained only when it is at depth 0 and the schema comparison of the record's stored id against
the reader reported ignored extra slots covering it: the record is stored under an id the reader lists compatible,
that id's catalog entry has more members, and the mark's slot count is at most the highest reported slot plus one.
Declaring that id consents to an explained mark only. Every mark in the table above is unexplained (stored under
the reader's own id), so each record stays read-only whatever is declared.
## Slot stores (`slot-outcomes.msgpack`)
Two single-file stores, each record written as a value and its payload then replaced, the store's clock pinned to
2026-01-01T00:00:00Z as `union-surface.cc`'s:
- `extra-slot-store.cc`, read with `tests.union_surface` (schema `tests.union_surface.v1`) and `tests.slot_unit`
(schema `tests.slot_unit.v1`, a document `[Name, Outcome: UnitOutcome]`). `marked` is
`extra-slot-nested-record.msgpack`; `clean` is `union-surface.msgpack`; `unit-arm` is the `tests.slot_unit`
document `["unit-arm", [2, ["extra-slot"]]]`, whose Denied2 arm sits at depth 1 (the document is depth 0; a union
adds none); `marked-variant` is a variant of `marked` overriding only `Id` (slot 0) to `"marked-variant"`.
- `extra-slot-consent.cc`: every record is stored under `tests.union_surface.wide`, whose catalog entry is
`tests.union_surface`'s seven members plus slot 7 (`Wider`). Read it with a `tests.union_surface` type of schema
`tests.union_surface.v2` (the reference names it `SurfaceDeclaring`) that lists `tests.union_surface.wide`
compatible, so the comparison reports slot 7 ignored. `consented` is `union-surface.msgpack` plus one root slot
(slot 7); `over-consent` plus two (slots 7 and 8, past the highest reported); `nested` is
`extra-slot-nested-record.msgpack` plus one root slot; `clean` is `union-surface.msgpack`.
`slot-outcomes.msgpack` is a second outcomes file, as `bounds-outcomes.msgpack` is, so readers of `outcomes.msgpack`
stay green. It is an array of rows `[store, key, writable, explained, type, slots, declared, occurrences, flattenRefusedBy]`,
one per record of both stores: `writable` is what `IsWritable` answers; `flattenRefusedBy` is nil unless the record
is a variant whose flatten is refused, and then it is the key of the base-chain record whose own refusal refuses
it (the refusal names that key; the store is unchanged). For an unmarked record `explained` through `occurrences`
are nil, otherwise they are the mark: whether it is explained, the first type read with extra slots, the slots it carried,
the slots the reader declares, and how many objects carried extra slots. A depth-0 type is the reader's document
type (the reference's names: `SurfaceDeclaring` for the consent store). For each row, open a copy of the store,
assert the mark and `IsWritable`, flatten the key when `flattenRefusedBy` is set and assert the refusal, then
remove the key: refused (with the mark, the file unchanged) exactly where
`writable` is false. In the consent store a write of `consented` lands and a write of `over-consent` is refused.
The carried-verbatim check: open `extra-slot-store.cc`, assert `marked` read-only and `clean` writable, write
`clean` and flush: `marked`'s payload bytes, schema id and stored-at, and every catalog entry's bytes, equal the
originals.
## Bounds (`bounds-outcomes.msgpack`)
@ -96,3 +170,22 @@ arrays around a canary string, which no formatter reads.
| `depth-skipped-past-limit.msgpack` | 257 | refused, depth | refuse on read though the typed depth is under 10 |
A walk of the payload must not recurse: 100000 nested arrays in an extra slot are refused on a 256 KiB stack.
## Bytes after the value (bytes-after-outcomes.msgpack)
A record's payload is exactly one MessagePack value. Each file is `union-surface.msgpack` followed by a tail the first value does not own; the C# reference writes them and every other runtime is held to the same refusal.
| File | Tail after the value | outcome, detail |
|---|---|---|
| `bytes-after-nil.msgpack` | `c0` (1 byte) | refused, bytes-after |
| `bytes-after-never-used.msgpack` | `c1 c1` (2 bytes, the never-used code) | refused, bytes-after |
| `bytes-after-deep-tail.msgpack` | 300 nested fixarray(1) around a canary string (324 bytes), nested past the limit | refused, bytes-after |
| `bytes-after-second-document.msgpack` | a second copy of `union-surface.msgpack` | refused, bytes-after |
`bytes-after-outcomes.msgpack` is an array of `[file, "refused", "UnionSurface", nil, "bytes-after"]` rows, the row shape of `bounds-outcomes.msgpack` but in its own file, so the bounds walkers (which assert 12 rows) are unchanged.
Must: refuse on read, naming the type and the count of bytes after the first value (`1`, `2`, the tail length) in a clause that says "bytes after its first MessagePack value"; never echo the canary; never report the depth refusal ("the limit is") for any of them, because the tail is not walked; and a single-file store holding `bytes-after-nil.msgpack` as a record payload refuses on open.
What a runtime walks: only the first value of the payload, to find where it ends (a leaf counts as complete at its own length, a container when its last child completes). It compares that end with the payload length; a payload that ends early is not bytes after the value and stays the decoder's to refuse. It never walks the tail, so the tail's own shape (a stray `c1`, 300 levels) is never judged.
A CDN manifest read as a record follows the same rule; its vectors are `contracts/cultmesh/content-acceptance-vectors.json` (`trailing_*` are refusals).

Some files were not shown because too many files have changed in this diff Show more