Compare commits

..
Author SHA1 Message Date
3d333e8466 Caching: CultCache.Update, update-with-retry as the golden-path write (golden-path-write-api) (#168) 2026-11-13 15:40:35 +00:00
f9c72a2fd2 cultcache-rs: pin try entry never waits and error 32 never retried off Windows (tests only) (#166) 2026-11-13 12:43:17 +00:00
64f4d29147 Mutation testing doc: name the revision of each narrow-pass score, restate the full-run rate (#165) 2026-11-13 12:16:41 +00:00
c229b86fde cultcache-rs: lock-file open waits through a Windows sharing violation (#164) 2026-11-13 11:16:15 +00:00
8edb2be2f4 Stryker scope extends to GameCult.Caching.Tests; CultElementIds survivors killed or recorded (mutation-testing-scope) (#163) 2026-11-13 10:09:10 +00:00
6cfd22c786 Remaining unlocked cargo mentions say --locked (locked-cargo-doc-stragglers) (#162) 2026-11-13 08:56:51 +00:00
568a60fa3e Late-link tests: the Python leak fails by name, the Rust try entry is pinned (link-late-check-lows) (#161) 2026-11-13 07:40:21 +00:00
c4a270bc7f Cargo callers on committed-lock crates run with --locked (locked-cargo-callers) (#160) 2026-11-13 07:00:14 +00:00
694d2ced10 Rust, Python and TypeScript stores re-check for a link once the store lock is held (link-late-check-rs-py-ts) (#159) 2026-11-13 06:12:54 +00:00
d7e94648ac Python: the BackingStore protocol can say absent, so the default soft push_all decides like the real stores (py-protocol-pull-absent) (#158) 2026-11-13 05:43:49 +00:00
d6708631da cultcache-rs and cultnet-rs commit Cargo.lock; CI builds them --locked (#157) 2026-11-13 05:26:13 +00:00
0e8a95ed07 cultcache-rs: the unlock-fault test tolerates a fork-exec duplicate of the lock descriptor (rs-unlock-fault-test-flake) (#156) 2026-11-13 04:55:47 +00:00
b646ba2732 IsWritable is per record: a link at a store path is the write's to refuse (link-is-writable-query) (#155) 2026-11-13 04:10:35 +00:00
adbbb780fd Python: JSONL store writes through commit_store_file (jsonl-tmp-leak-on-replace-failure) (#154) 2026-11-13 02:53:48 +00:00
558526c125 C# and Python: soft flush means one thing (soft-flush-cs-py) (#153) 2026-11-13 01:53:50 +00:00
845c024379 Rust: store replace on Windows tries POSIX-semantics rename first (store-windows-rs) (#148) 2026-11-13 01:29:21 +00:00
a85c63712b C#: store replace on Windows tries POSIX-semantics rename first (store-windows-cs) (#152) 2026-11-13 00:38:07 +00:00
73e2131d9a C#: a write through a live link at the store path is refused; read-under-lock pinned (store-link-cs) (#151) 2026-11-13 00:36:20 +00:00
3a8de542de Rust, Python, TS: a write through a live link at the store path is refused (store-link-rs-py-ts) (#150) 2026-11-13 00:01:44 +00:00
f1e72adf87 Python: store replace on Windows tries POSIX-semantics rename first (store-windows-py) (#149) 2026-11-12 22:40:27 +00:00
25f21fcc53 cultcache-ts: a pushAll through a dangling link is refused like push, not replaced (#147) 2026-11-12 21:46:59 +00:00
1045914701 cultnet-ts 0.4.0 release notes, range check in the npm publish gate, fixture closes build-path gaps (#146) 2026-11-12 21:21:41 +00:00
47e8e95d86 cultcache-py: pin the variant row of extra-slot-store.cc as a refused store (#145) 2026-11-12 20:43:56 +00:00
60e50b1cb7 Range check: build each package's own tagged version too; cultnet-ts 0.4.0 (#144) 2026-11-12 20:22:23 +00:00
4ebef4f023 cultmesh-py: pin the message_invalid answer on a tailed put under an unknown schema id (#143) 2026-11-12 19:42:55 +00:00
c3de792a7e Bytes-after residual lows: depth pin, interop tags, py shard-log tail, Rust load pins (#142) 2026-11-12 18:56:20 +00:00
dca0c5d095 C#: pin the one-value refusal on codec and Mesh single-file reads (#141) 2026-11-12 18:47:44 +00:00
498b848333 Docs: single-file lock contract, leftover temp file and pair-backup procedures (#138) 2026-11-12 18:47:43 +00:00
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
129 changed files with 9785 additions and 940 deletions

View file

@ -158,7 +158,7 @@ jobs:
# (only the interop example built, inside the C# store tests). The target directory is under RUNNER_TEMP, away from the checkout. # (only the interop example built, inside the C# store tests). The target directory is under RUNNER_TEMP, away from the checkout.
- name: Verify the CultCache document owner with the Rust cache - name: Verify the CultCache document owner with the Rust cache
shell: bash shell: bash
run: CARGO_TARGET_DIR="$RUNNER_TEMP/cultcache-rs-target" cargo test --manifest-path packages/cultcache-rs/Cargo.toml run: CARGO_TARGET_DIR="$RUNNER_TEMP/cultcache-rs-target" cargo test --locked --manifest-path packages/cultcache-rs/Cargo.toml
# tests/GameCult.Caching.Tests, the C# reference cache's own suite (about 1,260 tests, under a minute), ran in no # tests/GameCult.Caching.Tests, the C# reference cache's own suite (about 1,260 tests, under a minute), ran in no
# forge workflow; only the Mesh tests above exercised the reference. # forge workflow; only the Mesh tests above exercised the reference.

View file

@ -29,6 +29,12 @@ jobs:
steps: steps:
- name: Check out CultLib - name: Check out CultLib
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
# packages/cultnet-py/tests/test_declared_dependency_ranges.py reads the cultcache-py
# release tags and their file contents with `git show`; a shallow checkout has no tags
# and the test skips itself.
fetch-depth: 0
fetch-tags: true
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
@ -129,10 +135,10 @@ jobs:
python -m unittest discover -s packages/cultmesh-py/tests python -m unittest discover -s packages/cultmesh-py/tests
- name: Verify Rust RUDP reliability - name: Verify Rust RUDP reliability
run: cargo test --manifest-path packages/cultnet-rs/Cargo.toml rudp run: cargo test --locked --manifest-path packages/cultnet-rs/Cargo.toml rudp
- name: Verify Rust CultMesh runtime-local surface - name: Verify Rust CultMesh runtime-local surface
run: cargo test --manifest-path packages/cultmesh-rs/Cargo.toml run: cargo test --locked --manifest-path packages/cultmesh-rs/Cargo.toml
- name: Verify C# RUDP reliability - name: Verify C# RUDP reliability
run: dotnet test tests/GameCult.Networking.Tests/GameCult.Networking.Tests.csproj --filter "FullyQualifiedName~Rudp" run: dotnet test tests/GameCult.Networking.Tests/GameCult.Networking.Tests.csproj --filter "FullyQualifiedName~Rudp"

View file

@ -212,8 +212,8 @@ identity-owned C# session after provider route replacement.
Rust package tests: Rust package tests:
```powershell ```powershell
cargo test --manifest-path packages/cultcache-rs/Cargo.toml cargo test --locked --manifest-path packages/cultcache-rs/Cargo.toml
cargo test --manifest-path packages/cultnet-rs/Cargo.toml cargo test --locked --manifest-path packages/cultnet-rs/Cargo.toml
``` ```
Kotlin package build: Kotlin package build:

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 | | 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. | | 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. | | 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 - **Doors deliberately not routed.** cultnet-rs `idunn_lifecycle_brake`, `provider_health_contracts` and

View file

@ -1620,7 +1620,7 @@ The one door per runtime, and who walks through it:
- **Shared paths.** Open, flush, commit, `ReadDurable`, `ReplaceDurable`. Rust `pull_all` RS:1243 also checks `is_absent` on the store and on the lock path before it takes the shared lock; that skips lock creation only, and the verdict stays with the reader. - **Shared paths.** Open, flush, commit, `ReadDurable`, `ReplaceDurable`. Rust `pull_all` RS:1243 also checks `is_absent` on the store and on the lock path before it takes the shared lock; that skips lock creation only, and the verdict stays with the reader.
- **Cut line.** `merge-r1` h2 (C#) and h4/h6 (TS `readStoreBytes`); `mesh-single-file-reader` h1. - **Cut line.** `merge-r1` h2 (C#) and h4/h6 (TS `readStoreBytes`); `mesh-single-file-reader` h1.
- **Pinned by.** C#: `StoreReadabilityTests` `ADanglingFileLinkIsAnIoErrorOnOpenNotAnEmptyStore` :208, `ADanglingDirectoryLinkIsAnIoErrorOnOpenNotAnEmptyStore` :185, and the commit variants :196, :219. Mesh: `ADanglingLinkIsAnIoErrorOnTypedWriteRawWriteAndReadNeverAMissingFile` :107 and `OnlyNothingAtThePathIsAMissingFile` :134 (`CultMeshSingleFileStoreTests.cs`). Rust: `a_dangling_symlink_at_the_store_path_is_an_error_and_nothing_is_written` RS:5832. TS: "readStoreBytes reports nothing at the path only when the lstat finds nothing too". - **Pinned by.** C#: `StoreReadabilityTests` `ADanglingFileLinkIsAnIoErrorOnOpenNotAnEmptyStore` :208, `ADanglingDirectoryLinkIsAnIoErrorOnOpenNotAnEmptyStore` :185, and the commit variants :196, :219. Mesh: `ADanglingLinkIsAnIoErrorOnTypedWriteRawWriteAndReadNeverAMissingFile` :107 and `OnlyNothingAtThePathIsAMissingFile` :134 (`CultMeshSingleFileStoreTests.cs`). Rust: `a_dangling_symlink_at_the_store_path_is_an_error_and_nothing_is_written` RS:5832. TS: "readStoreBytes reports nothing at the path only when the lstat finds nothing too".
- **Known gaps.** The writer side still tolerates a dangling link: Rust `write_all_unlocked` RS:992-996 and TS `pushAll` TS:94-100 replace it on a whole-store write (`variants:follow_up:writer-dangling-link-tolerance`, owner `c2a-write-set`). What a replace does to a live link is `variants:follow_up:store-replace-cut` item 2. Windows junction behavior was proven at `42f05f66` and `b1ff2dcb`, not at the final head. - **Known gaps.** The writer no longer tolerates a dangling link: the reader's refusal stands for every write, whole-store `pushAll` included (Rust `a_push_all_through_a_dangling_link_is_refused_like_push_and_writes_nothing`, TS `a pushAll through a dangling link is refused like push`; `variants:follow_up:writer-dangling-link-tolerance`). What a replace does to a live link is `variants:follow_up:store-replace-cut` item 2. Windows junction behavior was proven at `42f05f66` and `b1ff2dcb`, not at the final head.
### C. Zero-byte file ### C. Zero-byte file
@ -1672,7 +1672,7 @@ The one door per runtime, and who walks through it:
- **Shared paths.** TS `pushAll` and Rust `push_all` only. - **Shared paths.** TS `pushAll` and Rust `push_all` only.
- **Cut line.** `merge-r1` h4 (TS) and h6 (Rust). - **Cut line.** `merge-r1` h4 (TS) and h6 (Rust).
- **Pinned by.** The manifest walks run a soft flush over every vector in TS (the `softPushAll` operation, TS test :2336) and Rust (`soft_push_all`, RS:5149), each with an absent and a dangling-link case; `a_soft_push_all_writes_where_nothing_is_and_refuses_a_dangling_link` RS:6019. - **Pinned by.** The manifest walks run a soft flush over every vector in TS (the `softPushAll` operation, TS test :2336) and Rust (`soft_push_all`, RS:5149), each with an absent and a dangling-link case; `a_soft_push_all_writes_where_nothing_is_and_refuses_a_dangling_link` RS:6019.
- **Known gaps.** C# and Python have no soft decision, and the C# Mesh flag is dead; no follow-up is registered (h6 `undone` names it). `writer-dangling-link-tolerance` applies to the non-soft branch. - **Known gaps.** C# and Python have no soft decision, and the C# Mesh flag is dead; no follow-up is registered (h6 `undone` names it). `writer-dangling-link-tolerance` is settled: the non-soft branch refuses a dangling link too.
### What no cut owns yet ### What no cut owns yet

View file

@ -3,7 +3,7 @@
CultLib measures its C# test suites with Stryker.NET, pinned as a repo-local CultLib measures its C# test suites with Stryker.NET, pinned as a repo-local
tool in `dotnet-tools.json`. Coverage is added project by project; a project tool in `dotnet-tools.json`. Coverage is added project by project; a project
with no `stryker-config.json` next to its test project has not been scoped with no `stryker-config.json` next to its test project has not been scoped
yet (see "Follow-ups" below for what's outstanding). yet (see "Scope and follow-ups" below for what's outstanding).
## Running it ## Running it
@ -126,14 +126,17 @@ project as precedent for the `vstest` caveat itself - that caveat is
`mtp`-runner-specific and documented above for `static readonly` field `mtp`-runner-specific and documented above for `static readonly` field
initializers, and GameCult.Geometry has never actually hit it. initializers, and GameCult.Geometry has never actually hit it.
## Follow-ups ## Scope and follow-ups
CultMath.Tests and GameCult.Geometry.Tests are the only projects currently CultMath.Tests, GameCult.Geometry.Tests and GameCult.Caching.Tests are the
scoped. GameCult.Geometry's `stryker-config.json` has no `test-runner` projects currently scoped. GameCult.Geometry's `stryker-config.json` has no
entry: the project is NUnit 4 with `NUnit3TestAdapter` and `test-runner` entry: the project is NUnit 4 with `NUnit3TestAdapter` and
`Microsoft.NET.Test.Sdk`, so Stryker's default `vstest` runner applies `Microsoft.NET.Test.Sdk`, so Stryker's default `vstest` runner applies
directly (no `mtp` workaround needed, unlike CultMath.Tests's xunit.v3 directly (no `mtp` workaround needed, unlike CultMath.Tests's xunit.v3
suite). suite). GameCult.Caching.Tests is the same shape (NUnit, default `vstest`)
and its `stryker-config.json` is driven the same way, by the repo-local
`dotnet-stryker` tool: `dotnet tool restore` once, then `dotnet tool run
dotnet-stryker` from `tests/GameCult.Caching.Tests`.
GameCult.Geometry has only ever been run `--since:`-scoped to the cut that GameCult.Geometry has only ever been run `--since:`-scoped to the cut that
added surface nets; a full-project baseline (running mutation against all of added surface nets; a full-project baseline (running mutation against all of
@ -151,7 +154,83 @@ file, anything under `Fixtures/`) loses `--since` line-level scoping for that
run; a future cut whose diff is C#-only, against a base commit that already run; a future cut whose diff is C#-only, against a base commit that already
has `stryker-config.json`, should scope correctly. has `stryker-config.json`, should scope correctly.
`GameCult.Caching`, `GameCult.Mesh` (+ `.Quic`, `.Quic.Native`), ### GameCult.Caching.Tests
`tests/GameCult.Caching.Tests/stryker-config.json` mutates all of
`src/GameCult.Caching` (the project is `GameCult.Caching.csproj`; Standard
mutation level, `break` 0, the same thresholds as Geometry). Caching was
scoped before Networking because its suite is deterministic and quick (1415
tests, about 40 s) over about 7.8k lines of source, where GameCult.Networking
is about 20k lines and its tests lean on socket timing, so a mutant timing out
there says little about the test. A full Caching run is about 2,900 candidate
mutants (3,602 created, minus 730 that fail to compile) at 1.25 to 1.6 s
each, so about 1 to 1.3 hours; run it detached and read the JSON report,
never as one attached session.
The first measurement was a narrow pass over the three files that carry the
store-replace and element-id machinery, from `tests/GameCult.Caching.Tests`:
```bash
dotnet tool run dotnet-stryker \
-m '**/src/GameCult.Caching/StoreReplace.cs' \
-m '**/src/GameCult.Caching/CultStoreLinks.cs' \
-m '**/src/GameCult.Caching/CultElementIds.cs'
```
A clean single run of that command tests 243 to 265 mutants (the count moves with the revision) and scored 85.28% on
568a60fa, before any test of this scope was added; 86.79% on d38b5728, with
the first batch of tests; and 87.17% on 76d9e7d1, with the throwing-enumerator
case, in about 10 minutes. The first measurement of it overlapped two Stryker
runs, so its numbers (and the 114 survivors it showed for `CultElementIds.cs`,
where tests that kill a mutant by hand reported `Survived` or `Timeout`) are
not measurements and are not quoted here. Never run two Stryker runs against
one checkout at once, and check `ps` before starting one. A clean single run
on `CultElementIds.cs` alone, after the tests below, tested 228 mutants: 208
`Killed`, 8 `Timeout`, 12 `Survived`, a score of 94.74%. The 12th survivor
was 321, which the throwing-enumerator case in `ListsOfValuesHoldNoIds` now
kills; the other 11 are equivalent mutants. With that case a clean single run
on `CultElementIds.cs` is 209 `Killed`, 8 `Timeout`, 11 `Survived`, 95.18%.
Real gaps that run exposed, each now pinned by `ElementIdWalkTests` (and each
failing when its mutant is applied by hand): the order of the id path (the
codec refuses at the first type MessagePack cannot allow, so order is what an
error names first; `Append` to `Prepend` at 163, 167 and 180); the
union-cycle guard in `ConcreteTypes` (248; the mutant is a stack overflow, so
the red is a crashed test host: a depth guard would need production code in
`ConcreteTypes`, since the recursion is private and a stack overflow cannot be
caught by the test, so the regression test is left to crash loudly); a list of values reporting that it holds an
id (`Holds`, 322); `[Key]` read through an override (`KeyOf`, 583); and the
`IsWrittenWhole` and `IsKeyed` predicates (629 to 631 and 636: formatter,
`[MessagePackObject]` and `KeyAsPropertyName` inherited from a base, and the
`[DataContract]` without `[MessagePackObject]` and `||` arms).
Equivalent survivors in `CultElementIds.cs` (11), each hand-checked against the full
suite; none can be told apart by any test:
- 142, 251, 636 (second arm), 506 (`IsDefined(UnionAttribute, false)` to
`true`): `UnionAttribute` is `Inherited = false`, so its `inherit` argument
changes nothing. Likewise 630's `DataContractAttribute` `inherit` argument
(`DataContractAttribute` is `Inherited = false`).
- 264, 542: `group.First()` to `FirstOrDefault()` over `GroupBy` groups, which
are never empty.
- 491: `new StringBuilder(bytes.Length * 2)` to `/ 2` changes only the initial
capacity.
- 578: removing the `Nullable.GetUnderlyingType` unwrap in `WalksAsOwner`;
`Classify` unwraps nullables itself, so the unwrap is dead. A cut
candidate, not a test gap.
- 645, 657: `Append(type)` to `Prepend(type)` in `ElementTypeOf` and
`MapTypes`; `type` matches only when it is itself `IEnumerable<>`,
`IDictionary<,>` or `IReadOnlyDictionary<,>`, and none of those has a second
matching interface, so the position of `type` never decides the answer.
The Windows-only P/Invoke group is exercised only on Windows: its mutants are
`NoCoverage` on Linux and are a target for a Windows-leg mutation run, not a
Linux gap or a finding. `StoreReplace.cs` line 45 is a different case: its
negated `IsOSPlatform(Windows)` mutant is `Survived` on Linux, because the
negated branch calls `ReplaceWindows`, whose `DllNotFound` fallback gives the
same result.
`GameCult.Mesh` (+ `.Quic`, `.Quic.Native`),
`GameCult.Networking` (+ `.WebSockets`), and the non-.NET runtimes `GameCult.Networking` (+ `.WebSockets`), and the non-.NET runtimes
(`cultcache-rs/py/ts`, `cultmesh-*`, `cultnet-*`) each still need their own (`cultcache-rs/py/ts`, `cultmesh-*`, `cultnet-*`) each still need their own
`stryker-config.json` (or ecosystem-equivalent tool: `cargo-mutants` for `stryker-config.json` (or ecosystem-equivalent tool: `cargo-mutants` for

View file

@ -16,7 +16,7 @@ this page.
| | CultCache | CultMath | Geometry | CultNet | CultMesh | | | CultCache | CultMath | Geometry | CultNet | CultMesh |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | | **C#** | [first call, tested](#c-first-call); [update, tested](#c-update-with-retry) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` |
| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | | **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run |
| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet | | **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet |
| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet | | **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet |
@ -70,6 +70,37 @@ public static class FirstCall
`FirstCall.Run` is the call; a second test in that file fails if the block above stops matching it. More: `src/GameCult.Caching/README.md`. `FirstCall.Run` is the call; a second test in that file fails if the block above stops matching it. More: `src/GameCult.Caching/README.md`.
## C# update with retry
Change a record you may not be the only one changing. `Update` reads the record, hands your function a private
copy, and commits the result only if the record is still the one it read. If another writer got there first, it
reads again and runs your function again, up to five times. The source is
[`tests/GameCult.Caching.Tests/UpdateCallTests.cs`](../../tests/GameCult.Caching.Tests/UpdateCallTests.cs); the
snippet reuses `Item` from the first call.
```csharp
using GameCult.Caching;
using GameCult.Caching.MessagePack;
public static class UpdateCall
{
public static CultUpdateResult<Item> Restock(string path, CultRecordKey key)
{
var cache = CultCacheMessagePack.Create(path);
return cache.Update<Item>(key, item =>
{
item ??= new Item { Name = "Potion", Category = "Consumable" };
item.Value += 10;
return item;
});
}
}
```
The write is durable when `Update` returns, with no `PushAll`. A lost race re-runs the function, so keep it free of
side effects; when the attempts run out the result says `LostRace` instead of throwing. `UpsertAsync` plus
`PushAll` is the blind opt-out: last writer wins.
## TypeScript first call ## TypeScript first call
Same shape in TypeScript: the document is a name, a schema id and a `parse`-able schema (zod here), Same shape in TypeScript: the document is a name, a schema id and a `parse`-able schema (zod here),
@ -124,7 +155,7 @@ The test imports from `../src/index` where the block reads `@gamecult/cultcache-
Same shape with `derive(DatabaseEntry)`: slots are `#[cultcache(key = N)]`, so the `.cc` payload is the Same shape with `derive(DatabaseEntry)`: slots are `#[cultcache(key = N)]`, so the `.cc` payload is the
slot-indexed array the C# runtime writes. The source is slot-indexed array the C# runtime writes. The source is
[`packages/cultcache-rs/tests/first_call.rs`](../../packages/cultcache-rs/tests/first_call.rs), run by [`packages/cultcache-rs/tests/first_call.rs`](../../packages/cultcache-rs/tests/first_call.rs), run by
`cargo test --test first_call` in `packages/cultcache-rs`; a second test in that file fails if the block `cargo test --locked --test first_call` in `packages/cultcache-rs`; a second test in that file fails if the block
below stops matching the file. The Rust runtime has no name lookup, so the read-back is by key. More: below stops matching the file. The Rust runtime has no name lookup, so the read-back is by key. More:
`packages/cultcache-rs/README.md`. `packages/cultcache-rs/README.md`.
@ -341,7 +372,7 @@ async function firstCall() {
### Rust CultNet first call ### Rust CultNet first call
Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by
`cargo test --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`. `cargo test --locked --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`.
```rust ```rust
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};

View file

@ -107,8 +107,8 @@ Authority map (also in the spec):
- `variants:follow_up:public-store-writes-behind-cache`: **leave**. It asks who may stage behind an attached cache (API - `variants:follow_up:public-store-writes-behind-cache`: **leave**. It asks who may stage behind an attached cache (API
shape); this cut changes what any staging is judged by. No collision: direct `Push`/`CommitBatch` reach the same on-disk shape); this cut changes what any staging is judged by. No collision: direct `Push`/`CommitBatch` reach the same on-disk
judge, so they get the per-key rule for free. Hands must not give direct staging a different predicate. judge, so they get the per-key rule for free. Hands must not give direct staging a different predicate.
- `variants:follow_up:writer-dangling-link-tolerance`: **leave**. Rust/TS whole-store `push_all` reading a dangling link is - `variants:follow_up:writer-dangling-link-tolerance`: **settled** (the reader owns the verdict; every runtime's whole-store
a reader/writer tolerance question, not ownership; this cut touches no leaf. `push_all` refuses a dangling link, as `push` does).
## Deferred doc findings this cut fixes ## Deferred doc findings this cut fixes

6
package-lock.json generated
View file

@ -1205,7 +1205,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.15.0", "@gamecult/cultcache-ts": "^0.15.0",
"@gamecult/cultnet-ts": "^0.3.0" "@gamecult/cultnet-ts": "^0.4.0"
}, },
"devDependencies": { "devDependencies": {
"@gamecult/cultmesh-ts": "^0.2.0", "@gamecult/cultmesh-ts": "^0.2.0",
@ -1222,7 +1222,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.15.0", "@gamecult/cultcache-ts": "^0.15.0",
"@gamecult/cultnet-ts": "^0.3.0", "@gamecult/cultnet-ts": "^0.4.0",
"koffi": "3.3.0" "koffi": "3.3.0"
}, },
"devDependencies": { "devDependencies": {
@ -1236,7 +1236,7 @@
}, },
"packages/cultnet-ts": { "packages/cultnet-ts": {
"name": "@gamecult/cultnet-ts", "name": "@gamecult/cultnet-ts",
"version": "0.3.0", "version": "0.4.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.15.0", "@gamecult/cultcache-ts": "^0.15.0",

View file

@ -10,7 +10,7 @@
"scripts": { "scripts": {
"build:ts": "node scripts/run-typescript-workspaces.mjs build", "build:ts": "node scripts/run-typescript-workspaces.mjs build",
"test:ts": "node scripts/run-typescript-workspaces.mjs test", "test:ts": "node scripts/run-typescript-workspaces.mjs test",
"test:pack:ts": "node scripts/test-typescript-package-closure.mjs && node scripts/test-declared-dependency-ranges.mjs", "test:pack:ts": "node --test scripts/test-declared-dependency-ranges.test.mjs && node scripts/test-typescript-package-closure.mjs && node scripts/test-declared-dependency-ranges.mjs",
"test:eve-browser-network": "node scripts/verify-eve-browser-network.mjs" "test:eve-browser-network": "node scripts/verify-eve-browser-network.mjs"
}, },
"devDependencies": { "devDependencies": {

View file

@ -8,11 +8,17 @@
### Changed ### Changed
- `store_write_lock` looks for a link at the store path again once the lock is held, so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, instead of being replaced. `JsonLinesBackingStore` takes no cross-process lock (only its thread lock, under which it already refuses a link), so it has no lock wait to re-check across. Pinned by a test that puts the link while a foreign process holds the lock.
- `CultCache.is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store.
- `JsonLinesBackingStore` writes LF line endings on Windows. It wrote CRLF there, through `write_text` newline translation, before the commit_store_file change (#154, adbbb780).
- `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. - `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.
- `JsonLinesBackingStore` writes through the same `commit_store_file` as the single-file store: a unique staged file, fsynced before the replace and removed when the replace fails, so a refused replace no longer leaves `<path>.tmp` behind. It staged a fixed `<path>.tmp` without an fsync.
- The single-file store's `push`, `push_all` and `delete` take the cross-process lock on `<path>.lock` (flock; LockFileEx over the whole file on Windows) across the read and the replace, waiting up to `lock_timeout` seconds (30 by default, `TimeoutError` after), and exclude the C#, Rust and TypeScript runtimes' writers. The staged file is `<path>.tmp-<pid>-<uuid>`, fsynced before the replace, with a directory fsync after it off Windows. A fixed `<path>.tmp` is no longer used. - The single-file store's `push`, `push_all` and `delete` take the cross-process lock on `<path>.lock` (flock; LockFileEx over the whole file on Windows) across the read and the replace, waiting up to `lock_timeout` seconds (30 by default, `TimeoutError` after), and exclude the C#, Rust and TypeScript runtimes' writers. The staged file is `<path>.tmp-<pid>-<uuid>`, fsynced before the replace, with a directory fsync after it off Windows. A fixed `<path>.tmp` is no longer used.
### Breaking ### Breaking
- `push_all(envelopes, soft=False)` on the single-file and JSONL stores and on the `BackingStore` protocol takes the soft flag, as TypeScript, Rust and C# do: a soft push writes only where nothing is at the path; over a store the reader reads, empty included, it returns without writing, and over a file the reader cannot read it refuses (`StoreUnreadableError` for the single-file store). The `BackingStore` protocol has `pull_or_absent()`: the envelopes the reader reads, `None` when nothing is at the path, a raise when the path cannot be read; its default is `pull_all()`, which cannot say absent, so a store that inherits it is always read. The default `push_all` decides soft by it (writes where it says absent, skips over a store it read, refuses where it raises), and the single-file and JSONL stores expose their pull through it; an inheriting store that can tell nothing-at-the-path overrides `pull_or_absent`, not `push_all`. Before, a soft push through the default always skipped. A store kind that overrides `push_all(envelopes)` without the keyword fails when a caller passes `soft`.
- A write at a store path that is a link, live or dangling, is refused with the new `StoreLinkedError` (an `OSError`, exported from `cultcache_py`; `.path` names the path) before the lock and the replace, for the single-file store and for `JsonLinesBackingStore` (`push`, `push_all`, `delete`), and nothing is written. Before, a write at a live link replaced the link with a regular file, and at a dangling link both stores raised `FileNotFoundError`. A caller catching `FileNotFoundError` there now catches `StoreLinkedError` (`OSError`); configure the link's target as the store path. On Windows a junction or any reparse point counts as a link. Reads through a live link work.
- 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 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 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. - 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.

View file

@ -19,7 +19,7 @@ from .documents import (
read_payload_tree, read_payload_tree,
) )
from .leaves import SlotCodec from .leaves import SlotCodec
from .stores import JsonLinesBackingStore, SchemaConflictError, SingleFileMessagePackBackingStore, StoreUnreadableError from .stores import JsonLinesBackingStore, SchemaConflictError, SingleFileMessagePackBackingStore, StoreLinkedError, StoreUnreadableError
__all__ = [ __all__ = [
"BackingStore", "BackingStore",
@ -38,6 +38,7 @@ __all__ = [
"SchemaConflictError", "SchemaConflictError",
"SingleFileMessagePackBackingStore", "SingleFileMessagePackBackingStore",
"SlotCodec", "SlotCodec",
"StoreLinkedError",
"StoreUnreadableError", "StoreUnreadableError",
"UnionArm", "UnionArm",
"UnreadSlots", "UnreadSlots",

View file

@ -67,10 +67,20 @@ class BackingStore(Protocol):
def delete(self, type: str, key: str) -> None: def delete(self, type: str, key: str) -> None:
... ...
def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
existing = { """The envelopes the reader reads (empty included), None when nothing is at the path; raises when the path cannot be read.
(envelope.type, envelope.key): envelope for envelope in self.pull_all()
} The default is pull_all, which cannot say absent: a store that inherits it is always a store the reader read. A store kind
that can tell nothing-at-the-path overrides this, and the default push_all then writes where nothing is on a soft push."""
return self.pull_all()
def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None:
# Soft: write only where pull_or_absent says nothing is; skip over a store it read, empty included; its raise refuses.
pulled = self.pull_or_absent()
if soft and pulled is not None:
return
pulled = pulled or []
existing = {(envelope.type, envelope.key): envelope for envelope in pulled}
for envelope in envelopes: for envelope in envelopes:
existing[(envelope.type, envelope.key)] = envelope existing[(envelope.type, envelope.key)] = envelope
self._replace_all(list(existing.values())) self._replace_all(list(existing.values()))

View file

@ -636,7 +636,8 @@ class CultCache:
a record held read-only (its payload carried slots past the declared ones, at any depth, and nothing consents to the loss, so 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 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 record this document does not own. Advisory (the write still decides): it answers at call time, and a key nothing holds is
writable.""" writable. It does not look at store paths: a link at a store path is not predicted, and the write refuses it with
`StoreLinkedError`."""
self._assert_registered(document) self._assert_registered(document)
try: try:
self._refuse_at(document, key) self._refuse_at(document, key)

View file

@ -76,10 +76,17 @@ class JsonLinesBackingStore:
self._lock = threading.RLock() self._lock = threading.RLock()
def pull_all(self) -> list[CultCacheEnvelope]: def pull_all(self) -> list[CultCacheEnvelope]:
return self._pull() or []
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
return self._pull()
def _pull(self) -> list[CultCacheEnvelope] | None:
"""The envelopes the reader reads, or None when nothing is at the path."""
with self._lock: with self._lock:
text = _read_store(self.path, lambda path: path.read_text(encoding="utf-8")) text = _read_store(self.path, lambda path: path.read_text(encoding="utf-8"))
if text is None: if text is None:
return [] return None
envelopes: list[CultCacheEnvelope] = [] envelopes: list[CultCacheEnvelope] = []
for line_number, line in enumerate(text.splitlines(), start=1): for line_number, line in enumerate(text.splitlines(), start=1):
if not line.strip(): if not line.strip():
@ -101,26 +108,31 @@ class JsonLinesBackingStore:
return envelopes return envelopes
def push(self, envelope: CultCacheEnvelope) -> None: def push(self, envelope: CultCacheEnvelope) -> None:
with self._lock: self.push_all([envelope])
existing = {(item.type, item.key): item for item in self.pull_all()}
existing[(envelope.type, envelope.key)] = envelope
self._replace_all(list(existing.values()))
def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None:
with self._lock: def merge(existing: dict[tuple[str, str], CultCacheEnvelope]) -> None:
existing = {(item.type, item.key): item for item in self.pull_all()}
for envelope in envelopes: for envelope in envelopes:
existing[(envelope.type, envelope.key)] = envelope existing[(envelope.type, envelope.key)] = envelope
self._replace_all(list(existing.values())) self._rewrite(merge, soft)
def delete(self, type: str, key: str) -> None: def delete(self, type: str, key: str) -> None:
self._rewrite(lambda existing: existing.pop((type, key), None))
def _rewrite(self, change: Callable[[dict[tuple[str, str], CultCacheEnvelope]], object], soft: bool = False) -> None:
"""The one entry of every write: refuses a link at the path, then reads the file (a soft write stops here when the
reader read it), applies the change and replaces it."""
with self._lock: with self._lock:
existing = [item for item in self.pull_all() if not (item.type == type and item.key == key)] _refuse_linked(self.path)
self._replace_all(existing) arrived = self._pull()
if soft and arrived is not None:
return
existing = {(item.type, item.key): item for item in arrived or []}
change(existing)
self._replace_all(list(existing.values()))
def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None: def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
temp = self.path.with_suffix(self.path.suffix + ".tmp")
lines = [] lines = []
for envelope in sorted(envelopes, key=lambda item: (item.type, item.key)): for envelope in sorted(envelopes, key=lambda item: (item.type, item.key)):
lines.append(json.dumps({ lines.append(json.dumps({
@ -129,8 +141,7 @@ class JsonLinesBackingStore:
"payload": base64.b64encode(envelope.payload).decode("ascii"), "payload": base64.b64encode(envelope.payload).decode("ascii"),
"stored_at": envelope.stored_at, "stored_at": envelope.stored_at,
}, ensure_ascii=False, sort_keys=True)) }, ensure_ascii=False, sort_keys=True))
temp.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8") commit_store_file(self.path, ("\n".join(lines) + ("\n" if lines else "")).encode("utf-8"))
temp.replace(self.path)
class SingleFileMessagePackBackingStore: class SingleFileMessagePackBackingStore:
@ -142,13 +153,20 @@ class SingleFileMessagePackBackingStore:
self._format = STORE_FORMAT_VERSION self._format = STORE_FORMAT_VERSION
def pull_all(self) -> list[CultCacheEnvelope]: def pull_all(self) -> list[CultCacheEnvelope]:
return self._pull() or []
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
return self._pull()
def _pull(self) -> list[CultCacheEnvelope] | None:
"""The envelopes the reader reads, or None when nothing is at the path."""
with self._lock: with self._lock:
# An empty file is not a store (no writer leaves one), so it is decoded and refused. # An empty file is not a store (no writer leaves one), so it is decoded and refused.
# The disk decides the header: a file that is gone is not marked. # The disk decides the header: a file that is gone is not marked.
self._format = STORE_FORMAT_VERSION self._format = STORE_FORMAT_VERSION
data = _read_store(self.path, Path.read_bytes) data = _read_store(self.path, Path.read_bytes)
if data is None: if data is None:
return [] return None
try: try:
decoded = msgpack.unpackb(data, raw=False) decoded = msgpack.unpackb(data, raw=False)
snapshot = _decode_snapshot(decoded) snapshot = _decode_snapshot(decoded)
@ -163,9 +181,14 @@ class SingleFileMessagePackBackingStore:
with self._write_transaction(): with self._write_transaction():
self._replace_all([envelope], self.pull_all()) self._replace_all([envelope], self.pull_all())
def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None:
"""A soft push writes only where nothing is at the path: a store the reader reads is left byte for byte, and a file it
cannot read is refused by the same read, as in TS and Rust."""
with self._write_transaction(): with self._write_transaction():
self._replace_all(list(envelopes), self.pull_all()) arrived = self._pull()
if soft and arrived is not None:
return
self._replace_all(list(envelopes), arrived or [])
def delete(self, type: str, key: str) -> None: def delete(self, type: str, key: str) -> None:
with self._write_transaction(): with self._write_transaction():
@ -323,7 +346,7 @@ def commit_store_file(path: Path, data: bytes) -> None:
os.fsync(fd) os.fsync(fd)
finally: finally:
os.close(fd) os.close(fd)
os.replace(staged, path) _replace_store(staged, path)
except BaseException: except BaseException:
try: try:
os.unlink(staged) os.unlink(staged)
@ -338,11 +361,136 @@ def commit_store_file(path: Path, data: bytes) -> None:
os.close(directory) os.close(directory)
REPLACE_ATTEMPTS = 25
REPLACE_RETRY_SECONDS = 0.02
_ERROR_ACCESS_DENIED = 5
_ERROR_SHARING_VIOLATION = 32
_ERROR_LOCK_VIOLATION = 33
_POSIX_UNSUPPORTED = (1, 50, 87) # incorrect function, not supported, invalid parameter: FAT, exFAT, before Windows 10 1607
MOVEFILE_REPLACE_WRITE_THROUGH = 0x9 # MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH
class ReplaceKernel:
"""The kernel32 calls a Windows store replace needs, each answering 0 or a Win32 error code."""
def rename_posix(self, staged: str, destination: str) -> int:
"""CreateFileW(staged, DELETE|SYNCHRONIZE), then FileRenameInfoEx with REPLACE_IF_EXISTS|POSIX_SEMANTICS, then CloseHandle."""
raise NotImplementedError
def move_file_ex(self, staged: str, destination: str, flags: int) -> int:
raise NotImplementedError
def sleep(self, seconds: float) -> None:
time.sleep(seconds)
def _replace_store(staged: str | os.PathLike[str], destination: str | os.PathLike[str]) -> None:
"""Replaces `destination` with `staged`: POSIX-semantics rename first on Windows (so a reader holding the store open
keeps it and rarely sees it absent; a stalled rename can still leave it absent for 5-47 ms on NTFS,
variants:ruling:posix-rename-absent-window-document), os.replace elsewhere."""
if sys.platform == "win32":
replace_store_windows(os.fspath(staged), os.fspath(destination), WindowsReplaceKernel())
else:
os.replace(staged, destination)
def replace_store_windows(staged: str, destination: str, kernel: ReplaceKernel) -> None:
"""POSIX-semantics rename, falling back for the rest of the call to write-through MoveFileExW where POSIX semantics are
unsupported; errors 5, 32 and 33 retry up to 25 times 20 ms apart, any other code raises at once. The TypeScript and C#
runtimes take the same decision table."""
posix = True
last = 0
for _ in range(REPLACE_ATTEMPTS):
code = kernel.rename_posix(staged, destination) if posix else kernel.move_file_ex(staged, destination, MOVEFILE_REPLACE_WRITE_THROUGH)
if posix and code in _POSIX_UNSUPPORTED:
posix = False
code = kernel.move_file_ex(staged, destination, MOVEFILE_REPLACE_WRITE_THROUGH)
if code == 0:
return
if code not in (_ERROR_ACCESS_DENIED, _ERROR_SHARING_VIOLATION, _ERROR_LOCK_VIOLATION):
raise _replace_error(f"CultCache could not replace the store: Win32 error {code}.", code)
last = code
kernel.sleep(REPLACE_RETRY_SECONDS)
raise _replace_error(f"CultCache could not replace the store after {REPLACE_ATTEMPTS} attempts: last Win32 error {last}.", last)
def _replace_error(message: str, code: int) -> OSError:
error = OSError(message)
error.winerror = code # type: ignore[attr-defined]
return error
class WindowsReplaceKernel(ReplaceKernel):
"""kernel32 bound by ctypes, as _try_lock_windows binds it. Windows only."""
def __init__(self) -> None:
import ctypes
from ctypes import wintypes
self._ctypes = ctypes
k = self._k = ctypes.WinDLL("kernel32", use_last_error=True)
k.CreateFileW.restype = wintypes.HANDLE
k.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
k.SetFileInformationByHandle.restype = wintypes.BOOL
k.SetFileInformationByHandle.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD]
k.CloseHandle.restype = wintypes.BOOL
k.CloseHandle.argtypes = [wintypes.HANDLE]
k.MoveFileExW.restype = wintypes.BOOL
k.MoveFileExW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD]
def rename_posix(self, staged: str, destination: str) -> int:
ctypes, k = self._ctypes, self._k
delete_synchronize, share_all, open_existing, normal = 0x00110000, 0x7, 3, 0x80
handle = k.CreateFileW(staged, delete_synchronize, share_all, None, open_existing, normal, None)
if handle is None or ctypes.c_void_p(handle).value in (None, ctypes.c_void_p(-1).value):
return ctypes.get_last_error()
try:
# FILE_RENAME_INFO: Flags (DWORD), RootDirectory (pointer-aligned HANDLE), FileNameLength (DWORD), FileName (WCHAR[]).
name = os.path.abspath(destination).encode("utf-16-le")
pointer = ctypes.sizeof(ctypes.c_void_p)
length_at = pointer + pointer
info = ctypes.create_string_buffer(length_at + 4 + len(name) + 2)
info[0:4] = (0x3).to_bytes(4, "little") # FILE_RENAME_FLAG_REPLACE_IF_EXISTS | FILE_RENAME_FLAG_POSIX_SEMANTICS
info[length_at:length_at + 4] = len(name).to_bytes(4, "little")
info[length_at + 4:length_at + 4 + len(name)] = name
file_rename_info_ex = 22
if k.SetFileInformationByHandle(handle, file_rename_info_ex, info, len(info)):
return 0
return ctypes.get_last_error()
finally:
k.CloseHandle(handle)
def move_file_ex(self, staged: str, destination: str, flags: int) -> int:
return 0 if self._k.MoveFileExW(staged, destination, flags) else self._ctypes.get_last_error()
class StoreLinkedError(OSError):
"""A write was asked of a store path that is a link: a replace there would swap the link for a regular file."""
def __init__(self, path: Path) -> None:
super().__init__(f"CultCache store {path} is a link, and a write at it would replace the link; configure the link's target as the store path.")
self.path = path
def _refuse_linked(path: Path) -> None:
"""Refuses a link at the store path: a file or directory link, and on Windows a junction or any reparse point (islink misses
junctions before Python 3.12). Absent is not a link."""
linked = os.path.islink(path)
if not linked and sys.platform == "win32":
try:
linked = bool(os.lstat(path).st_file_attributes & 0x400) # FILE_ATTRIBUTE_REPARSE_POINT
except FileNotFoundError:
pass
if linked:
raise StoreLinkedError(path)
@contextmanager @contextmanager
def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Iterator[None]: def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Iterator[None]:
"""Holds the exclusive store lock on `<path>.lock`, the lock the C#, Rust and TypeScript runtimes take: flock on Unix, """Holds the exclusive store lock on `<path>.lock`, the lock the C#, Rust and TypeScript runtimes take: flock on Unix,
LockFileEx over the whole file on Windows, polled every 10 ms with no handle held between polls. The lock file is LockFileEx over the whole file on Windows, polled every 10 ms with no handle held between polls. The lock file is
created if absent and never written or deleted.""" created if absent and never written or deleted."""
_refuse_linked(path)
lock_path = path.with_name(path.name + ".lock") lock_path = path.with_name(path.name + ".lock")
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while True: while True:
@ -353,6 +501,8 @@ def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Itera
raise TimeoutError(f"CultCache timed out after {timeout} s waiting for the store lock {lock_path}.") raise TimeoutError(f"CultCache timed out after {timeout} s waiting for the store lock {lock_path}.")
time.sleep(_LOCK_POLL_SECONDS) time.sleep(_LOCK_POLL_SECONDS)
try: try:
# A link may have been put at the path while the lock was awaited; the look above only saw the path before.
_refuse_linked(path)
yield yield
finally: finally:
release() release()

View file

@ -12,6 +12,7 @@ from cultcache_py import (
CultCache, CultCache,
JsonLinesBackingStore, JsonLinesBackingStore,
SingleFileMessagePackBackingStore, SingleFileMessagePackBackingStore,
StoreLinkedError,
StoreUnreadableError, StoreUnreadableError,
define_database_entry_type, define_database_entry_type,
define_document_type, define_document_type,
@ -1431,10 +1432,10 @@ class CultCacheTests(unittest.TestCase):
self.assertNotIn("SECRET", str(caught.exception)) self.assertNotIn("SECRET", str(caught.exception))
self.assertIn("not a valid store", str(caught.exception)) self.assertIn("not a valid store", str(caught.exception))
# What a write does through a symbolic link today. R3 decides whether a write resolves # A write at a store path that is a link is refused: a replace there would swap the link for a regular file and
# the link or refuses it; this pins the current behaviour so that change is deliberate. # detach the store from its target. Reads through a live link keep working.
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here") @unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
def test_r3_decides_resolve_or_refuse_a_push_through_a_live_link_replaces_the_link(self) -> None: def test_a_write_at_a_live_link_is_refused_and_nothing_changes_while_a_read_through_it_works(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
volume = Path(tmp) / "volume" volume = Path(tmp) / "volume"
volume.mkdir() volume.mkdir()
@ -1442,12 +1443,91 @@ class CultCacheTests(unittest.TestCase):
envelope = CultCacheEnvelope(key="a", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z") envelope = CultCacheEnvelope(key="a", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z")
SingleFileMessagePackBackingStore(target).push(envelope) SingleFileMessagePackBackingStore(target).push(envelope)
before = target.read_bytes() before = target.read_bytes()
link = Path(tmp) / "store.cc" volume_before = sorted(p.name for p in volume.iterdir())
link = Path(tmp) / "link.cc"
link.symlink_to(target) link.symlink_to(target)
SingleFileMessagePackBackingStore(link).push(replace(envelope, key="b")) store = SingleFileMessagePackBackingStore(link)
self.assertFalse(link.is_symlink()) for write in (
self.assertTrue(link.is_file()) lambda: store.push(replace(envelope, key="b")),
lambda: store.push_all([replace(envelope, key="b")]),
lambda: store.delete("t", "a"),
):
with self.assertRaises(StoreLinkedError) as caught:
write()
self.assertIn(str(link), str(caught.exception))
self.assertTrue(link.is_symlink())
self.assertEqual(target.read_bytes(), before) self.assertEqual(target.read_bytes(), before)
self.assertEqual(sorted(p.name for p in Path(tmp).iterdir()), ["link.cc", "volume"])
self.assertEqual(sorted(p.name for p in volume.iterdir()), volume_before)
self.assertEqual([item.key for item in store.pull_all()], ["a"])
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
def test_a_json_lines_write_at_a_live_link_is_refused_and_the_link_survives(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
volume = Path(tmp) / "volume"
volume.mkdir()
target = volume / "store.jsonl"
envelope = CultCacheEnvelope(key="a", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z")
JsonLinesBackingStore(target).push(envelope)
before = target.read_bytes()
link = Path(tmp) / "link.jsonl"
link.symlink_to(target)
store = JsonLinesBackingStore(link)
for write in (
lambda: store.push(replace(envelope, key="b")),
lambda: store.push_all([replace(envelope, key="b")]),
lambda: store.delete("t", "a"),
):
with self.assertRaises(StoreLinkedError):
write()
self.assertTrue(link.is_symlink())
self.assertEqual(target.read_bytes(), before)
self.assertEqual(sorted(p.name for p in Path(tmp).iterdir()), ["link.jsonl", "volume"])
self.assertEqual(sorted(p.name for p in volume.iterdir()), ["store.jsonl"])
# The advisory query answers for the held record and the cache's judges; a link at the store path is the write's to refuse.
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
def test_is_writable_over_a_store_at_a_live_link_answers_true_while_the_cache_write_is_refused(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
volume = Path(tmp) / "volume"
volume.mkdir()
target = volume / "store.cc"
document = item_doc()
seed = CultCache.builder().register_document_type(document).add_generic_store(SingleFileMessagePackBackingStore(target)).build()
seed.put(document, "held", Item(name="Held", category="c", value=1))
before = target.read_bytes()
link = Path(tmp) / "link.cc"
link.symlink_to(target)
cache = CultCache.builder().register_document_type(document).add_generic_store(SingleFileMessagePackBackingStore(link)).build()
cache.pull_all_backing_stores()
names_before = (sorted(p.name for p in Path(tmp).iterdir()), sorted(p.name for p in volume.iterdir()))
self.assertTrue(cache.is_writable(document, "held"))
self.assertTrue(cache.is_writable(document, "fresh"))
with self.assertRaises(StoreLinkedError) as caught:
cache.put(document, "fresh", Item(name="Fresh", category="d", value=2))
self.assertEqual(Path(caught.exception.path), link)
self.assertTrue(link.is_symlink())
self.assertEqual(target.read_bytes(), before)
self.assertEqual((sorted(p.name for p in Path(tmp).iterdir()), sorted(p.name for p in volume.iterdir())), names_before)
def test_store_linked_error_is_an_os_error_carrying_the_path(self) -> None:
error = StoreLinkedError(Path("some/store.cc"))
self.assertIsInstance(error, OSError)
self.assertEqual(error.path, Path("some/store.cc"))
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
def test_a_write_at_a_live_directory_link_is_refused_as_a_link(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
real = Path(tmp) / "real"
real.mkdir()
link = Path(tmp) / "dirlink"
link.symlink_to(real, target_is_directory=True)
with self.assertRaises(StoreLinkedError):
SingleFileMessagePackBackingStore(link).push(
CultCacheEnvelope(key="a", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z"))
self.assertTrue(link.is_symlink())
self.assertEqual(list(real.iterdir()), [])
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here") @unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
def test_a_dangling_symlink_at_a_store_path_is_an_os_error_and_nothing_is_written(self) -> None: def test_a_dangling_symlink_at_a_store_path_is_an_os_error_and_nothing_is_written(self) -> None:
@ -1460,7 +1540,7 @@ class CultCacheTests(unittest.TestCase):
store = store_type(path) store = store_type(path)
with self.assertRaises(FileNotFoundError): with self.assertRaises(FileNotFoundError):
store.pull_all() store.pull_all()
with self.assertRaises(FileNotFoundError): with self.assertRaises(StoreLinkedError):
store.push(CultCacheEnvelope(key="k", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z")) store.push(CultCacheEnvelope(key="k", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z"))
self.assertTrue(path.is_symlink()) self.assertTrue(path.is_symlink())
self.assertEqual(list(volume.iterdir()), []) self.assertEqual(list(volume.iterdir()), [])

View file

@ -16,6 +16,7 @@ from cultcache_py import (
SlotCodec, SlotCodec,
SchemaConflictError, SchemaConflictError,
SingleFileMessagePackBackingStore, SingleFileMessagePackBackingStore,
StoreUnreadableError,
UnionArm, UnionArm,
UnreadSlots, UnreadSlots,
database_entry_field, database_entry_field,
@ -178,6 +179,22 @@ class StoreRowTests(unittest.TestCase):
return slot_unit_document return slot_unit_document
return declaring_document if store == "extra-slot-consent.cc" else surface_document return declaring_document if store == "extra-slot-consent.cc" else surface_document
def test_the_variant_row_is_refused_as_a_store_not_walked_and_the_rest_of_the_store_reads_without_it(self) -> None:
# This runtime holds no variant records (ruling: refuse, not carry; the reference's marked-variant row, the one
# row with a flattenRefusedBy, is the C# reader's). A five-slot persisted record makes the whole store
# unreadable, the file is left as it was, and the store without exactly that row reads.
variants = [row for row in _rows() if row[8] is not None]
self.assertEqual([(row[0], row[1], row[8]) for row in variants], [("extra-slot-store.cc", "marked-variant", "marked")])
path = self.tmp / "extra-slot-store.cc"
shutil.copyfile(VECTORS / "extra-slot-store.cc", path)
before = path.read_bytes()
with self.assertRaises(StoreUnreadableError):
_open(path, surface_document, slot_unit_document)
self.assertEqual(path.read_bytes(), before)
_, cache = self._store("extra-slot-store.cc")
self.assertIsNone(cache.get(surface_document, "marked-variant"))
self.assertIsNotNone(cache.get(surface_document, "marked"))
def test_every_row_is_writable_exactly_where_the_vector_says_and_refuses_with_its_mark(self) -> None: def test_every_row_is_writable_exactly_where_the_vector_says_and_refuses_with_its_mark(self) -> None:
walked = 0 walked = 0
for store, key, writable, explained, type_name, carried, declared, occurrences, flatten in _rows(): for store, key, writable, explained, type_name, carried, declared, occurrences, flatten in _rows():

View file

@ -0,0 +1,140 @@
from __future__ import annotations
import shutil
import tempfile
import unittest
from pathlib import Path
from cultcache_py import JsonLinesBackingStore, SingleFileMessagePackBackingStore, StoreUnreadableError
from cultcache_py.backing_store import BackingStore, CultCacheEnvelope
def envelope(key: str) -> CultCacheEnvelope:
return CultCacheEnvelope(key=key, type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z")
class SoftPushAllTests(unittest.TestCase):
"""A soft push writes where nothing is, skips a store it reads, and refuses one it cannot read (TS and Rust parity)."""
def setUp(self) -> None:
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.path = self.dir / "state.cc"
def test_over_a_readable_store_it_leaves_the_file_byte_for_byte(self) -> None:
for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore):
with self.subTest(make.__name__):
path = self.dir / make.__name__
store = make(path)
store.push_all([envelope("a")])
before = path.read_bytes()
store.push_all([envelope("b")], soft=True)
self.assertEqual(before, path.read_bytes())
self.assertEqual(["a"], [item.key for item in make(path).pull_all()])
store.push_all([envelope("b")]) # a plain push still lands what the caller holds
self.assertEqual(["a", "b"], sorted(item.key for item in make(path).pull_all()))
def test_over_a_readable_store_holding_no_records_it_leaves_the_file_byte_for_byte(self) -> None:
for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore):
with self.subTest(make.__name__):
path = self.dir / make.__name__
store = make(path)
store.push_all([envelope("a")])
store.delete("t", "a")
self.assertTrue(path.exists())
self.assertEqual([], make(path).pull_all())
before = path.read_bytes()
store.push_all([envelope("b")], soft=True)
self.assertEqual(before, path.read_bytes())
self.assertEqual([], make(path).pull_all())
def test_over_an_absent_path_it_writes(self) -> None:
for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore):
with self.subTest(make.__name__):
path = self.dir / make.__name__
make(path).push_all([envelope("a")], soft=True)
self.assertEqual(["a"], [item.key for item in make(path).pull_all()])
def test_over_an_unreadable_file_it_refuses_and_leaves_it(self) -> None:
junk = b"\xc1\x01\x02\x03"
self.path.write_bytes(junk)
with self.assertRaises(StoreUnreadableError):
SingleFileMessagePackBackingStore(self.path).push_all([envelope("a")], soft=True)
self.assertEqual(junk, self.path.read_bytes())
def test_over_an_unreadable_jsonl_file_it_refuses_and_leaves_it(self) -> None:
junk = b"this is not json\n"
self.path.write_bytes(junk)
with self.assertRaises(ValueError):
JsonLinesBackingStore(self.path).push_all([envelope("a")], soft=True)
self.assertEqual(junk, self.path.read_bytes())
def test_the_protocol_default_push_all_decides_soft_by_what_pull_or_absent_says(self) -> None:
class Memory(BackingStore):
def __init__(self) -> None:
self.items: list[CultCacheEnvelope] | None = None # None: nothing at the path
def pull_all(self) -> list[CultCacheEnvelope]:
return list(self.items or [])
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
return None if self.items is None else list(self.items)
def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None:
self.items = list(envelopes)
store = Memory()
store.push_all([envelope("a")], soft=True) # nothing at the path: writes
self.assertEqual(["a"], [item.key for item in store.items or []])
store.push_all([envelope("b")], soft=True) # a store with records: skips
self.assertEqual(["a"], [item.key for item in store.items or []])
store.items = []
store.push_all([envelope("b")], soft=True) # a store the reader read, empty: skips
self.assertEqual([], store.items)
store.push_all([envelope("b")]) # plain: lands
self.assertEqual(["b"], [item.key for item in store.items])
def test_a_store_that_only_pulls_all_is_read_so_a_soft_push_skips_it(self) -> None:
class PullOnly(BackingStore):
def __init__(self) -> None:
self.items: list[CultCacheEnvelope] = []
def pull_all(self) -> list[CultCacheEnvelope]:
return list(self.items)
def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None:
self.items = list(envelopes)
store = PullOnly()
store.push_all([envelope("a")], soft=True)
self.assertEqual([], store.items)
def test_the_single_file_and_jsonl_stores_say_absent_and_read_through_pull_or_absent(self) -> None:
for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore):
with self.subTest(make.__name__):
path = self.dir / make.__name__
store = make(path)
self.assertIsNone(store.pull_or_absent())
store.push_all([envelope("a")])
self.assertEqual(["a"], [item.key for item in store.pull_or_absent() or []])
def test_the_protocol_default_push_all_refuses_where_its_pull_refuses(self) -> None:
class Unreadable(BackingStore):
replaced = False
def pull_all(self) -> list[CultCacheEnvelope]:
raise StoreUnreadableError(Path("x"), ValueError("junk"))
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
raise StoreUnreadableError(Path("x"), ValueError("junk"))
def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None:
Unreadable.replaced = True
with self.assertRaises(StoreUnreadableError):
Unreadable().push_all([envelope("a")], soft=True)
self.assertFalse(Unreadable.replaced)
if __name__ == "__main__":
unittest.main()

View file

@ -1,21 +1,25 @@
from __future__ import annotations from __future__ import annotations
import os import os
import select
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import threading
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
from cultcache_py import SingleFileMessagePackBackingStore, stores from cultcache_py import SingleFileMessagePackBackingStore, stores
from cultcache_py.stores import StoreLinkedError, store_write_lock
from cultcache_py.backing_store import CultCacheEnvelope from cultcache_py.backing_store import CultCacheEnvelope
REPO = Path(__file__).resolve().parents[3] REPO = Path(__file__).resolve().parents[3]
TS_STORE_FILE = REPO / "packages" / "cultcache-ts" / "dist" / "store-file.js" TS_STORE_FILE = REPO / "packages" / "cultcache-ts" / "dist" / "store-file.js"
HOLD_SECONDS = 1.5 HOLD_SECONDS = 1.5
HELD_WAIT_SECONDS = 3
# A holder that takes the lock the way the Rust runtime does (an exclusive file lock), not through this package. # A holder that takes the lock the way the Rust runtime does (an exclusive file lock), not through this package.
PY_HOLDER = """ PY_HOLDER = """
@ -41,7 +45,14 @@ def envelope(key: str) -> CultCacheEnvelope:
def start_holder(argv: list[str]) -> subprocess.Popen[str]: def start_holder(argv: list[str]) -> subprocess.Popen[str]:
holder = subprocess.Popen(argv, stdout=subprocess.PIPE, text=True) holder = subprocess.Popen(argv, stdout=subprocess.PIPE, text=True)
assert holder.stdout is not None and holder.stdout.readline().strip() == "HELD" assert holder.stdout is not None
ready, _, _ = select.select([holder.stdout], [], [], HELD_WAIT_SECONDS) # a holder blocked on a leaked lock never prints
if not ready:
holder.kill()
holder.wait()
holder.stdout.close()
raise AssertionError(f"the holder never took {argv[-2]}.lock within {HELD_WAIT_SECONDS} s: an earlier writer leaked the lock")
assert holder.stdout.readline().strip() == "HELD"
holder.held_at = time.monotonic() # type: ignore[attr-defined] holder.held_at = time.monotonic() # type: ignore[attr-defined]
return holder return holder
@ -73,6 +84,48 @@ class StoreLockTests(unittest.TestCase):
holder = start_holder(["node", "-e", TS_HOLDER, str(TS_STORE_FILE), str(self.path), str(HOLD_SECONDS)]) holder = start_holder(["node", "-e", TS_HOLDER, str(TS_STORE_FILE), str(self.path), str(HOLD_SECONDS)])
self.assert_write_waits_for(holder) self.assert_write_waits_for(holder)
def test_a_link_put_while_a_writer_waits_on_the_lock_is_refused_once_it_holds_it(self) -> None:
target = self.dir / "volume" / "store.cc"
target.parent.mkdir()
SingleFileMessagePackBackingStore(target).push(envelope("a"))
before = target.read_bytes()
writes = {
"push": lambda store: store.push(envelope("b")),
"push_all": lambda store: store.push_all([envelope("b")]),
"delete": lambda store: store.delete("t", "a"),
}
missing = self.dir / "volume" / "missing.cc"
for (name, write), link_to in [(item, to) for item in writes.items() for to in (target, missing)]:
with self.subTest(name, dangling=link_to == missing):
if os.path.lexists(self.path):
os.unlink(self.path)
holder = start_holder([sys.executable, "-c", PY_HOLDER, str(self.path), str(HOLD_SECONDS)])
self.addCleanup(holder.wait)
self.addCleanup(holder.stdout.close) # type: ignore[union-attr]
outcome: list[BaseException | None] = []
def run() -> None:
try:
write(SingleFileMessagePackBackingStore(self.path, lock_timeout=10))
outcome.append(None)
except BaseException as exc: # noqa: BLE001 - the thread hands the outcome to the test
outcome.append(exc)
writer = threading.Thread(target=run)
writer.start()
time.sleep(0.3)
os.symlink(link_to, self.path) # after the writer's pre-lock look, while it waits
writer.join()
holder.wait()
self.assertEqual(1, len(outcome))
self.assertIsInstance(outcome[0], StoreLinkedError)
self.assertTrue(os.path.islink(self.path), "the link was replaced")
self.assertEqual(before, target.read_bytes())
self.assertFalse(missing.exists(), "the dangling target was created")
os.unlink(self.path) # a link is refused before the lock, so remove it to probe the writer's own lock file
with store_write_lock(self.path, timeout=0.2): # the lock is free
pass
def test_the_lock_is_released_after_each_write(self) -> None: def test_the_lock_is_released_after_each_write(self) -> None:
store = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2) store = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2)
for key in "abc": for key in "abc":

View file

@ -0,0 +1,137 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from cultcache_py import SingleFileMessagePackBackingStore, stores
from cultcache_py.backing_store import CultCacheEnvelope
from cultcache_py.stores import MOVEFILE_REPLACE_WRITE_THROUGH, ReplaceKernel, replace_store_windows
class FakeKernel(ReplaceKernel):
def __init__(self, posix: list[int], move_ex: list[int]) -> None:
self.posix, self.move_ex = list(posix), list(move_ex)
self.log: list[str] = []
self.sleeps: list[float] = []
def rename_posix(self, staged, destination):
self.log.append("posix")
return self.posix.pop(0) if self.posix else 0
def move_file_ex(self, staged, destination, flags):
self.log.append(f"moveex:{flags}")
return self.move_ex.pop(0) if self.move_ex else 0
def sleep(self, seconds):
self.sleeps.append(seconds)
def run(posix, move_ex):
kernel = FakeKernel(posix, move_ex)
return kernel, lambda: replace_store_windows("a", "b", kernel)
class WindowsReplaceTable(unittest.TestCase):
def test_falls_back_to_write_through_move_where_posix_rename_is_unsupported(self):
self.assertEqual(MOVEFILE_REPLACE_WRITE_THROUGH, 0x9)
for unsupported in (1, 50, 87):
kernel, go = run([unsupported], [0])
go()
self.assertEqual(kernel.log, ["posix", "moveex:9"])
def test_once_on_move_the_rest_of_the_call_stays_there(self):
kernel, go = run([87], [32, 0])
go()
self.assertEqual(kernel.log, ["posix", "moveex:9", "moveex:9"])
def test_transient_codes_retry_the_same_primitive(self):
for transient in (5, 32, 33):
kernel, go = run([transient, transient, 0], [])
go()
self.assertEqual(kernel.log, ["posix", "posix", "posix"])
self.assertEqual(kernel.sleeps, [0.02, 0.02])
def test_a_working_posix_rename_is_the_whole_replace(self):
kernel, go = run([0], [])
go()
self.assertEqual(kernel.log, ["posix"])
self.assertEqual(kernel.sleeps, [])
def test_unsupported_after_the_fallback_is_an_error_not_another_fallback(self):
kernel, go = run([87], [32, 87])
with self.assertRaisesRegex(OSError, "Win32 error 87") as caught:
go()
self.assertEqual(caught.exception.winerror, 87)
self.assertEqual(kernel.log, ["posix", "moveex:9", "moveex:9"])
for unsupported in (1, 50, 87):
kernel, go = run([87], [unsupported])
with self.assertRaisesRegex(OSError, f"Win32 error {unsupported}"):
go()
self.assertEqual(kernel.log, ["posix", "moveex:9"])
def test_gives_up_after_25_attempts_naming_the_last_code(self):
kernel, go = run([32] * 30, [])
with self.assertRaisesRegex(OSError, "after 25 attempts: last Win32 error 32\\."):
go()
self.assertEqual(len(kernel.log), 25)
def test_other_codes_fail_at_once(self):
kernel, go = run([2], [])
with self.assertRaisesRegex(OSError, "Win32 error 2"):
go()
self.assertEqual(len(kernel.log), 1)
kernel, go = run([87], [3])
with self.assertRaisesRegex(OSError, "Win32 error 3"):
go()
class StoreWritesRouteThroughTheReplace(unittest.TestCase):
def test_both_stores_replace_through_replace_store(self):
seen: list[tuple[Path, Path]] = []
real = stores._replace_store
def spy(staged, destination):
seen.append((Path(staged), Path(destination)))
real(staged, destination)
with tempfile.TemporaryDirectory() as directory, mock.patch.object(stores, "_replace_store", spy):
envelope = CultCacheEnvelope(key="k", type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z")
binary = Path(directory) / "s.cc"
SingleFileMessagePackBackingStore(binary).push_all([envelope])
lines = Path(directory) / "s.jsonl"
stores.JsonLinesBackingStore(lines).push_all([envelope])
self.assertEqual([destination for _, destination in seen], [binary, lines])
self.assertTrue(binary.read_bytes() and lines.read_text())
def test_a_failed_jsonl_replace_leaves_no_staged_file_and_the_old_store(self):
envelope = CultCacheEnvelope(key="k", type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z")
with tempfile.TemporaryDirectory() as directory:
lines = Path(directory) / "s.jsonl"
store = stores.JsonLinesBackingStore(lines)
store.push_all([envelope])
before = lines.read_bytes()
with mock.patch.object(stores, "_replace_store", side_effect=OSError("replace refused")):
with self.assertRaises(OSError):
store.push_all([CultCacheEnvelope(key="k2", type="t", payload=b"\x02", stored_at="2026-11-06T00:00:00Z")])
self.assertEqual(lines.read_bytes(), before)
self.assertEqual([p.name for p in Path(directory).iterdir() if ".tmp" in p.name], [])
@unittest.skipUnless(sys.platform == "win32", "needs a real Windows")
def test_real_kernel_replaces_through_both_primitives(self):
kernel = stores.WindowsReplaceKernel()
with tempfile.TemporaryDirectory() as directory:
for name, replace in (("posix", kernel.rename_posix), ("moveex", lambda s, d: kernel.move_file_ex(s, d, MOVEFILE_REPLACE_WRITE_THROUGH))):
staged, store = Path(directory, name + ".tmp"), Path(directory, name)
store.write_bytes(b"old")
staged.write_bytes(b"new")
self.assertEqual(replace(str(staged), str(store)), 0)
self.assertEqual(store.read_bytes(), b"new")
self.assertEqual(kernel.rename_posix(str(Path(directory, "missing")), str(Path(directory, "x"))), 2)
if __name__ == "__main__":
unittest.main()

View file

@ -1,2 +1 @@
/target /target
Cargo.lock

View file

@ -6,8 +6,25 @@ only in git history.
## Unreleased ## Unreleased
### Changed
- The single-file store re-checks for a link once the store lock is held (every write and the try-exchange), so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, as C# does, instead of being replaced. Pinned by a test that puts the link while a second holder owns the lock.
- `CultCache::is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store.
### Breaking ### Breaking
Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0).
- A write at a store path that is a symbolic link, live or dangling, is refused with the new public `StoreLinkedError` (`error.downcast_ref::<StoreLinkedError>()`) before the lock and the replace, and writes nothing: `push`, `push_all`, `delete`, the exchanges, and a soft `push_all`, which used to skip a link and now refuses. Before, a write at a live link replaced the link with a regular file and a dangling link failed with `io::ErrorKind::NotFound`. The redb stores (`RedbMessagePackBackingStore`, `OwnedRedbMessagePackBackingStore`) are exempt: they are ruled migrated and deleted (`paged-store-redb-migrate-and-delete`) and still open a live link's target. Configure the link's target as the store path. Reads through a live link work.
- 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 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). - 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. - `#[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.
@ -22,6 +39,7 @@ only in git history.
### Added ### 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. - `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`). - 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>()`. - `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>()`.

730
packages/cultcache-rs/Cargo.lock generated Normal file
View file

@ -0,0 +1,730 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "ar_archive_writer"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "523e5e187fff36fcb8d1d19ccf0ca4ca43ec3d158316057f66dc6f72716bc5ec"
dependencies = [
"object",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cultcache-rs"
version = "0.3.1"
dependencies = [
"anyhow",
"chrono",
"cultcache-rs-derive",
"fs2",
"pretty_assertions",
"redb",
"rmp-serde",
"serde",
"serde_bytes",
"serde_json",
"serde_stacker",
"stacker",
"tempfile",
"uuid",
"windows-sys 0.59.0",
]
[[package]]
name = "cultcache-rs-derive"
version = "0.2.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "errno"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e35d6e78f4cebb90e51d543db8fe41528851d6deeef6ef21ebd4bfdc1e784776"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "itoa"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4"
[[package]]
name = "js-sys"
version = "0.3.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "psm"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redb"
version = "4.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b317ef681e18f6091efbfab532eaf924a135c492d9d0299d0fbfae07e92a39b"
dependencies = [
"libc",
]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]]
name = "rustix"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_bytes"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.10",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_stacker"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a"
dependencies = [
"serde",
"serde_core",
"stacker",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "stacker"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.61.2",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "uuid"
version = "1.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bed99139f7dac9b5cfe5f17be4d66dc7079a34dee1d74bcc77217119240e267c"
dependencies = [
"getrandom",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
dependencies = [
"cfg-if",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.10",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
dependencies = [
"unicode-ident",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

View file

@ -31,4 +31,4 @@ pretty_assertions = "1.4"
tempfile = "3" tempfile = "3"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_WindowsProgramming"] }

View file

@ -39,8 +39,8 @@ CultCache is a domain cache with persistence adapters.
file access with a sidecar lock file. file access with a sidecar lock file.
This is not an ORM, not a database, and not distributed consensus in a novelty This is not an ORM, not a database, and not distributed consensus in a novelty
hat. If multiple processes write the same backing file, use an external lock or hat. Several processes may write the same single-file store, one at a time under
a coordinator. its sidecar lock; write ordering across keys or stores needs a coordinator.
## Current API ## Current API
@ -236,6 +236,17 @@ assert_eq!(rmp_serde::from_slice::<Node>(&bytes)?, tree);
# Ok::<(), Box<dyn std::error::Error>>(()) # 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 ## Structure Of Arrays
The C# CultCache hot path exposes CPU-local SoA scans as: The C# CultCache hot path exposes CPU-local SoA scans as:
@ -373,10 +384,10 @@ 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. 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 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: 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) reading it is an I/O error. A write at a store path that is a link, live or dangling, is
fails before writing. Writes do not yet follow links: a write through a live link, refused with `StoreLinkedError` (`error.downcast_ref::<StoreLinkedError>()`) before the
and a `push_all` through a dangling one, replace the link with a regular file and lock and the replace, so no write swaps a link for a regular file; configure the link's
leave its target alone. A refusal names no target as the store path. Reads through a live link work. A refusal names no
value read from the file; it names a record only by its key and schema id, and 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>`. a header only when it has the shape `cultcache.store.v<digits>`.

View file

@ -381,6 +381,7 @@ fn slot_codec(ident: &syn::Ident, slots: &[CultCacheSlot]) -> proc_macro2::Token
where where
__A: ::serde::de::SeqAccess<'de>, __A: ::serde::de::SeqAccess<'de>,
{ {
let __ticket = ::cultcache_rs::__enter_slots();
#(#option_declarations)* #(#option_declarations)*
let mut index = 0usize; let mut index = 0usize;
while index <= #max_slot { while index <= #max_slot {
@ -390,7 +391,15 @@ fn slot_codec(ident: &syn::Ident, slots: &[CultCacheSlot]) -> proc_macro2::Token
} }
index += 1; 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 { Ok(#ident {
#(#build_fields),* #(#build_fields),*
}) })
@ -567,10 +576,13 @@ fn union_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
quote! { quote! {
#key => { #key => {
match seq match seq
.next_element::<::std::option::Option<[(); 0]>>() .next_element::<::std::option::Option<::cultcache_rs::__UnitArmSlots>>()
.map_err(|_| ::serde::de::Error::custom(#arity_message))? .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) => { ::std::option::Option::Some(::std::option::Option::None) => {
return ::std::result::Result::Err(::serde::de::Error::custom( return ::std::result::Result::Err(::serde::de::Error::custom(
format!(#nil_slots_message, #key), format!(#nil_slots_message, #key),

View file

@ -1,6 +1,8 @@
use anyhow::Result; use anyhow::Result;
use anyhow::anyhow; use anyhow::anyhow;
use cultcache_rs::CultCache; use cultcache_rs::CultCache;
use cultcache_rs::CultSlots;
use cultcache_rs::CultUnion;
use cultcache_rs::DatabaseEntry; use cultcache_rs::DatabaseEntry;
use cultcache_rs::SingleFileMessagePackBackingStore; use cultcache_rs::SingleFileMessagePackBackingStore;
use serde_json::json; use serde_json::json;
@ -39,6 +41,100 @@ struct CultCacheInteropDeck {
marks: Vec<CultCacheInteropMark>, 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<()> { fn read_deck(file: &str) -> Result<()> {
let mut cache = CultCache::new(); let mut cache = CultCache::new();
cache.register_entry_type::<CultCacheInteropDeck>()?; cache.register_entry_type::<CultCacheInteropDeck>()?;
@ -70,6 +166,8 @@ fn main() -> Result<()> {
"write" => write_note(file, require_arg(&options, "runtime-id")?)?, "write" => write_note(file, require_arg(&options, "runtime-id")?)?,
"read" => read_note(file)?, "read" => read_note(file)?,
"read-deck" => read_deck(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()?)?, "hold-lock" => hold_lock(file, require_arg(&options, "ms")?.parse()?)?,
_ => return Err(anyhow!("unknown mode {mode}")), _ => 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,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. //! 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. //! 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::CultCache;
use cultcache_rs::CacheBackingStore; use cultcache_rs::CacheBackingStore;
use cultcache_rs::CultCacheEnvelope; use cultcache_rs::CultCacheEnvelope;
use cultcache_rs::CultCacheLoadRefused;
use cultcache_rs::CultCacheSoaTable;
use cultcache_rs::MAX_PAYLOAD_DEPTH; use cultcache_rs::MAX_PAYLOAD_DEPTH;
use cultcache_rs::PayloadTooDeep; use cultcache_rs::PayloadTooDeep;
use cultcache_rs::check_payload_depth; use cultcache_rs::check_payload_depth;
use cultcache_rs::CultSlots; use cultcache_rs::decode_payload;
use cultcache_rs::CultUnion; use cultcache_rs::RedbMessagePackBackingStore;
use cultcache_rs::DatabaseEntry; use cultcache_rs::DatabaseEntry;
use cultcache_rs::SingleFileMessagePackBackingStore; use cultcache_rs::SingleFileMessagePackBackingStore;
#[derive(Clone, Debug, PartialEq, CultUnion)] mod common;
enum Node { use common::*;
#[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()))
}
fn decode(bytes: &[u8]) -> Result<UnionSurface, String> { fn decode(bytes: &[u8]) -> Result<UnionSurface, String> {
rmp_serde::from_slice(bytes).map_err(|error| error.to_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] #[test]
fn a_union_that_is_not_a_key_and_slots_pair_is_refused_naming_the_enum() { 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]), ("an empty array", vec![0x90]),
("a trailing element", rmp_serde::to_vec(&(1u64, (7u8,), 0u8)).unwrap()), ("a trailing element", rmp_serde::to_vec(&(1u64, (7u8,), 0u8)).unwrap()),
("a string key", rmp_serde::to_vec(&("CANARY-key", (7u8,))).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 { for (label, bytes) in cases {
let message = rmp_serde::from_slice::<UnitOutcome>(&bytes).unwrap_err().to_string(); 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 // 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> { fn through_cache(payload: &[u8]) -> Result<UnionSurface, String> {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("foreign.cc"); let path = temp.path().join("foreign.cc");
@ -700,7 +476,10 @@ fn through_cache(payload: &[u8]) -> Result<UnionSurface, String> {
let mut cache = CultCache::new(); let mut cache = CultCache::new();
cache.register_entry_type::<UnionSurface>().unwrap(); cache.register_entry_type::<UnionSurface>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).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") { match cache.get::<UnionSurface>("k") {
Ok(value) => Ok(value.expect("the envelope was stored")), Ok(value) => Ok(value.expect("the envelope was stored")),
Err(error) => Err(format!("{error:#}")), Err(error) => Err(format!("{error:#}")),
@ -757,6 +536,174 @@ fn bounds_vectors() {
assert_eq!((accepted, refused, read_only), (4, 7, 1)); 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());
}
// load_soa is a load too: a table holding a payload nested past the bound is refused with the typed depth refusal, the
// prior view is kept, and a tail behind the depth is still the depth refusal.
#[test]
fn load_soa_refuses_a_payload_nested_past_the_bound() {
let mut deep = vec![0x91u8; MAX_PAYLOAD_DEPTH + 1];
deep.push(0xc0);
let mut cache = CultCache::new();
cache.register_entry_type::<UnionSurface>().unwrap();
cache.load_soa(CultCacheSoaTable::from_envelopes(vec![envelope("kept", &vector("union-surface.msgpack"))])).unwrap();
let before = cache.snapshot();
for payload in [deep.clone(), [deep.clone(), vec![0xc0]].concat()] {
let error = cache.load_soa(CultCacheSoaTable::from_envelopes(vec![envelope("k", &payload)])).unwrap_err();
assert_eq!(error.downcast_ref::<PayloadTooDeep>(), Some(&TOO_DEEP), "{error:#}");
assert_eq!(cache.snapshot(), before, "a refused load keeps the prior view");
}
}
// 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 { fn wrap_chain(wraps: usize, leaf: Node) -> UnionSurface {
let mut node = leaf; let mut node = leaf;
for _ in 0..wraps { for _ in 0..wraps {
@ -788,6 +735,7 @@ fn write_refused_past_limit() {
let mut cache = CultCache::new(); let mut cache = CultCache::new();
cache.register_entry_type::<UnionSurface>().unwrap(); cache.register_entry_type::<UnionSurface>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).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![])); let at_limit = wrap_chain(126, boxed("0000000000f0", vec![]));
assert_eq!(cache.put("at", &at_limit).unwrap(), at_limit); assert_eq!(cache.put("at", &at_limit).unwrap(), at_limit);
@ -871,6 +819,7 @@ fn the_raw_doors_hold_the_bound_for_arms_maps_and_arrays() {
cache.register_entry_type::<UnionSurface>().unwrap(); cache.register_entry_type::<UnionSurface>().unwrap();
cache.register_entry_type::<MapDoc>().unwrap(); cache.register_entry_type::<MapDoc>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).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_raw_envelope(raw(type_name, "at-raw", at.clone())).unwrap();
cache.put_prepared_batch(vec![raw(type_name, "at-batch", at.clone())]).unwrap(); cache.put_prepared_batch(vec![raw(type_name, "at-batch", at.clone())]).unwrap();
let stored = cache.snapshot_envelopes(); let stored = cache.snapshot_envelopes();

View file

@ -6,7 +6,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use cultcache_rs::{ use cultcache_rs::{
CultCache, CultCacheEnvelope, CultCacheSoaTable, DatabaseEntry, SingleFileMessagePackBackingStore, CultCache, CultCacheEnvelope, CultCacheLoadRefused, CultCacheSoaTable, DatabaseEntry, SingleFileMessagePackBackingStore,
}; };
use serde::Deserialize; use serde::Deserialize;
@ -419,6 +419,54 @@ fn a_load_of_a_duplicate_image_is_refused_and_keeps_the_prior_view() {
soa.put("item:y", &unique("K")).unwrap(); soa.put("item:y", &unique("K")).unwrap();
} }
// A pull refused for a duplicate unique-index value, or for one key held under two types in two stores, closes the same
// write gate a payload refusal does: the cache holds no image of its stores, so a write would lay the empty image over them.
#[test]
fn a_pull_refused_for_a_duplicate_index_or_a_retype_closes_every_write() {
fn assert_gated(cache: &mut CultCache, path: &Path, door_label: &str) {
let before = std::fs::read(path).unwrap();
let errors = [
("put", cache.put("item:new", &unique("N")).unwrap_err()),
("put_raw_envelope", cache.put_raw_envelope(cache.prepare_entry("item:new", &unique("N")).unwrap().0).unwrap_err()),
("delete", cache.delete::<Unique>("item:keep").unwrap_err()),
];
for (door, error) in errors {
assert!(error.downcast_ref::<CultCacheLoadRefused>().is_some(), "{door_label} {door}: {error:#}");
}
assert_eq!(std::fs::read(path).unwrap(), before, "{door_label}: the store changed");
}
// Duplicate index value found in the stored image.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dup.cc");
let mut cache = cache_at(&path);
cache.put("item:keep", &unique("K")).unwrap();
let mut twin = twin_cache_at(&path);
twin.put("item:b", &Twin { sku: "S".into(), category: "c".into() }).unwrap();
twin.put("item:a", &Twin { sku: "S".into(), category: "c".into() }).unwrap();
assert!(error_text(cache.pull_all_backing_stores()).contains("both hold unique index 'Sku'"));
assert_gated(&mut cache, &path, "duplicate index");
// One key held under two types by two stores. The cache pulled cleanly first, so only the refused pull can close the gate.
let (first, second) = (dir.path().join("first.cc"), dir.path().join("second.cc"));
let mut cache = CultCache::new();
cache.register_entry_type::<Plain>().unwrap();
cache.register_entry_type::<Other>().unwrap();
cache.register_entry_type::<Unique>().unwrap();
cache.add_backing_store(SingleFileMessagePackBackingStore::new(&first), [Plain::TYPE]).unwrap();
cache.add_backing_store(SingleFileMessagePackBackingStore::new(&second), [Other::TYPE]).unwrap();
cache.pull_all_backing_stores().unwrap();
cache.put("k", &Plain { value: "p".into() }).unwrap();
let mut seed = CultCache::new();
seed.register_entry_type::<Other>().unwrap();
seed.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&second)).unwrap();
seed.pull_all_backing_stores().unwrap();
seed.put("k", &Other { sku: "o".into() }).unwrap();
assert!(error_text(cache.pull_all_backing_stores()).contains("\"k\""));
assert_gated(&mut cache, &first, "retype");
assert_eq!(cache.get::<Plain>("k").unwrap().unwrap().value, "p", "a refused pull keeps the prior view");
}
#[test] #[test]
fn a_multi_index_store_with_a_duplicate_on_the_second_alias_refuses_to_open() { fn a_multi_index_store_with_a_duplicate_on_the_second_alias_refuses_to_open() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();

View file

@ -5,8 +5,15 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
## [Unreleased] ## [Unreleased]
### Changed
- `withStoreWriteLock` looks for a link at the store path again once the lock is held, so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, instead of being replaced. Pinned by a test that puts the link while another holder owns the lock.
- `CultCache.isWritable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store.
### Breaking ### Breaking
- A write at a single-file store path that is a symbolic link, live or dangling, is refused with the new `StoreLinkedError` (exported from the package entry; `error.path` names the path) before the lock and the replace, and writes nothing: `push`, `pushAll`, `delete` and a soft flush alike. Before, a write at a live link replaced the link with a regular file, a dangling link failed with `ENOENT` (or, for `pushAll`, was replaced), and a soft flush at a link skipped. A caller catching `ENOENT` around a write at a dangling link now catches `StoreLinkedError`; configure the link's target as the store path. Reads through a live link work. An `lstat` failure other than `ENOENT` (`ENOTDIR`, `EACCES`, `ELOOP`) is rethrown as it is.
- `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 is 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. - 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 - `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 value under one alias is refused naming both keys and the alias, and writes nothing. Index text is the C# canonical
@ -20,13 +27,16 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
- A store whose canonical-schema catalog slot is not a string is refused as `StoreUnreadableError`. - 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. - 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 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 ### 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. - `CultCache.foreignRecords()`: the `{key, schemaId, schemaName, storedAt}` of every record the last pull carried and no registered type claims, sorted by key ordinal.
### Changed ### 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 - 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 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. 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. write) take them with `documentPayloadBytes`, the sanctioned escape.
- `decodeDocumentPayload(definition, payload)` reads a payload through the definition's formatter, - `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, - `encodeDocumentPayload(definition, value)` is the writer: the schema first, then the formatter,
codec or plain map. codec or plain map.
- `documentPayloadFromBytes(bytes)` is where bytes enter (a record you validated, a store read, a - `documentPayloadFromBytes(bytes)` is where bytes enter (a record you validated, a store read, a
@ -172,7 +174,7 @@ const settings = cache.getRequiredGlobal(settingsDocument);
- `updateGlobal(...)` - `updateGlobal(...)`
- `delete(...)` - `delete(...)`
- `deleteGlobal(...)` - `deleteGlobal(...)`
- `snapshot()` - `snapshot()` (the logical image) and `storedSnapshot()` (the image as the stores hold it, for a whole flush)
- `getEnvelope(...)` - `getEnvelope(...)`
- `getRequiredEnvelope(...)` - `getRequiredEnvelope(...)`
- `getGlobalEnvelope(...)` - `getGlobalEnvelope(...)`
@ -327,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 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 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 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 do, which means generated Ajv-backed JSON-schema contracts can feed the cache
@ -371,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 - A slot is `[name, codec, default?]`. A gap is written nil and skipped on read; an extra trailing slot is read past and
slot missing from the payload (or nil, when a default is declared) takes its default, and is refused without one. 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 - 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, []]`. 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. - A refusal names the union, the field and the slot; it never echoes the value it refused.

View file

@ -34,6 +34,7 @@ export type {
CultCacheIndexScalar, CultCacheIndexScalar,
InferCultCacheSchemaValue, InferCultCacheSchemaValue,
CultCacheStoreRegistration, CultCacheStoreRegistration,
CultCacheUnreadSlots,
PushAllOptions, PushAllOptions,
} from "./types"; } from "./types";
export type { 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]>>; 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_MIN = -(2 ** 31);
const INT32_MAX = 2 ** 31 - 1; 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 // 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, // 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 // 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. // 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) { decode(raw) {
expect(raw, "an array of slots", Array.isArray(raw)); expect(raw, "an array of slots", Array.isArray(raw));
const input = raw as unknown[]; const input = raw as unknown[];
if (input.length > length) {
noteExtraSlots(simpleTypeName(typeName), input.length, length);
}
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
for (const field of fields) { for (const field of fields) {
const present = field.key < input.length; const present = field.key < input.length;
@ -291,7 +354,7 @@ export function slots<TValue extends object>(spec: SlotsSpec<TValue>, typeName?:
? defaultOf(field) ? defaultOf(field)
: input[field.key] === null && !field.codec.valueType : input[field.key] === null && !field.codec.valueType
? null ? 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; return out as TValue;
}, },
@ -363,6 +426,9 @@ export function union<TValue extends { kind: string }>(
if (arm === undefined) { if (arm === undefined) {
throw new Error(`Union ${name}: unknown arm key ${key} (declared keys: ${declared}).`); 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])); 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; return { kind: arm.armName, ...fields } as unknown as TValue;
}, },

View file

@ -1,6 +1,6 @@
import { decode } from "@msgpack/msgpack"; 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"; import { type StoreFormat, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots } from "./store-format";
export interface CultCacheInspection { 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 { function decodeBoundedPayload(payload: Uint8Array): unknown {
checkPayloadDepth(payload); checkPayloadIsOneValue(payload, "document payload");
return decode(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"> { function previewPayload(payload: Uint8Array): Pick<InspectedRecord, "payloadPreview" | "payloadDecodeError"> {
try { try {
return { payloadPreview: toJsonSafe(decodeBoundedPayload(payload)) }; return { payloadPreview: toJsonSafe(decodeBoundedPayload(payload)) };
@ -248,7 +258,7 @@ function previewPayload(payload: Uint8Array): Pick<InspectedRecord, "payloadPrev
return { return {
payloadPreview: { payloadPreview: {
bytes: payload.length, 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")) .map((value) => value.toString(16).padStart(2, "0"))
.join(" "), .join(" "),
}, },

View file

@ -1,6 +1,6 @@
import { import {
declaresRole, declaresRole,
decodeDocumentPayload, decodeDocumentPayloadMarked,
documentPayloadBytes, documentPayloadBytes,
documentPayloadFromBytes, documentPayloadFromBytes,
encodeDocumentPayload, encodeDocumentPayload,
@ -8,6 +8,7 @@ import {
schemaIdentityOf, schemaIdentityOf,
} from "./document"; } from "./document";
import { resolveDocumentFormatter } from "./document-formatter"; import { resolveDocumentFormatter } from "./document-formatter";
import type { NotedSlots } from "./codec";
import type { DocumentPayload } from "./document"; import type { DocumentPayload } from "./document";
import { SchemaConflictError, schemaIdForEnvelope, writtenOwns } from "./store-format"; import { SchemaConflictError, schemaIdForEnvelope, writtenOwns } from "./store-format";
import type { import type {
@ -20,6 +21,7 @@ import type {
CultCacheEnvelope, CultCacheEnvelope,
CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogEntry,
CultCacheStoreRegistration, CultCacheStoreRegistration,
CultCacheUnreadSlots,
} from "./types"; } from "./types";
type Accessor = (value: unknown) => string | undefined; 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. // A registered definition reads and writes its documents through the owner, on payloads the cache has cloned.
type RegisteredFormatter = { type RegisteredFormatter = {
encode(value: unknown): DocumentPayload; encode(value: unknown): DocumentPayload;
decode(payload: DocumentPayload): unknown; decode(payload: DocumentPayload): { value: unknown; unreadSlots?: NotedSlots };
}; };
type RegisteredDefinition = { type RegisteredDefinition = {
@ -50,6 +52,10 @@ type HydratedEntry = CultCacheEnvelope & {
catalogEntry: NonNullable<CultCacheEnvelope["catalogEntry"]>; catalogEntry: NonNullable<CultCacheEnvelope["catalogEntry"]>;
value: unknown; value: unknown;
lookups: EntryLookups; 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. // Everything a pull replaces. Built whole, then swapped in with one assignment.
@ -84,6 +90,20 @@ type DerivedTypeLookups = {
readonly indexMaps: Map<string, Map<string, string>>; 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 { function emptyHydratedState(): HydratedState {
return { return {
entries: new Map(), entries: new Map(),
@ -312,7 +332,7 @@ export class CultCache {
} }
const payload = this.#cloneBytes(entry.payload); 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 }; let admitted: CultCacheEnvelope = { ...entry, type, payload };
if (registered.global) { if (registered.global) {
const persistedKey = persistedGlobalKeys.get(type); const persistedKey = persistedGlobalKeys.get(type);
@ -327,7 +347,7 @@ export class CultCache {
admitted = { ...admitted, key: CultCache.GLOBAL_KEY }; 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); const other = this.#heldUnderOtherType(next, type, hydrated.key);
if (other !== undefined) { if (other !== undefined) {
throw new Error(`Record ${hydrated.key} is held by two stores, as type "${other.type}" and as type "${type}".`); throw new Error(`Record ${hydrated.key} is held by two stores, as type "${other.type}" and as type "${type}".`);
@ -518,10 +538,9 @@ export class CultCache {
if (!envelope.storedAt || envelope.storedAt.trim().length === 0) { if (!envelope.storedAt || envelope.storedAt.trim().length === 0) {
throw new Error(`CultCache envelope storedAt for type "${definition.type}" must be non-empty.`); 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 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 = { const entry: CultCacheEnvelope = {
key: envelope.key, key: envelope.key,
type: envelope.type, type: envelope.type,
@ -532,7 +551,7 @@ export class CultCache {
schemaId: registered.catalogEntry.schemaId, schemaId: registered.catalogEntry.schemaId,
catalogEntry: registered.catalogEntry, catalogEntry: registered.catalogEntry,
}; };
await this.#writeNow(registered, entry, parsed); await this.#writeNow(registered, entry, parsed, unreadSlots);
return parsed; return parsed;
}); });
} }
@ -585,6 +604,30 @@ 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. It does not look at store paths: a link at a store path is not predicted, and
* the write refuses it with `StoreLinkedError`. 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. // 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[] { foreignRecords(): readonly CultCacheForeignRecord[] {
return [...this.#hydrated.foreign].sort((left, right) => compareOrdinal(left.key, right.key)); return [...this.#hydrated.foreign].sort((left, right) => compareOrdinal(left.key, right.key));
@ -594,6 +637,13 @@ export class CultCache {
return [...this.#hydrated.entries.values()].map((entry) => this.#toEnvelope(entry)); 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 { #registerNow(definition: AnyCultCacheDocumentDefinition): void {
// Every registration refusal is a SchemaConflictError naming the new definition's schema id and both schema names. // 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) => const refuse = (holder: RegisteredDefinition, claimed: CultCacheSchemaCatalogEntry, what: string) =>
@ -680,9 +730,8 @@ export class CultCache {
value: CultCacheDocumentValue<TDefinition>, value: CultCacheDocumentValue<TDefinition>,
): Promise<CultCacheDocumentValue<TDefinition>> { ): Promise<CultCacheDocumentValue<TDefinition>> {
const registered = this.#requireDefinition(definition); const registered = this.#requireDefinition(definition);
this.#requireGlobalKey(registered, key);
const payload = registered.formatter.encode(value); 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 = { const entry: CultCacheEnvelope = {
key, key,
type: definition.type, type: definition.type,
@ -691,26 +740,16 @@ export class CultCache {
schemaId: registered.catalogEntry.schemaId, schemaId: registered.catalogEntry.schemaId,
catalogEntry: registered.catalogEntry, catalogEntry: registered.catalogEntry,
}; };
await this.#writeNow(registered, entry, parsed); await this.#writeNow(registered, entry, parsed, unreadSlots);
return parsed; return parsed;
} }
// Validates the whole write (home, global singleton, name and index accessors) before the store // 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. // 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 home = this.#homeStore(entry.type);
const retyped = this.#refuseRetype(entry); const retyped = this.#judge(registered, entry.key, entry);
if (retyped !== undefined && this.#homeStore(retyped.type) !== home) { const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value, unreadSlots);
// 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(entry),
[retyped.type, entry.type],
entry.key,
`CultCache holds record "${entry.key}" as type "${retyped.type}" in another store than the home of type "${entry.type}", so the rename cannot replace it; remove the record first.`,
);
}
const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value);
this.#refuseIndexSharing(this.#hydrated, entry.type, registered.catalogEntry.schemaName, entry.key, hydrated.lookups); this.#refuseIndexSharing(this.#hydrated, entry.type, registered.catalogEntry.schemaName, entry.key, hydrated.lookups);
const legacy = this.#hydrated.legacyGlobals.get(entry.type); const legacy = this.#hydrated.legacyGlobals.get(entry.type);
await home?.push(entry); await home?.push(entry);
@ -725,6 +764,85 @@ export class CultCache {
this.#installEntry(this.#hydrated, registered, hydrated); 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 // 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 // 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 // (writtenOwns); this judges the cache, which a store-less cache has no other judge for. Returns the held record of
@ -782,18 +900,20 @@ export class CultCache {
key: string, key: string,
): Promise<boolean> { ): Promise<boolean> {
const registered = this.#requireDefinition(definition); 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) { if (!entry) {
// variants:ruling:unheld-removal-refused-at-commit: a key a store carries foreign is refused, a key held under
// another type or by nobody is the existing false.
this.#refuseForeignKey(key, definition.type, "removed");
return false; return false;
} }
const home = this.#homeStore(definition.type); // The cache drops the record only once the store has removed it.
await home?.delete(this.#hydrated.legacyGlobals.get(definition.type) ?? this.#toEnvelope(entry)); const home = this.#homeStore(entry.type);
this.#hydrated.legacyGlobals.delete(definition.type); await home?.delete(this.#hydrated.legacyGlobals.get(entry.type) ?? this.#toEnvelope(entry));
this.#removeEntry(this.#hydrated, registered, entry); this.#hydrated.legacyGlobals.delete(entry.type);
this.#removeEntry(this.#hydrated, this.#definitions.get(entry.type)!, entry);
return true; return true;
} }
@ -820,7 +940,7 @@ export class CultCache {
return { return {
encode: (value: unknown) => this.#cloneBytes(encodeDocumentPayload(definition, value)), encode: (value: unknown) => this.#cloneBytes(encodeDocumentPayload(definition, value)),
decode: (payload: DocumentPayload) => decodeDocumentPayload(definition, this.#cloneBytes(payload)), decode: (payload: DocumentPayload) => decodeDocumentPayloadMarked(definition, this.#cloneBytes(payload)),
}; };
} }
@ -1000,6 +1120,7 @@ export class CultCache {
registered: RegisteredDefinition, registered: RegisteredDefinition,
entry: CultCacheEnvelope, entry: CultCacheEnvelope,
value: unknown, value: unknown,
noted?: NotedSlots,
): HydratedEntry { ): HydratedEntry {
if (registered.global) { if (registered.global) {
const currentGlobalKey = state.globalKeys.get(entry.type); const currentGlobalKey = state.globalKeys.get(entry.type);
@ -1020,6 +1141,8 @@ export class CultCache {
catalogEntry: registered.catalogEntry, catalogEntry: registered.catalogEntry,
value, value,
lookups, 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 { decode, encode } from "@msgpack/msgpack";
import { MAX_PAYLOAD_DEPTH } from "./payload-depth"; 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 type { AnyCultCacheDocumentDefinition, CultCacheDocumentFormatter } from "./types";
import { readWire, writeWire } from "./wire"; import { readWire, writeWire } from "./wire";
@ -16,6 +16,13 @@ export function resolveDocumentFormatter(definition: AnyCultCacheDocumentDefinit
return definition.formatter ?? (definition.codec ? codecFormatter(definition.codec) : plainDocumentFormatter); 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> = { const plainDocumentFormatter: CultCacheDocumentFormatter<unknown> = {
// The encoder's own depth refusal (100 by default) is set above the limit, so the payload walk decides. // 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 }), encode: (value: unknown) => encode(value, { maxDepth: MAX_PAYLOAD_DEPTH * 2 }),

View file

@ -1,5 +1,6 @@
import { resolveDocumentFormatter } from "./document-formatter"; import type { NotedSlots } from "./codec";
import { checkPayloadDepth } from "./payload-depth"; import { decodeMarked, resolveDocumentFormatter } from "./document-formatter";
import { checkPayloadDepth, checkPayloadIsOneValue } from "./payload-depth";
import type { import type {
AnyCultCacheDocumentDefinition, AnyCultCacheDocumentDefinition,
CultCacheSchemaCatalogMember, CultCacheSchemaCatalogMember,
@ -118,10 +119,24 @@ export function decodeDocumentPayload<TDefinition extends AnyCultCacheDocumentDe
definition: TDefinition, definition: TDefinition,
payload: DocumentPayload, payload: DocumentPayload,
): CultCacheDocumentValue<TDefinition> { ): 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); const bytes = documentPayloadBytes(payload);
if (writesLibraryMessagePack(definition)) checkPayloadDepth(bytes, `document type "${definition.type}" payload`); if (writesLibraryMessagePack(definition)) {
const decoded = resolveDocumentFormatter(definition).decode(bytes); // A payload is exactly one MessagePack value: bytes after the first are refused by count before any formatter reads,
return definition.schema.parse(decoded) as CultCacheDocumentValue<TDefinition>; // 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. // The one writer: the definition's schema first, then its formatter, codec or plain map.

View file

@ -1,2 +1,3 @@
export * from "./browser"; export * from "./browser";
export { SingleFileMessagePackBackingStore, readSingleFileStore, type SingleFileStoreRead } from "./single-file-messagepack-backing-store"; export { SingleFileMessagePackBackingStore, readSingleFileStore, type SingleFileStoreRead } from "./single-file-messagepack-backing-store";
export { StoreLinkedError } from "./store-file";

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, // 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, // 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. // never payload bytes. Returns the offset where the first value ends, or undefined when the payload ends early (the
export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload"): void { // 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 view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const remaining = new Float64Array(MAX_PAYLOAD_DEPTH); const remaining = new Float64Array(MAX_PAYLOAD_DEPTH);
let depth = 0; 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 <= 0x8f) { container = true; header = 1; children = 2 * (code & 0x0f); }
else if (code <= 0x9f) { container = true; header = 1; children = code & 0x0f; } else if (code <= 0x9f) { container = true; header = 1; children = code & 0x0f; }
else if (code <= 0xbf) skip = 1 + (code & 0x1f); 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 === 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; skip = 3 + 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; skip = 5 + 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; skip = 3 + 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; skip = 4 + 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; skip = 6 + 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 === 0xca || code === 0xce || code === 0xd2) skip = 5;
else if (code === 0xcb || code === 0xcf || code === 0xd3) skip = 9; else if (code === 0xcb || code === 0xcf || code === 0xd3) skip = 9;
else if (code === 0xcc || code === 0xd0) skip = 2; 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 === 0xd6) skip = 6;
else if (code === 0xd7) skip = 10; else if (code === 0xd7) skip = 10;
else if (code === 0xd8) skip = 18; 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 === 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; container = true; header = 5; 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; container = true; header = 3; children = 2 * 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; container = true; header = 5; children = 2 * n; } else { const n = width(4); if (n === undefined) return undefined; container = true; header = 5; children = 2 * n; }
if (container) { if (container) {
if (depth === MAX_PAYLOAD_DEPTH) throw new CultCachePayloadDepthError(MAX_PAYLOAD_DEPTH + 1, MAX_PAYLOAD_DEPTH, subject); 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; 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. // One element is complete; close every container it was the last element of.
while (depth > 0 && --remaining[depth - 1] === 0) depth--; 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

@ -82,31 +82,24 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
async pushAll(entries: CultCacheEnvelope[], options: PushAllOptions = {}): Promise<void> { async pushAll(entries: CultCacheEnvelope[], options: PushAllOptions = {}): Promise<void> {
await this.#enqueue(async () => { 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 // A flush writes `entries` onto the store as it is under the lock, the way a push does: a record under the same key
// stays marked, and one that is not (or is gone or legacy) is written unmarked. The file is read by the same // is replaced and no other is removed, so a cache whose image is stale (or empty, never pulled) cannot drop what
// reader as `pullAll`, so one it would refuse (not exactly one store, a variant, a body it cannot decode) is refused // another writer added. The store is read by the same reader as `pullAll`, so one it would refuse (not exactly one
// and left as it is. A soft flush asks that reader too: nothing at the path is written, a store is left alone. // store, a variant, a body it cannot decode) is refused and left as it is. A soft flush asks that reader too: nothing
try { // at the path is written, a store is left alone.
const disk = this.#readDisk(); // The reader owns the verdict on what is at the path: a dangling link is refused here as it is for open and push.
if (options.soft && !disk.absent) { const disk = this.#readDisk();
return; if (options.soft && !disk.absent) {
} return;
this.#format = disk.format;
} 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.
if (options.soft || (error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
this.#format = STORE_FORMAT_VERSION;
} }
requireOneTypePerKey(this.filePath, entries, []); this.#format = disk.format;
const held = disk.envelopes;
// A key is held once: of same-type entries sharing a key in one batch, the last is kept. // 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);
}); });
} }

View file

@ -5,7 +5,7 @@
// lazily inside `loadKernel`, so a consumer that never writes a store never loads it. // lazily inside `loadKernel`, so a consumer that never writes a store never loads it.
import { closeSync, openSync, constants as fsConstants } from "node:fs"; import { closeSync, openSync, constants as fsConstants } from "node:fs";
import { mkdir, open, rename, rm } from "node:fs/promises"; import { lstat, mkdir, open, rename, rm } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises";
import { dirname } from "node:path"; import { dirname } from "node:path";
@ -25,6 +25,30 @@ export class StoreReplacedNotDurableError extends Error {
} }
} }
/** Thrown when a write is asked of a store path that is a link: a replace there would swap the link for a regular file. */
export class StoreLinkedError extends Error {
constructor(readonly path: string) {
super(`CultCache store ${path} is a link, and a write at it would replace the link; configure the link's target as the store path.`);
this.name = "StoreLinkedError";
}
}
/** Refuses a link at the store path (file or directory link; libuv reports Windows junctions as links). Absent is not a link. */
export async function refuseLinkedStore(storePath: string): Promise<void> {
let linked = false;
try {
linked = (await lstat(storePath)).isSymbolicLink();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
if (linked) {
throw new StoreLinkedError(storePath);
}
}
/** The file operations of one commit. Tests record or fail them; production uses `nodeStoreFileOps`. */ /** The file operations of one commit. Tests record or fail them; production uses `nodeStoreFileOps`. */
export interface StoreFileOps { export interface StoreFileOps {
createExclusive(path: string): Promise<FileHandle>; createExclusive(path: string): Promise<FileHandle>;
@ -177,6 +201,7 @@ export async function withStoreWriteLock<T>(
): Promise<T> { ): Promise<T> {
const lockPath = `${storePath}.lock`; const lockPath = `${storePath}.lock`;
const timeoutMs = options.timeoutMs ?? LOCK_TIMEOUT_MS; const timeoutMs = options.timeoutMs ?? LOCK_TIMEOUT_MS;
await refuseLinkedStore(storePath);
await mkdir(dirname(lockPath), { recursive: true }); await mkdir(dirname(lockPath), { recursive: true });
const started = Date.now(); const started = Date.now();
@ -193,6 +218,8 @@ export async function withStoreWriteLock<T>(
} }
try { try {
// A link may have been put at the path while the lock was awaited; the look above only saw the path before.
await refuseLinkedStore(storePath);
return await operation(); return await operation();
} finally { } finally {
release(); release();

View file

@ -1,6 +1,6 @@
import { decode } from "@msgpack/msgpack"; import { decode } from "@msgpack/msgpack";
import type { CultCacheEnvelope } from "./types"; import type { CultCacheEnvelope, CultCacheUnreadSlots } from "./types";
export const STORE_FORMAT_VERSION = "cultcache.store.v1"; export const STORE_FORMAT_VERSION = "cultcache.store.v1";
@ -83,13 +83,16 @@ export class SchemaConflictError extends Error {
readonly schemaId: string; readonly schemaId: string;
readonly schemaNames: string[]; readonly schemaNames: string[];
readonly recordKey: 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(", ")}.`); super(message ?? `Schema id "${schemaId}" cannot describe record "${recordKey}": ${schemaNames.map((name) => `"${name}"`).join(", ")}.`);
this.name = "SchemaConflictError"; this.name = "SchemaConflictError";
this.schemaId = schemaId; this.schemaId = schemaId;
this.schemaNames = schemaNames; this.schemaNames = schemaNames;
this.recordKey = recordKey; this.recordKey = recordKey;
this.unreadSlots = unreadSlots;
} }
} }

View file

@ -99,6 +99,26 @@ export interface CultCacheEnvelope {
catalogEntry?: CultCacheSchemaCatalogEntry; 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 { export interface PushAllOptions {
soft?: boolean; soft?: boolean;
} }

View file

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

View file

@ -15,6 +15,7 @@ import { CultCache } from "../src/cult-cache";
import { inspectCultCacheBytes } from "../src/cult-cache-inspector"; import { inspectCultCacheBytes } from "../src/cult-cache-inspector";
import { defineDocumentRegistry, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; import { defineDocumentRegistry, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document";
import { SingleFileMessagePackBackingStore, readSingleFileStore, readStoreBytes } from "../src/single-file-messagepack-backing-store"; import { SingleFileMessagePackBackingStore, readSingleFileStore, readStoreBytes } from "../src/single-file-messagepack-backing-store";
import { StoreLinkedError, withStoreWriteLock } from "../src/store-file";
import { SchemaConflictError, StoreUnreadableError, refuseUnreadStoreBytes } from "../src/store-format"; import { SchemaConflictError, StoreUnreadableError, refuseUnreadStoreBytes } from "../src/store-format";
import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types"; import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types";
@ -33,8 +34,7 @@ const cultcacheRsRoot = existsSync(resolve(cultLibRoot, "packages", "cultcache-r
? resolve(cultLibRoot, "packages", "cultcache-rs") ? resolve(cultLibRoot, "packages", "cultcache-rs")
: resolve(cultCacheTsRoot, "..", "cultcache-rs"); : resolve(cultCacheTsRoot, "..", "cultcache-rs");
const rustInteropBinary = resolve( const rustInteropBinary = resolve(
cultcacheRsRoot, process.env.CARGO_TARGET_DIR ?? resolve(cultcacheRsRoot, "target"),
"target",
"debug", "debug",
"examples", "examples",
process.platform === "win32" ? "cultcache_interop.exe" : "cultcache_interop", process.platform === "win32" ? "cultcache_interop.exe" : "cultcache_interop",
@ -1019,6 +1019,7 @@ test("legacyKeyGlobalLoadsWithoutWritingAndFirstWriteLeavesOneGlobal", async ()
assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), { t: "new" }); assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), { t: "new" });
// Deleting an adopted global removes the legacy record. // Deleting an adopted global removes the legacy record.
await rm(storePath);
await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy]); await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy]);
const deleting = build(); const deleting = build();
await deleting.pullAllBackingStores(); await deleting.pullAllBackingStores();
@ -1026,10 +1027,30 @@ test("legacyKeyGlobalLoadsWithoutWritingAndFirstWriteLeavesOneGlobal", async ()
assert.deepEqual(await new SingleFileMessagePackBackingStore(storePath).pullAll(), []); assert.deepEqual(await new SingleFileMessagePackBackingStore(storePath).pullAll(), []);
// Two globals of one type on disk, under any keys, are refused. // 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 new SingleFileMessagePackBackingStore(storePath).pushAll([legacy, { ...legacy, key: CultCache.GLOBAL_KEY }]);
await assert.rejects(build().pullAllBackingStores(), /has multiple persisted entries/u); 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 () => { test("concurrentPutAndAttachCannotLandRecordInNonHomeStore", async () => {
const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ t: z.string() }) }); const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ t: z.string() }) });
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-put-attach-race-")); const tempDir = await mkdtemp(join(tmpdir(), "cultcache-put-attach-race-"));
@ -1252,6 +1273,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 // 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 // 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 // runtime's record and catalog entry are as that runtime wrote them. Identity is compared on the decoded MessagePack values, every
@ -2043,7 +2106,7 @@ async function readTsInteropStore(file: string): Promise<InteropNote> {
async function buildInteropPeers(): Promise<void> { async function buildInteropPeers(): Promise<void> {
if (!(await exists(rustInteropBinary))) { if (!(await exists(rustInteropBinary))) {
await execAsync(`"${cargoCommand}" build --quiet --example cultcache_interop`, { await execAsync(`"${cargoCommand}" build --locked --quiet --example cultcache_interop`, {
cwd: cultcacheRsRoot, cwd: cultcacheRsRoot,
}); });
} }
@ -2261,7 +2324,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 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 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"); const existing = join(await mkdtemp(join(tmpdir(), "cultcache-pushall-soft-")), "store.msgpack");
@ -2273,7 +2336,7 @@ test("pushAll leaves a store it reads alone when soft, and replaces one it reads
const store = new SingleFileMessagePackBackingStore(own); const store = new SingleFileMessagePackBackingStore(own);
await store.pushAll([envelope]); await store.pushAll([envelope]);
await store.pushAll([{ ...envelope, key: "second" }]); 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 () => { test("SingleFileMessagePackBackingStore reads only a missing store as empty", async () => {
@ -2404,12 +2467,36 @@ test("SingleFileMessagePackBackingStore refuses a dangling symlink at its path a
() => store.pullAll(), () => store.pullAll(),
(error: NodeJS.ErrnoException) => error.code === "ENOENT" && error.message.includes("symbolic link") && error.message.includes(file), (error: NodeJS.ErrnoException) => error.code === "ENOENT" && error.message.includes("symbolic link") && error.message.includes(file),
); );
await assert.rejects(() => store.push(envelope), (error: NodeJS.ErrnoException) => error.code === "ENOENT"); await assert.rejects(() => store.push(envelope), (error: unknown) => error instanceof StoreLinkedError);
await assert.rejects(() => store.pushAll([envelope], { soft: true }), (error: NodeJS.ErrnoException) => error.code === "ENOENT"); await assert.rejects(() => store.pushAll([envelope], { soft: true }), (error: unknown) => error instanceof StoreLinkedError);
assert.ok((await lstat(file)).isSymbolicLink()); assert.ok((await lstat(file)).isSymbolicLink());
assert.deepEqual(await readdir(volume), []); assert.deepEqual(await readdir(volume), []);
}); });
// The advisory query answers for the held record and the cache's judges; a link at the store path is the write's to refuse.
test("isWritable over a store at a live link answers true while the cache write is refused", { skip: process.platform === "win32" }, async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-writable-link-"));
const volume = join(dir, "volume");
await mkdir(volume);
const target = join(volume, "store.cc");
const link = join(dir, "link.cc");
const document = defineDocumentType({ type: "note", schema: z.object({ text: z.string() }) });
await CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(target)).build()
.put(document, "held", { text: "held" });
const before = await readFile(target);
await symlink(target, link);
const cache = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(link)).build();
await cache.pullAllBackingStores();
const namesBefore = [(await readdir(dir)).sort(), (await readdir(volume)).sort()];
assert.equal(cache.isWritable(document, "held"), true);
assert.equal(cache.isWritable(document, "fresh"), true);
await assert.rejects(() => cache.put(document, "fresh", { text: "fresh" }), (error: unknown) => error instanceof StoreLinkedError && error.path === link);
assert.ok((await lstat(link)).isSymbolicLink());
assert.deepEqual(await readFile(target), before);
assert.deepEqual([(await readdir(dir)).sort(), (await readdir(volume)).sort()], namesBefore);
});
// The second look of the read, staged: nothing is at the path only when the read and the lstat both find nothing. A path // The second look of the read, staged: nothing is at the path only when the read and the lstat both find nothing. A path
// that changed between them (a link in a parent swapped for a file, a store that appeared) is never an empty store. // that changed between them (a link in a parent swapped for a file, a store that appeared) is never an empty store.
test("readStoreBytes reports nothing at the path only when the lstat finds nothing too", async () => { test("readStoreBytes reports nothing at the path only when the lstat finds nothing too", async () => {
@ -2446,9 +2533,9 @@ test("SingleFileMessagePackBackingStore record refusal names only a string key",
} }
}); });
// What a write does through a symbolic link today. R3 decides whether a write resolves the link // A write at a store path that is a link is refused: a replace there would swap the link for a regular file and
// or refuses it; these pin the current behaviour so that change is deliberate. // detach the store from its target. Reads through a live link keep working.
test("R3 decides resolve-or-refuse: a push through a live link replaces the link", { skip: process.platform === "win32" }, async () => { test("a write at a live link is refused and nothing changes, while a read through it still works", { skip: process.platform === "win32" }, async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-live-link-")); const dir = await mkdtemp(join(tmpdir(), "cultcache-live-link-"));
const volume = join(dir, "volume"); const volume = join(dir, "volume");
await mkdir(volume); await mkdir(volume);
@ -2456,21 +2543,42 @@ test("R3 decides resolve-or-refuse: a push through a live link replaces the link
const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }; const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" };
await new SingleFileMessagePackBackingStore(target).push(envelope); await new SingleFileMessagePackBackingStore(target).push(envelope);
const before = await readFile(target); const before = await readFile(target);
const link = join(dir, "store.cc"); const volumeBefore = (await readdir(volume)).sort();
const link = join(dir, "link.cc");
await symlink(target, link); await symlink(target, link);
await new SingleFileMessagePackBackingStore(link).push({ ...envelope, key: "b" }); const store = new SingleFileMessagePackBackingStore(link);
assert.ok((await lstat(link)).isFile()); const refused = (error: unknown) => error instanceof StoreLinkedError && error.path === link && error.message.includes(link);
await assert.rejects(() => store.push({ ...envelope, key: "b" }), refused);
await assert.rejects(() => store.pushAll([{ ...envelope, key: "b" }]), refused);
await assert.rejects(() => store.delete(envelope), refused);
assert.ok((await lstat(link)).isSymbolicLink());
assert.deepEqual(await readFile(target), before); assert.deepEqual(await readFile(target), before);
assert.deepEqual((await readdir(dir)).sort(), ["link.cc", "volume"]);
assert.deepEqual((await readdir(volume)).sort(), volumeBefore);
assert.deepEqual((await store.pullAll()).map((e) => e.key), ["a"]);
}); });
test("R3 decides resolve-or-refuse: a pushAll through a dangling link replaces the link", { skip: process.platform === "win32" }, async () => { test("a write at a live directory link is refused as a link", { skip: process.platform === "win32" }, async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-dir-link-"));
const realDir = join(dir, "real");
await mkdir(realDir);
const link = join(dir, "dirlink");
await symlink(realDir, link);
const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" };
await assert.rejects(() => new SingleFileMessagePackBackingStore(link).push(envelope), (error: unknown) => error instanceof StoreLinkedError);
assert.ok((await lstat(link)).isSymbolicLink());
assert.deepEqual(await readdir(realDir), []);
});
test("a pushAll through a dangling link is refused like push, and the link and its volume are left as they were", { skip: process.platform === "win32" }, async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-dangling-push-all-")); const dir = await mkdtemp(join(tmpdir(), "cultcache-dangling-push-all-"));
const volume = join(dir, "volume"); const volume = join(dir, "volume");
await mkdir(volume); await mkdir(volume);
const link = join(dir, "store.cc"); const link = join(dir, "store.cc");
await symlink(join(volume, "store.cc"), link); await symlink(join(volume, "store.cc"), link);
await new SingleFileMessagePackBackingStore(link).pushAll([{ key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }]); const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" };
assert.ok((await lstat(link)).isFile()); await assert.rejects(() => new SingleFileMessagePackBackingStore(link).pushAll([envelope]), (error: unknown) => error instanceof StoreLinkedError);
assert.ok((await lstat(link)).isSymbolicLink());
assert.deepEqual(await readdir(volume), []); assert.deepEqual(await readdir(volume), []);
}); });
@ -2575,7 +2683,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, // 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. // 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 root = join(c2aVectors, "readability");
const rows = (await readFile(join(root, "manifest.txt"), "utf8")) const rows = (await readFile(join(root, "manifest.txt"), "utf8"))
.split(/\r?\n/u) .split(/\r?\n/u)
@ -2612,6 +2720,7 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads",
? store.push(envelopes[0]!) ? store.push(envelopes[0]!)
: store.delete(envelopes[0]!); : store.delete(envelopes[0]!);
if (reads) { if (reads) {
const heldBefore = operation === "pushAll" ? (await new SingleFileMessagePackBackingStore(file).pullAll()).map((entry) => entry.key) : [];
await run(); await run();
if (operation === "pullAll") { if (operation === "pullAll") {
continue; continue;
@ -2628,7 +2737,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}`); 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); const keys = (await new SingleFileMessagePackBackingStore(file).pullAll()).map((entry) => entry.key);
if (operation === "pushAll") { 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") { } else if (operation === "push") {
assert.ok(keys.includes(envelopes[0]!.key), `${vector} ${operation}`); assert.ok(keys.includes(envelopes[0]!.key), `${vector} ${operation}`);
} else { } else {
@ -2762,7 +2872,6 @@ test("every registration refusal is a SchemaConflictError naming the id and both
// Cross-runtime store lock: each runtime's writer excludes the others on `<file>.lock`. // Cross-runtime store lock: each runtime's writer excludes the others on `<file>.lock`.
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { withStoreWriteLock } from "../src/store-file";
type LockHolder = { exited: Promise<number | null>; stderr: () => string }; type LockHolder = { exited: Promise<number | null>; stderr: () => string };

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))); 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", () => { 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(arrays(100_000), "100000 nested arrays");
refused(chain(bytes(0x81, 0x00), 100_000), "100000 nested maps"); 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. // 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); 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

@ -10,6 +10,7 @@ import { promisify } from "node:util";
import { decode, encode } from "@msgpack/msgpack"; import { decode, encode } from "@msgpack/msgpack";
import { StoreLinkedError as PackageStoreLinkedError } from "../src/index";
import { documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; import { documentPayloadBytes, documentPayloadFromBytes } from "../src/document";
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store"; import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
import { SchemaConflictError, StoreUnreadableError } from "../src/store-format"; import { SchemaConflictError, StoreUnreadableError } from "../src/store-format";
@ -23,6 +24,7 @@ import {
replaceStoreFile, replaceStoreFile,
replaceStoreFileWindows, replaceStoreFileWindows,
windowsKernel, windowsKernel,
refuseLinkedStore,
withStoreWriteLock, withStoreWriteLock,
} from "../src/store-file"; } from "../src/store-file";
import type { ReplaceKernel, StoreFileOps } from "../src/store-file"; import type { ReplaceKernel, StoreFileOps } from "../src/store-file";
@ -415,6 +417,43 @@ test("a second writer in this process waits for the store lock and runs after re
} }
}); });
test("a link put while a writer waits on the store lock is refused once it holds the lock", { skip: process.platform === "win32" }, async () => {
const directory = await scratch();
try {
const target = join(directory, "target.cc");
await new SingleFileMessagePackBackingStore(target).push(noteEnvelope("a"));
const before = await readFile(target);
const writes: Array<[string, (store: SingleFileMessagePackBackingStore) => Promise<void>]> = [
["push", (store) => store.push(noteEnvelope("b"))],
["pushAll", (store) => store.pushAll([noteEnvelope("b")])],
["delete", (store) => store.delete(noteEnvelope("a"))],
];
const missing = join(directory, "missing.cc");
for (const [name, write, linkTo] of writes.flatMap(([n, w]) => [[n, w, target], [`${n} (dangling)`, w, missing]] as const)) {
const link = join(directory, `${name.replace(/\W+/g, "-")}.cc`);
let writer: Promise<void> | undefined;
await withStoreWriteLock(link, async () => {
writer = write(new SingleFileMessagePackBackingStore(link));
writer.catch(() => undefined);
await sleepFor(150);
await fsp.symlink(linkTo, link); // after the writer's pre-lock look, while it waits
});
await assert.rejects(writer!, (error: unknown) => error instanceof PackageStoreLinkedError, name);
assert.ok((await fsp.lstat(link)).isSymbolicLink(), `${name}: the link was replaced`);
assert.deepEqual(await readFile(target), before, `${name}: the target changed`);
await assert.rejects(fsp.lstat(missing), { code: "ENOENT" }, `${name}: the dangling target was created`);
await rm(link); // the link is refused before the lock, so remove it to probe the lock
assert.equal(await withStoreWriteLock(link, async () => "free", { timeoutMs: 200 }), "free", `${name}: the lock is held`);
}
} finally {
await rm(directory, { recursive: true, force: true });
}
});
function noteEnvelope(key: string): CultCacheEnvelope {
return { key, type: "note", payload: documentPayloadFromBytes(Uint8Array.of(0x91, 1)), storedAt: "2026-10-20T00:00:00Z" };
}
test("the store lock gives up after its wait bound and names the lock path", async () => { test("the store lock gives up after its wait bound and names the lock path", async () => {
const directory = await scratch(); const directory = await scratch();
try { try {
@ -670,7 +709,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(); const directory = await scratch();
try { try {
const path = join(directory, "store.cc"); const path = join(directory, "store.cc");
@ -681,7 +720,14 @@ test("pushAll replaces the whole store, and a soft pushAll leaves an existing st
await store.pushAll([entryOf("b", "t", 2)], { soft: true }); await store.pushAll([entryOf("b", "t", 2)], { soft: true });
assert.deepEqual([...(await readFile(path))], [...before], "soft leaves an existing store byte for byte"); assert.deepEqual([...(await readFile(path))], [...before], "soft leaves an existing store byte for byte");
await store.pushAll([entryOf("b", "t", 2)]); 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 { } finally {
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
} }
@ -811,3 +857,35 @@ test("opening writes nothing: a missing store leaves no lock file, a legacy stor
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
} }
}); });
test("StoreLinkedError is importable from the package entry and is what a write at a link throws", { skip: process.platform === "win32" }, async () => {
const directory = await scratch();
try {
const target = join(directory, "target.cc");
await writeFile(target, "x");
const link = join(directory, "link.cc");
await fsp.symlink(target, link);
await assert.rejects(
() => refuseLinkedStore(link),
(error: unknown) => error instanceof PackageStoreLinkedError && error.path === link && error.name === "StoreLinkedError",
);
await assert.rejects(() => new SingleFileMessagePackBackingStore(link).pushAll([]), (error: unknown) => error instanceof PackageStoreLinkedError);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("refuseLinkedStore lets a path that is absent pass and rethrows any other lstat failure", { skip: process.platform === "win32" }, async () => {
const directory = await scratch();
try {
await refuseLinkedStore(join(directory, "absent.cc"));
const file = join(directory, "file");
await writeFile(file, "x");
await assert.rejects(
() => refuseLinkedStore(join(file, "child.cc")),
(error: unknown) => (error as NodeJS.ErrnoException).code === "ENOTDIR",
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});

View file

@ -1,8 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join, resolve } from "node:path"; import { join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { decode, encode } from "@msgpack/msgpack"; import { decode, encode } from "@msgpack/msgpack";
@ -22,190 +21,14 @@ import {
encodeDocumentPayload, encodeDocumentPayload,
} from "../src/document"; } from "../src/document";
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store"; 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; 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() { async function outcomeEntries() {
const entries = decode(await read("outcomes.msgpack")) as [string, string, string, number | null, string][]; 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 })); 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("dd")), /truncated/u);
assert.throws(() => readWire(fromHex("ddffffffff")), /truncated/u, "a huge array header reserves nothing"); assert.throws(() => readWire(fromHex("ddffffffff")), /truncated/u, "a huge array header reserves nothing");
assert.throws(() => readWire(fromHex("a5616263")), /truncated/u); 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("81a161a162")), /map, extension or reserved/u);
assert.throws(() => readWire(fromHex("c1")), /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); 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)." }); 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", () => { test("arm keys span Int32 and are listed ascending; slot keys are non-negative Int32", () => {
const keys = union<{ kind: "High" | "Zero" | "Neg" | "Low" }>("U", { const keys = union<{ kind: "High" | "Zero" | "Neg" | "Low" }>("U", {
2147483647: ["High", unit], 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(); 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)); 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

@ -147,8 +147,8 @@ CultMath names with tests instead of copied project-local helper drift.
```powershell ```powershell
dotnet build packages/cultmath/src/CultMath/CultMath.csproj dotnet build packages/cultmath/src/CultMath/CultMath.csproj
dotnet test packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj dotnet test packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj
cargo test --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml cargo test --locked --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml
cargo build --release --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml cargo build --locked --release --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml
.\packages\cultmath\scripts\build-unity-package.ps1 .\packages\cultmath\scripts\build-unity-package.ps1
``` ```

View file

@ -9,6 +9,10 @@ All notable changes to `@gamecult/cultmesh-browser` are recorded here, following
- The package is renamed `cultmesh-browser` to `@gamecult/cultmesh-browser` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-browser` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`; the interop test uses `@gamecult/cultmesh-ts`. - The package is renamed `cultmesh-browser` to `@gamecult/cultmesh-browser` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-browser` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`; the interop test uses `@gamecult/cultmesh-ts`.
### Dependencies
- Needs `@gamecult/cultnet-ts` `^0.4.0` (was `^0.3.0`), moving with the `cultnet-ts` 0.4.0 release.
## [0.4.0] ## [0.4.0]
### Added ### Added

View file

@ -42,7 +42,7 @@
}, },
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.15.0", "@gamecult/cultcache-ts": "^0.15.0",
"@gamecult/cultnet-ts": "^0.3.0" "@gamecult/cultnet-ts": "^0.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",

View file

@ -180,6 +180,21 @@ class RemoteWriteAuthorityTests(unittest.TestCase):
reply = self.send(granting, unowned) reply = self.send(granting, unowned)
self.assertEqual(reply[0]["code"], "unregistered_document_put", "a granted put still meets the store's refusal") self.assertEqual(reply[0]["code"], "unregistered_document_put", "a granted put still meets the store's refusal")
def test_a_put_with_a_payload_tail_and_an_unknown_schema_id_is_refused_whole_under_the_canonical_id(self) -> None:
# The payload head names the registered schema, so the put routes by stamp; the tail makes the payload more
# than one MessagePack value. It is answered message_invalid (not unregistered_document_put), nothing is
# stored, and the authority is asked under the canonical id, not the id as sent.
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
server = self.build(authority)
tailed = put_of(KEY_A, "x", schema_id="sha256:nobody-owns-this")
tailed["document"]["payload"] = bytes(tailed["document"]["payload"]) + b"\x01"
replies = self.send(server, tailed)
self.assertEqual([(reply["code"], reply["details"]["field"]) for reply in replies], [("message_invalid", "document")])
self.assertIsNone(self.text_of(server, KEY_A))
self.assertEqual([request.schema_id for request in authority.requests], [SCHEMA_ID])
def test_an_authority_that_raises_refuses_and_is_logged_as_an_error(self) -> None: def test_an_authority_that_raises_refuses_and_is_logged_as_an_error(self) -> None:
def explode(_: Any) -> Any: def explode(_: Any) -> Any:
raise RuntimeError("authority exploded") raise RuntimeError("authority exploded")

View file

@ -732,21 +732,37 @@ impl CultMeshNode {
let cultnet_rs::CultNetMessage::SnapshotResponseRaw { documents, .. } = response else { let cultnet_rs::CultNetMessage::SnapshotResponseRaw { documents, .. } = response else {
anyhow::bail!("expected cultnet.snapshot_response_raw.v0"); 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 { for document in documents {
let Some(document_type) = let Some(document_type) =
registered_document_type_for_schema(&self.documents, &document.schema_id) registered_document_type_for_schema(&self.documents, &document.schema_id)
else { else {
continue; continue;
}; };
self.cache.put_raw_envelope(CultCacheEnvelope { staged.push(CultCacheEnvelope {
key: document.record_key, key: document.record_key,
r#type: document_type, r#type: document_type,
payload: document.payload, payload: document.payload,
stored_at: document.stored_at, stored_at: document.stored_at,
schema_id: Some(document.schema_id), schema_id: Some(document.schema_id),
})?; });
applied += 1; }
let applied = staged.len();
if applied > 0 {
self.cache.put_prepared_batch(staged)?;
} }
Ok(applied) Ok(applied)
} }
@ -1195,10 +1211,111 @@ mod tests {
body: String, 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 { cultmesh_documents!(TestDocuments {
Note => "cultmesh.test.note.v0", 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] #[test]
fn node_round_trips_registered_documents() -> Result<()> { fn node_round_trips_registered_documents() -> Result<()> {
let temp = tempfile::tempdir()?; let temp = tempfile::tempdir()?;

View file

@ -29,6 +29,8 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following
- Needs the cultcache-ts release that ships with this change (its manifest range moves with that release): it imports `schemaIdentityOf` and `SchemaConflictError`, which 0.14.x does not export. - Needs the cultcache-ts release that ships with this change (its manifest range moves with that release): it imports `schemaIdentityOf` and `SchemaConflictError`, which 0.14.x does not export.
- Needs `@gamecult/cultnet-ts` `^0.4.0` (was `^0.3.0`): this package imports names that `cultnet-ts` 0.3.0 does not export (`cultNetMessageInvalidError`, `cultNetTextError`, `cultNetWriteNotAuthorizedError`, `WriteTracker`, `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS`, `helloAdvertisesWriteReceipts`, `resultWindowMs`). The declared-range check (`npm run test:pack:ts`) found the old range admitting a release that lacks them.
## [0.2.0] ## [0.2.0]
### Added ### Added

View file

@ -50,7 +50,7 @@
}, },
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.15.0", "@gamecult/cultcache-ts": "^0.15.0",
"@gamecult/cultnet-ts": "^0.3.0", "@gamecult/cultnet-ts": "^0.4.0",
"koffi": "3.3.0" "koffi": "3.3.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -3933,7 +3933,7 @@ export class CultMeshNode {
} }
public async flush(soft = false): Promise<void> { 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 assert from "node:assert/strict";
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { z } from "zod"; import { z } from "zod";
import { SchemaConflictError, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts"; import { SchemaConflictError, SingleFileMessagePackBackingStore, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts";
import { import {
CultNetDocumentRegistry, CultNetDocumentRegistry,
CultNetPeer, 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")); 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 () => { 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 filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-doc-")), "node.ccmp");
const node = await CultMesh.startNode(filePath, { const node = await CultMesh.startNode(filePath, {

View file

@ -122,7 +122,7 @@ const rust: Target = {
name: "rust", name: "rust",
payload: (text) => encode({ text }), payload: (text) => encode({ text }),
async start() { async start() {
if (!prebuilt) await execFileAsync(cargo, ["build", "--example", "receipt_interop_server", "--message-format", "short"], { cwd: rustManifestDir }); if (!prebuilt) await execFileAsync(cargo, ["build", "--locked", "--example", "receipt_interop_server", "--message-format", "short"], { cwd: rustManifestDir });
const metadata = spawnSync(cargo, ["metadata", "--format-version", "1", "--no-deps"], { cwd: rustManifestDir, encoding: "utf8" }); const metadata = spawnSync(cargo, ["metadata", "--format-version", "1", "--no-deps"], { cwd: rustManifestDir, encoding: "utf8" });
if (metadata.status !== 0) throw new Error(`cargo metadata failed: ${metadata.stderr}`); if (metadata.status !== 0) throw new Error(`cargo metadata failed: ${metadata.stderr}`);
const exe = join(JSON.parse(metadata.stdout).target_directory, "debug", "examples", `receipt_interop_server${process.platform === "win32" ? ".exe" : ""}`); const exe = join(JSON.parse(metadata.stdout).target_directory, "debug", "examples", `receipt_interop_server${process.platform === "win32" ? ".exe" : ""}`);

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.", (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,7 +2,7 @@
## Unreleased (breaking; version 0.x, so the next minor) ## 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. - `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). `resolve_document_for_raw_record` reads the schema hint from a raw record's first value whether or not bytes follow it, so a record routed by its stamp is refused by count when applied (`ValueError`, in a snapshot, a shard log and a direct apply) like one routed by a registered schema id; `apply_shard_log_response` used to skip it silently as an unknown schema. A tail on a payload whose first value names no registered schema is still `KeyError`, which a shard log skips.
- `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. - `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. - `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. - 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.

View file

@ -174,10 +174,11 @@ def _infer_schema_version_from_payload(payload: bytes) -> str | None:
import msgpack # type: ignore import msgpack # type: ignore
try: try:
decoded = msgpack.unpackb(payload, raw=False) decoded = msgpack.unpackb(payload, raw=False)
except msgpack.ExtraData: except msgpack.ExtraData as extra:
# A payload with bytes after its value gives no routing hint: the record is unroutable by inference, and a # The hint is read from the first value; the tail is not this function's to judge. The routed definition refuses
# registered schema id routes it to a definition whose decode refuses the tail by count. # it by count when the record is applied, as it does for a record routed by a registered schema id. Treating a
return None # tail as "no hint" made a stamp-routed record look like an unknown schema, which a shard log skips silently.
decoded = extra.unpacked
except Exception: except Exception:
return None return None
if isinstance(decoded, list) and decoded and isinstance(decoded[0], str): if isinstance(decoded, list) and decoded and isinstance(decoded[0], str):

View file

@ -1971,7 +1971,7 @@ class CultNetTests(unittest.TestCase):
apply_raw_document_record(cache, schema_document_map([first, second]), raw(first)) # the same type still replaces 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") 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: def test_a_raw_record_with_a_tail_is_routed_by_its_stamp_and_refused_by_count_on_every_apply_path(self) -> None:
import msgpack # type: ignore import msgpack # type: ignore
from cultcache_py import define_document_type from cultcache_py import define_document_type
from cultnet_py import resolve_document_for_raw_record from cultnet_py import resolve_document_for_raw_record
@ -1986,18 +1986,33 @@ class CultNetTests(unittest.TestCase):
"payloadEncoding": "messagepack", "payload": payload} "payloadEncoding": "messagepack", "payload": payload}
documents = schema_document_map([document]) 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 # The stamp is read from the first value whether or not a tail follows, so an unknown schema id whose payload names
# stays the unknown-schema error (KeyError) even when the first value names a registered schema. # a registered schema routes to it; the tail is then refused by count where the record is applied.
self.assertIs(resolve_document_for_raw_record(documents, "unknown", raw("unknown", head)), document) self.assertIs(resolve_document_for_raw_record(documents, "unknown", raw("unknown", head + b"\xc0")), document)
for schema_id in (known, "unknown"):
cache = CultCache()
cache.register_document_type(document)
with self.assertRaises(ValueError, msg=schema_id) as caught:
apply_raw_document_record(cache, documents, raw(schema_id, head + b"\xc0"))
self.assertIn("1 bytes after its first MessagePack value", str(caught.exception))
self.assertIsNone(cache.get(document, "k"))
snapshot = {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": "s",
"documents": [raw(schema_id, head + b"\xc0")]}
with self.assertRaises(ValueError, msg=schema_id):
apply_raw_snapshot(cache, [document], snapshot)
log = {"schemaVersion": "cultnet.shard_log_response.v0", "messageId": "l", "shardId": "s", "shardEpoch": 1,
"resyncRequired": False,
"entries": [{"sequence": 0, "changeKind": "updated",
"put": {"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "p",
"document": raw(schema_id, head + b"\xc0"), "shardId": "s", "shardEpoch": 1}}]}
with self.assertRaises(ValueError, msg=schema_id) as caught:
apply_shard_log_response(cache, [document], log)
self.assertIn("1 bytes after its first MessagePack value", str(caught.exception))
self.assertIsNone(cache.get(document, "k"))
# A tail on a payload that names no registered schema is still an unknown schema, which a shard log skips.
stranger = msgpack.packb(["tests.stranger.v1", "x"], use_bin_type=True) + b"\xc0"
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
resolve_document_for_raw_record(documents, "unknown", raw("unknown", head + b"\xc0")) resolve_document_for_raw_record(documents, "unknown", raw("unknown", stranger))
# 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: def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None:
document = define_database_entry_type( document = define_database_entry_type(

View file

@ -1,2 +1 @@
/target /target
Cargo.lock

View file

@ -15,5 +15,6 @@ only in git history.
### Breaking ### 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(...)?`. - `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. - `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.

1227
packages/cultnet-rs/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -30,7 +30,7 @@ knock on the contract before touching the furniture.
## Receipts ## Receipts
```powershell ```powershell
cargo test cargo test --locked
``` ```
The initial tests prove: The initial tests prove:

View file

@ -7,7 +7,7 @@
//! traffic crosses the same schedule. The schedule is `policy.rs`. //! traffic crosses the same schedule. The schedule is `policy.rs`.
//! //!
//! ```text //! ```text
//! cargo run --release --example cultnet_impair -- \ //! cargo run --locked --release --example cultnet_impair -- \
//! --listen 0.0.0.0:17890 --upstream 127.0.0.1:17990 \ //! --listen 0.0.0.0:17890 --upstream 127.0.0.1:17990 \
//! --profile tests/impairment/loss-1pct.toml --seed 424242 --metrics loss-1pct.csv //! --profile tests/impairment/loss-1pct.toml --seed 424242 --metrics loss-1pct.csv
//! ``` //! ```

View file

@ -146,6 +146,7 @@ impl CultNetDocumentRegistry {
schema_id: binding.schema_id.clone(), schema_id: binding.schema_id.clone(),
schema_names: vec![holder.document_type.clone(), binding.document_type.clone()], schema_names: vec![holder.document_type.clone(), binding.document_type.clone()],
record_key: String::new(), record_key: String::new(),
unread_slots: None,
})); }));
} }
self.bindings_by_schema_id 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); assert_eq!(documents.len(), 1);
backing.push(&envelope("past", limit + 1))?; // The sender cannot load a payload past the bound, so the frame is the at-limit one with its bytes swapped.
let mut cache = CultCache::new(); let mut past = at_limit.clone();
cache.register_entry_type::<GhostlightAgentStateFixture>()?; let CultNetMessage::SnapshotResponseRaw { documents, .. } = &mut past else { unreachable!() };
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store))?; documents[0].payload = envelope("past", limit + 1).payload;
cache.pull_all_backing_stores()?;
let past = registry.create_raw_snapshot_response(&cache, "past", None, Some(&["past".to_string()]))?;
let mut target = CultCache::new(); let mut target = CultCache::new();
target.register_entry_type::<GhostlightAgentStateFixture>()?; target.register_entry_type::<GhostlightAgentStateFixture>()?;
let error = registry 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> { fn raw_v1_bytes(header_unknown: Option<&[u8]>) -> Vec<u8> {
let mut header: Vec<(rmpv::Value, rmpv::Value)> = vec![ let mut header: Vec<(rmpv::Value, rmpv::Value)> = vec![
("schemaId".into(), "s".into()), ("schemaId".into(), "s".into()),

View file

@ -3,7 +3,7 @@
//! Ignored by default; run in release, where the numbers mean something: //! Ignored by default; run in release, where the numbers mean something:
//! //!
//! ```text //! ```text
//! cargo test --release --test media_fec_bench -- --ignored --nocapture //! cargo test --locked --release --test media_fec_bench -- --ignored --nocapture
//! ``` //! ```
//! //!
//! Each line is nanoseconds per call through the public surface, so record //! Each line is nanoseconds per call through the public surface, so record

View file

@ -3,7 +3,7 @@
All notable changes to `@gamecult/cultnet-ts` are recorded here, following All notable changes to `@gamecult/cultnet-ts` are recorded here, following
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## Unreleased ## [0.4.0]
### Breaking ### Breaking

View file

@ -1,6 +1,6 @@
{ {
"name": "@gamecult/cultnet-ts", "name": "@gamecult/cultnet-ts",
"version": "0.3.0", "version": "0.4.0",
"description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.", "description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

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", () => { 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/); assert.throws(() => decodeManifest(encode({ 0: "a", 9: { k: "v" } })), /field 'manifest' has the wrong type/);
}); });

View file

@ -14,6 +14,8 @@ import {
SchemaConflictError, SchemaConflictError,
SingleFileMessagePackBackingStore, SingleFileMessagePackBackingStore,
defineDocumentType, defineDocumentType,
documentPayloadBytes,
documentPayloadFromBytes,
schemaIdentityOf, schemaIdentityOf,
} from "@gamecult/cultcache-ts"; } 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.nobody"), undefined);
assert.equal(registry.canonicalSchemaId("p3.shared"), undefined, "an id several bindings claim resolves to none"); 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

@ -6,7 +6,7 @@ cultnet-rs only, and Idunn's `IdunnRuntimeActivationLaunch` is that same crate's
It needs Linux and a machine-id equal to the `machine-id` file here, because the It needs Linux and a machine-id equal to the `machine-id` file here, because the
provider identity is bound to it. On Yggdrasil: provider identity is bound to it. On Yggdrasil:
DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh <CultLib> HEAD rust 'cd packages/cultnet-rs && cp <fixed-machine-id> /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run -q --example idunn_runtime_fixture /tmp/out' DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh <CultLib> HEAD rust 'cd packages/cultnet-rs && cp <fixed-machine-id> /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run --locked -q --example idunn_runtime_fixture /tmp/out'
then copy `/tmp/out` over this directory. `--cap-add SYS_ADMIN` exists only so the bind mount can present the committed `machine-id`, which keeps Yggdrasil's real machine-id out of the repo. Every `presence-*.bin` was accepted by then copy `/tmp/out` over this directory. `--cap-add SYS_ADMIN` exists only so the bind mount can present the committed `machine-id`, which keeps Yggdrasil's real machine-id out of the repo. Every `presence-*.bin` was accepted by
`authenticate_runtime_presence_claim` and correlated by `authenticate_runtime_presence_claim` and correlated by
@ -26,7 +26,7 @@ The generator takes its specification as input, so a consumer needs no patch to
specification is command-line flags; `@<file>` splices in a response file (one `--flag value` specification is command-line flags; `@<file>` splices in a response file (one `--flag value`
per line, `#` comments). CultLib's own sets are `fixture.args` here: per line, `#` comments). CultLib's own sets are `fixture.args` here:
cargo run -q --example idunn_runtime_fixture /tmp/out @<path>/fixture.args cargo run --locked -q --example idunn_runtime_fixture /tmp/out @<path>/fixture.args
The consumer keeps its own `.args` file in its repository and runs the same command with the The consumer keeps its own `.args` file in its repository and runs the same command with the
same machine-id bind mount as above. Global flags: `--source-repository`, `--detail`. Each same machine-id bind mount as above. Global flags: `--source-repository`, `--detail`. Each

View file

@ -2795,7 +2795,7 @@ async function buildInteropPeers(): Promise<void> {
} }
async function buildRustInteropPeer(): Promise<void> { async function buildRustInteropPeer(): Promise<void> {
rustInteropPeerBuild ??= execFileAsync(cargoCommand, ["build", "--quiet", "--example", "cultnet_interop_peer"], { rustInteropPeerBuild ??= execFileAsync(cargoCommand, ["build", "--locked", "--quiet", "--example", "cultnet_interop_peer"], {
cwd: cultnetRsRoot, cwd: cultnetRsRoot,
}).then(() => undefined); }).then(() => undefined);
await rustInteropPeerBuild; await rustInteropPeerBuild;

View file

@ -2,7 +2,7 @@
// //
// The vectors below were produced by `cultnet-rs`, not written by hand: // The vectors below were produced by `cultnet-rs`, not written by hand:
// //
// cargo run --example runtime_presence_vector -p cultnet-rs // cargo run --locked --example runtime_presence_vector -p cultnet-rs
// //
// That matters. A fixture authored for both sides at once agrees with itself // That matters. A fixture authored for both sides at once agrees with itself
// and proves nothing; the contract is only real if one fork emits the bytes and // and proves nothing; the contract is only real if one fork emits the bytes and

View file

@ -7,7 +7,7 @@ plus the rules that need no store to state (`Answer::is_training_data`, `Answer:
(comments 682 and 759). Specification only: no collector, tally, export or tombstone hashing lives here. (comments 682 and 759). Specification only: no collector, tally, export or tombstone hashing lives here.
Entry points: `src/lib.rs`; golden vectors `tests/vectors/consent_golden.csv`, run by `tests/golden.rs` Entry points: `src/lib.rs`; golden vectors `tests/vectors/consent_golden.csv`, run by `tests/golden.rs`
(`cargo test`). Planned, not live: the collector, the tally, the training export. (`cargo test --locked`). Planned, not live: the collector, the tally, the training export.
## Where each ruling lands (doc lines at aaeb175) ## Where each ruling lands (doc lines at aaeb175)

View file

@ -11,8 +11,8 @@ adversary, storage, and any projection. Every parameter is a placeholder until t
## First call ## First call
``` ```
cargo run --example first_call # prints the Meta example and a splitting table cargo run --locked --example first_call # prints the Meta example and a splitting table
cargo test cargo test --locked
``` ```
```rust ```rust

View file

@ -857,7 +857,7 @@ test("workflow: each job releases only through release-after-suite.mjs", () => {
}); });
test("release-after-suite checks the tag, then runs the suite, the semver check and the package's tests, and releases only when all passed", async () => { test("release-after-suite checks the tag, then runs the suite, the semver check and the package's tests, and releases only when all passed", async () => {
const { releaseAfterSuite, suite, checker } = await import("./release-after-suite.mjs"); const { releaseAfterSuite, suite, checker, rangeCheck } = await import("./release-after-suite.mjs");
const npmVersion = JSON.parse(readFileSync(join(repoRoot, "packages", "cultcache-ts", "package.json"), "utf8")).version; const npmVersion = JSON.parse(readFileSync(join(repoRoot, "packages", "cultcache-ts", "package.json"), "utf8")).version;
const npmDir = join(repoRoot, "packages", "cultcache-ts"); const npmDir = join(repoRoot, "packages", "cultcache-ts");
const pyDir = join(repoRoot, "packages", "cultnet-py"); const pyDir = join(repoRoot, "packages", "cultnet-py");
@ -881,13 +881,15 @@ test("release-after-suite checks the tag, then runs the suite, the semver check
const npmCalls = [ const npmCalls = [
suiteCall, suiteCall,
checkerCall("@gamecult/cultcache-ts", npmVersion), checkerCall("@gamecult/cultcache-ts", npmVersion),
["npm", ["run", "build:ts"], repoRoot, checked],
[process.execPath, [rangeCheck], repoRoot, checked],
["npm", ["test"], npmDir, checked], ["npm", ["test"], npmDir, checked],
["npm", ["publish", "--access", "public"], npmDir, env], ["npm", ["publish", "--access", "public"], npmDir, env],
]; ];
assert.equal(go(["npm-publish", npmDir, npmTag]), 0); assert.equal(go(["npm-publish", npmDir, npmTag]), 0);
assert.deepEqual(calls, npmCalls); assert.deepEqual(calls, npmCalls);
// any check that fails stops the release at that check // any check that fails stops the release at that check
for (const fail of [0, 1, 2]) { for (const fail of [0, 1, 2, 3, 4]) {
assert.equal(go(["npm-publish", npmDir, npmTag], fail), 1, `check ${fail}`); assert.equal(go(["npm-publish", npmDir, npmTag], fail), 1, `check ${fail}`);
assert.deepEqual(calls, npmCalls.slice(0, fail + 1), `check ${fail}`); assert.deepEqual(calls, npmCalls.slice(0, fail + 1), `check ${fail}`);
} }

View file

@ -1,7 +1,9 @@
// How publish-packages.yml releases a package. From this one process, in order: the release tag must name // How publish-packages.yml releases a package. From this one process, in order: the release tag must name
// the package's declared prefix and its manifest version, the release-checks suite must pass, the semver // the package's declared prefix and its manifest version, the release-checks suite must pass, the semver
// policy check must pass for that version, the package's own tests must pass, and only then the release // policy check must pass for that version, an npm release's declared dependency ranges must hold (the
// action runs. No workflow condition can then release what any of those checks did not pass. // workspace is built, then every tagged release a sibling's range admits is checked against what the
// sibling imports), the package's own tests must pass, and only then the release action runs. No workflow
// condition can then release what any of those checks did not pass.
// docs/semver-policy.md, "The suite gates every release". // docs/semver-policy.md, "The suite gates every release".
// //
// node scripts/release-after-suite.mjs npm-publish <package dir> <git ref> npm publish --access public in <dir> // node scripts/release-after-suite.mjs npm-publish <package dir> <git ref> npm publish --access public in <dir>
@ -18,6 +20,7 @@ import releasePackages from "./release-packages.mjs";
export const suite = fileURLToPath(new URL("./check-changelog-semver.test.mjs", import.meta.url)); export const suite = fileURLToPath(new URL("./check-changelog-semver.test.mjs", import.meta.url));
export const checker = fileURLToPath(new URL("./check-changelog-semver.mjs", import.meta.url)); export const checker = fileURLToPath(new URL("./check-changelog-semver.mjs", import.meta.url));
export const rangeCheck = fileURLToPath(new URL("./test-declared-dependency-ranges.mjs", import.meta.url));
const repoRoot = fileURLToPath(new URL("..", import.meta.url)); const repoRoot = fileURLToPath(new URL("..", import.meta.url));
// The checks run without the variables that can make `node --test` run nothing and exit 0 // The checks run without the variables that can make `node --test` run nothing and exit 0
@ -34,6 +37,8 @@ export const actions = {
"npm-publish": { "npm-publish": {
manifest: (dir) => JSON.parse(readFileSync(join(dir, "package.json"), "utf8")), manifest: (dir) => JSON.parse(readFileSync(join(dir, "package.json"), "utf8")),
test: (dir) => ["npm", ["test"], dir], test: (dir) => ["npm", ["test"], dir],
// The range check builds each tagged release against the workspace's built dists, so it follows a workspace build.
ranges: [["npm", ["run", "build:ts"], repoRoot], [process.execPath, [rangeCheck], repoRoot]],
release: (dir) => ["npm", ["publish", "--access", "public"], dir], release: (dir) => ["npm", ["publish", "--access", "public"], dir],
tagOnly: true, tagOnly: true,
}, },
@ -83,6 +88,9 @@ export function releaseAfterSuite(argv, run = spawn, env = process.env) {
if (isTag && run(process.execPath, [checker, "--package", manifest.name, "--version", manifest.version, "--cwd", repoRoot], undefined, clean) !== 0) { if (isTag && run(process.execPath, [checker, "--package", manifest.name, "--version", manifest.version, "--cwd", repoRoot], undefined, clean) !== 0) {
return refuse(`${manifest.name} ${manifest.version} fails the semver policy (docs/semver-policy.md)`); return refuse(`${manifest.name} ${manifest.version} fails the semver policy (docs/semver-policy.md)`);
} }
for (const [command, args, cwd] of isTag ? action.ranges ?? [] : []) {
if (run(command, args, cwd, clean) !== 0) return refuse(`${manifest.name}'s declared dependency ranges do not hold (scripts/test-declared-dependency-ranges.mjs)`);
}
if (run(...action.test(dir), clean) !== 0) return refuse(`${manifest.name}'s own tests failed`); if (run(...action.test(dir), clean) !== 0) return refuse(`${manifest.name}'s own tests failed`);
return run(...action.release(dir), env); return run(...action.release(dir), env);
} }

View file

@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const rootRequire = createRequire(join(repoRoot, "package.json")); const rootRequire = createRequire(join(repoRoot, "package.json"));
const ts = rootRequire("typescript"); const ts = rootRequire("typescript");
const npmCli = join(npmRoot(), "bin", "npm-cli.js");
const semver = createRequire(join(npmRoot(), "package.json"))("semver"); const semver = createRequire(join(npmRoot(), "package.json"))("semver");
// npm ships semver, so the check needs no dependency of its own. npm's root is beside the node binary on Windows and under lib/ on Unix. // npm ships semver, so the check needs no dependency of its own. npm's root is beside the node binary on Windows and under lib/ on Unix.
@ -84,16 +85,17 @@ function sourceFiles(directory) {
}); });
} }
// Builds the package as the tag released it, in a scratch directory that borrows the workspace's node_modules. // Builds the package as the tag released it (with the repo-level contracts its generator reads), in a scratch directory that borrows the workspace's node_modules.
function buildRelease(directory, tag, scratch) { function buildRelease(directory, tag, scratch) {
const archive = join(scratch, `${tag}.tar`); const archive = join(scratch, `${tag}.tar`);
git("archive", "--format=tar", "-o", archive, tag, `packages/${directory}`); git("archive", "--format=tar", "-o", archive, tag, `packages/${directory}`, "contracts");
const extracted = join(scratch, tag); const extracted = join(scratch, tag);
mkdirSync(extracted); mkdirSync(extracted);
execFileSync("tar", ["-xf", archive, "-C", extracted]); execFileSync("tar", ["-xf", archive, "-C", extracted]);
symlinkSync(join(repoRoot, "node_modules"), join(extracted, "node_modules"), "junction"); symlinkSync(join(repoRoot, "node_modules"), join(extracted, "node_modules"), "junction");
const packageRoot = join(extracted, "packages", directory); const packageRoot = join(extracted, "packages", directory);
execFileSync(process.execPath, [rootRequire.resolve("typescript/bin/tsc"), "-p", "tsconfig.json"], { cwd: packageRoot, stdio: "inherit" }); // The package's own build, which is how a release was made: cultnet-ts generates its contracts module first.
execFileSync(process.execPath, [npmCli, "run", "build", "--silent"], { cwd: packageRoot, stdio: "inherit" });
return rootRequire(join(packageRoot, "dist", "index.js")); return rootRequire(join(packageRoot, "dist", "index.js"));
} }
@ -127,7 +129,7 @@ try {
const prefix = `${dependency.directory}-v`; const prefix = `${dependency.directory}-v`;
const admitted = tags.filter((tag) => { const admitted = tags.filter((tag) => {
const version = tag.startsWith(prefix) ? tag.slice(prefix.length) : undefined; const version = tag.startsWith(prefix) ? tag.slice(prefix.length) : undefined;
return version !== undefined && semver.valid(version) !== null && semver.satisfies(version, range) && version !== dependency.manifest.version; return version !== undefined && semver.valid(version) !== null && semver.satisfies(version, range);
}); });
for (const tag of admitted) { for (const tag of admitted) {
if (!built.has(tag)) { if (!built.has(tag)) {

View file

@ -0,0 +1,71 @@
// test-declared-dependency-ranges.mjs against a fixture repo: a dependency that gained an export a dependent imports,
// while its manifest still carries the version of its latest release tag, must fail the check, because a consumer's
// range admits that tagged release and it lacks the export. Bumping the version and the range makes it pass.
// The fixture package a builds like cultnet-ts: its build script first generates a module from the repo-level contracts/
// directory, so a tag built by a bare tsc, or archived without contracts/, fails here as it does on the real tree.
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const script = join(repoRoot, "scripts", "test-declared-dependency-ranges.mjs");
const write = (root, path, text) => {
mkdirSync(dirname(join(root, path)), { recursive: true });
writeFileSync(join(root, path), text);
};
const git = (root, ...args) => execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...args], { cwd: root, stdio: "pipe" });
const tsc = "node ../../node_modules/typescript/bin/tsc -p tsconfig.json";
const manifest = (name, version, extra = {}, build = tsc) =>
JSON.stringify({ name: `@t/${name}`, version, scripts: { build }, ...extra });
const generated = "node tools/generate.mjs && " + tsc;
const tsconfig = JSON.stringify({ compilerOptions: { outDir: "dist", module: "commonjs", target: "es2020", declaration: false }, include: ["src"] });
function fixture() {
const root = mkdtempSync(join(tmpdir(), "declared-ranges-fixture-"));
write(root, "package.json", JSON.stringify({ name: "fixture", private: true, workspaces: ["packages/a", "packages/b"] }));
write(root, "contracts/value.json", "2");
write(root, "packages/a/tools/generate.mjs",
'import { readFileSync, writeFileSync } from "node:fs";\n' +
'writeFileSync("src/data.ts", `export const contractValue = ${readFileSync("../../contracts/value.json", "utf8")};\\n`);\n');
write(root, "packages/a/package.json", manifest("a", "1.0.0", {}, generated));
write(root, "packages/a/tsconfig.json", tsconfig);
write(root, "packages/a/src/index.ts", 'export { contractValue } from "./data";\nexport const foo = 1;\n');
write(root, "packages/b/package.json", manifest("b", "1.0.0", { dependencies: { "@t/a": "^1.0.0" } }));
write(root, "packages/b/tsconfig.json", tsconfig);
write(root, "packages/b/src/index.ts", 'import { foo } from "@t/a";\nexport const b = foo;\n');
mkdirSync(join(root, "scripts"));
copyFileSync(script, join(root, "scripts", "test-declared-dependency-ranges.mjs"));
symlinkSync(join(repoRoot, "node_modules"), join(root, "node_modules"), "junction");
git(root, "init", "-q", "-b", "main");
git(root, "add", "package.json", "contracts", "packages", "scripts");
git(root, "commit", "-q", "-m", "release a 1.0.0");
git(root, "tag", "a-v1.0.0");
return root;
}
const check = (root) => spawnSync(process.execPath, [join(root, "scripts", "test-declared-dependency-ranges.mjs")], { cwd: root, encoding: "utf8" });
test("a dependency that gained an export a dependent imports, still at its latest tag's version, fails the check", () => {
const root = fixture();
try {
assert.equal(check(root).status, 0, "the fixture passes before the export is added");
write(root, "packages/a/src/index.ts", 'export { contractValue } from "./data";\nexport const foo = 1;\nexport const bar = 2;\n');
write(root, "packages/b/src/index.ts", 'import { foo, bar } from "@t/a";\nexport const b = foo + bar;\n');
const stale = check(root);
assert.notEqual(stale.status, 0, stale.stdout);
assert.match(stale.stderr, /a-v1\.0\.0 does not export bar/u);
write(root, "packages/a/package.json", manifest("a", "1.1.0", {}, generated));
write(root, "packages/b/package.json", manifest("b", "1.0.0", { dependencies: { "@t/a": "^1.1.0" } }));
const bumped = check(root);
assert.equal(bumped.status, 0, bumped.stderr);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

View file

@ -237,7 +237,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
}); });
} }
public override void PushAll() public override void PushAll(bool soft)
{ {
ThrowIfReadOnly(); ThrowIfReadOnly();
Held(() => Held(() =>
@ -247,7 +247,12 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
Directory.CreateDirectory(_manifestFile.DirectoryName!); Directory.CreateDirectory(_manifestFile.DirectoryName!);
using var commitLease = AcquireCommitLease(wait: true, create: true); using var commitLease = AcquireCommitLease(wait: true, create: true);
var (manifest, catalogBytes) = ReadManifest(); var manifestBytes = ReadManifestBytes();
var (manifest, catalogBytes) = ParseManifest(manifestBytes);
// A soft flush writes only where no manifest is: the reader's answer (null is absent), not a second look at the path.
// A manifest it read stays as it is, its staged writes staged.
if (soft && manifestBytes != null)
return;
RefuseUndeclaredOnDisk(manifest, StagedKeys, Array.Empty<CultStoredDocument>(), Array.Empty<CultStoredDocument>(), Array.Empty<string>()); RefuseUndeclaredOnDisk(manifest, StagedKeys, Array.Empty<CultStoredDocument>(), Array.Empty<CultStoredDocument>(), Array.Empty<string>());
if (IsDirty) if (IsDirty)
WriteGeneration(manifest, catalogBytes); WriteGeneration(manifest, catalogBytes);
@ -681,14 +686,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
stream.Flush(true); stream.Flush(true);
} }
if (File.Exists(path)) StoreReplace.Replace(tempPath, path);
{
ReplaceExistingFile(tempPath, path);
}
else
{
File.Move(tempPath, path);
}
} }
finally finally
{ {
@ -710,17 +708,23 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
// Only a writer creates the records folder and the lock, so opening the store creates nothing. A reader opens an // Only a writer creates the records folder and the lock, so opening the store creates nothing. A reader opens an
// existing lock and returns null when there is none; that does not mean no writer committed (a store may be copied // existing lock and returns null when there is none; that does not mean no writer committed (a store may be copied
// without its lock, and a first writer may create it mid-load), so an unleased load checks that it settled. // without its lock, and a first writer may create it mid-load), so an unleased load checks that it settled.
// A writer's lease is also where a link at the manifest path is refused (a commit would replace the link with a file): before
// the records folder or the lock is made, and again once the lease is held, because a link can be put there while it waited.
private FileStream? AcquireCommitLease(bool wait, bool create) private FileStream? AcquireCommitLease(bool wait, bool create)
{ {
if (create) if (create)
{
CultStoreLinks.Refuse(_manifestFile.FullName);
Directory.CreateDirectory(_recordDirectory.FullName); Directory.CreateDirectory(_recordDirectory.FullName);
}
var lockPath = Path.Combine(_recordDirectory.FullName, ".commit.lock"); var lockPath = Path.Combine(_recordDirectory.FullName, ".commit.lock");
var started = Stopwatch.StartNew(); var started = Stopwatch.StartNew();
while (true) while (true)
{ {
FileStream held;
try try
{ {
return new FileStream( held = new FileStream(
lockPath, lockPath,
create ? FileMode.OpenOrCreate : FileMode.Open, create ? FileMode.OpenOrCreate : FileMode.Open,
FileAccess.ReadWrite, FileAccess.ReadWrite,
@ -743,29 +747,23 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30)) catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30))
{ {
Thread.Sleep(10); Thread.Sleep(10);
continue;
} }
}
}
private static void ReplaceExistingFile(string sourcePath, string destinationPath) if (create)
{
for (var attempt = 0; attempt < 3; attempt++)
{
try
{ {
File.Replace(sourcePath, destinationPath, null); try
return; {
CultStoreLinks.Refuse(_manifestFile.FullName);
}
catch
{
held.Dispose();
throw;
}
} }
catch (IOException) when (attempt < 2)
{
Thread.Sleep(10 * (attempt + 1));
}
catch (UnauthorizedAccessException) when (attempt < 2)
{
Thread.Sleep(10 * (attempt + 1));
}
}
File.Replace(sourcePath, destinationPath, null); return held;
}
} }
} }

View file

@ -8,7 +8,7 @@
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" /> <ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="..\..\packages\cultmath\src\CultMath\CultMath.csproj" /> <ProjectReference Include="..\..\packages\cultmath\src\CultMath\CultMath.csproj" />
<PackageReference Include="MessagePack" Version="3.1.7" /> <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" /> <PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
<InternalsVisibleTo Include="GameCult.Caching.Tests" /> <InternalsVisibleTo Include="GameCult.Caching.Tests" />
<InternalsVisibleTo Include="GameCult.Caching.GeneratorConsumer" /> <InternalsVisibleTo Include="GameCult.Caching.GeneratorConsumer" />

View file

@ -315,11 +315,138 @@ refuses to replace a file whose header it cannot read, and leaves it as it was.
in that store, and leaves the store clean. in that store, and leaves the store clean.
- A conditional commit lands the batch onto the file as it is under the lock. - A conditional commit lands the batch onto the file as it is under the lock.
C#, Rust and TypeScript writers exclude each other on the shared lock file; C#, Rust, TypeScript and Python single-file writers exclude each other on the
Python takes no cross-process lock yet (`variants:follow_up:py-shared-write-lock`), shared lock file. Another runtime need not obey the `storedAt` rule.
and Rust's open fails rather than waits while a C# writer holds the lock
(`variants:follow_up:rust-lock-open-waits`). Another runtime need not obey the ## Operating a single-file store
`storedAt` rule.
What the lock guarantees, per runtime. Every claim cites the code.
- **Scope.** The lock is the sidecar `<path>.lock`, taken by every write of a
single-file store, never by a read except in Rust. It is advisory: it excludes
only openers that also take it, in this process and others. C# opens it
share-none (`CultCache.cs:5166-5183`; .NET emulates that with `flock(LOCK_EX)`
on Unix, switched off by `DOTNET_SYSTEM_IO_DISABLEFILELOCKING`, see
`docs/research/cross-process-file-locking-prior-art.md`); Rust takes
`fs2` exclusive (`cultcache-rs/src/lib.rs:1721-1726`); TypeScript `flock` or
`LockFileEx` (`cultcache-ts/src/store-file.ts:340-382`); Python the same
(`cultcache-py/src/cultcache_py/stores.py:342-389`). A tool that opens and
writes the `.cc` file without the lock is not excluded. `flock` is not
reliable on every network filesystem; keep stores on a local one.
- **What a second writer sees.** It waits. C# polls every 10 ms for 30 seconds,
then the `IOException` propagates (`CultCache.cs:5166-5183`); with `wait: false`
(`TryCommit`) it returns `Contended` (`CultCache.cs:2052`, `2897`, `4691-4693`).
TypeScript times out after 30 s (`store-file.ts:173-195`), Python raises
`TimeoutError` after `lock_timeout`, 30 s by default (`stores.py:342-354`).
Rust waits without a timeout on its write paths (`lib.rs:1721-1726`); only its
`try_with_exclusive_lock` returns without waiting (`lib.rs:1832-1843`). Each
write re-reads the file after taking the lock and applies its staged keys onto
it (`CultCache.cs:4659-4676`), so waiting writers merge rather than overwrite.
The in-memory cache of a writer is not refreshed by another's write until its
next `PullAll`, which does nothing while that cache is dirty
(`CultCache.cs:4582-4588`).
- **What a reader sees.** C#, TypeScript and Python read without the lock
(`CultCache.cs:5139`, `5227`; `single-file-messagepack-backing-store.ts:200`;
`stores.py:144-152`). The store file is only ever replaced by rename of a fully
written, fsynced temp file (`CultCache.cs:5191-5213`, `lib.rs:1693-1712`,
`store-file.ts:70-95`, `stores.py:316-326`), so a read gets one complete
generation, old or new, never a mix. Rust `pull_all` takes the lock shared and
so waits behind a writer (`lib.rs:1926-1930`); it creates the `.lock` file if
absent, so on a read-only directory use `pull_all_read_only_snapshot`
(`lib.rs:1667-1669`), which takes no lock and relies on the producer's atomic
replace.
- **Not covered.** A unique-index check or condition spanning two stores, or any
invariant between two files: each store has its own lock and no store carries a
generation counter (the file is `[formatVersion, schemaCatalog, records]`);
a record's identity is its `version`, the SHA-256 of its stored bytes. No
library writer holds two store locks at once (`CultCache.cs:2917-2926` judges
one store, releases, then commits the home). Python's `JsonLinesBackingStore`
takes only an in-process lock (`stores.py:73-80`) and is not a single-file store
in this sense.
- **The `.lock` file is not a stale-lock marker.** It is never written or deleted
by a library writer (`store-file.ts:167-172`, `stores.py:342-345`), and the
lock is released when the holding process exits. A `.lock` file left on disk is
normal. Do not delete it while any process may open the store: a new opener
would create a second file and take a second, independent lock on it.
### A leftover temp file
A write stages its bytes in a temp file beside the store and renames it over the
store. The name is consumed by the rename, so a temp file that still exists is a
write that was never applied. The store file itself is never partial because of
it.
| Runtime | Temp name | Source |
|---|---|---|
| C# | `.<store file name>.<32 hex>.tmp` | `CultCache.cs:5191` |
| Rust | `<store file name>.<uuid>.tmp` | `lib.rs:4268-4275` |
| TypeScript | `<path>.tmp-<pid>-<ms>-<random>` | `store-file.ts:70` |
| Python | `<path>.tmp-<pid>-<uuid hex>` | `stores.py:316` |
- **Which temp files are safe to delete.** One that no writer is writing:
delete only while you hold `<path>.lock` yourself
(`flock <path>.lock rm <temp file>`, Linux, util-linux) or with every writer of
the store stopped. Merely testing that the lock is free is not enough: a writer
can take it and stage a new temp file the moment after the test. While you hold
the lock, every temp file of that store is abandoned. Deleting one a writer is
still writing destroys that write.
- **How to know the write was incomplete.** The temp file existing is the proof.
Its content may be partial (killed during the write) or whole (killed between
the fsync and the rename); either way it was not applied, and the writer that
made it never saw its flush succeed. The store holds the last completed write.
Do not rename a temp file over the store by hand. Rust additionally reports a
rename that landed but whose directory fsync failed as `ReplacedNotDurable`
(`lib.rs:1685-1688`; TypeScript `StoreReplacedNotDurableError`,
`store-file.ts:88-92`): after a power loss the store may still be the earlier
generation.
- **What the store does about it.** Opening and reading ignore temp files and
never delete them. A writer removes only its own temp file when its write
throws (`CultCache.cs:5216-5222`, `store-file.ts:82-84`, `stores.py:327-331`,
`lib.rs:1707-1710`); a killed process skips that. Rust alone sweeps: its next
write, under the exclusive lock, deletes every regular file named
`<store file name>.<uuid>.tmp` (`lib.rs:1698`, `4318-4352`). C#, TypeScript and
Python never sweep, and their unique names mean leftovers accumulate until an
operator deletes them. A store file that cannot be read (truncated, wrong
header) is refused at open and left byte-identical (the open reads through
`ReadSnapshot`, `CultCache.cs:4590`, `5104-5118`, whose `ReadStore` must throw
`CultStoreUnreadableException` for anything that is not exactly one store,
`4510-4516`); that is the store, not a temp file, and is
restored from a copy, never deleted and rewritten.
### Backing up a pair of stores
Each store is whole on its own: copying one `.cc` file is safe at any moment,
because the file is only ever replaced whole, and a copy of the path is one
complete generation. Two stores have two independent locks and no shared
generation, so copying them one after the other can capture store A at one
moment and store B at a later one. If an application invariant spans the pair,
that copy may violate it. Back up the pair at one point in time:
1. **Preferred, quiesce.** Stop (or have flush and close) every process that
writes either store. Flush staged writes first; `PullAll` and the dispose
flush do not capture unflushed state of a still-running cache. Copy both `.cc`
files. Do not copy `.lock` files or temp files. Start the writers again.
2. **Without stopping (Unix): per-store consistency only.** Take both locks in
one fixed order (sorted by full path), copy while holding both, release:
`flock A.cc.lock flock B.cc.lock cp A.cc B.cc /backup/dir/`.
This does not make the pair consistent. A writer holds one store lock at a
time, so it can land A and then B with a gap between; holding both locks
cannot pause a writer that has landed A and not yet B, so the copy can hold A
after and B before that pair of writes. It guarantees each copy is one
complete generation of its store and nothing more. Any invariant spanning the
pair requires step 1. Cooperating writers wait (30 s in C#, TypeScript and
Python; Rust waits indefinitely), so keep the hold short. Unflushed state of
live caches is not in it. On Windows, no shipped tool holds the C# share-none
lock for you: quiesce.
3. **Verify.** Open each copy read-only with the runtime that will use it (for
Rust, `pull_all_read_only_snapshot`); an unreadable copy is refused, not
repaired (`CultCache.cs:4510-4516`, `5104-5118`).
4. **Restore both files together** from the same backup, with every writer
stopped, then restart so each cache loads fresh. Copy each file to a temp
name in the store's own directory and `mv` it over the store: a plain `cp`
over the store is not atomic, and C#, TypeScript and Python readers take no
lock, so they can read a torn file. Restoring one half of a pair from a
different backup recreates the skew. Leave the `.lock` files as they are.
## Serialization options ## Serialization options

View file

@ -2052,6 +2052,29 @@ namespace GameCult.Caching
Contended Contended
} }
public enum CultUpdateOutcome
{
Updated,
Declined,
LostRace
}
/// <summary>
/// What <see cref="CultCache.Update{T}"/> did. A lost race is a value, not an exception. <see cref="Document"/> is the instance the
/// cache holds at the key when Update returns: the written one for Updated, null when the key is absent.
/// </summary>
public readonly struct CultUpdateResult<T> where T : class
{
public CultUpdateResult(CultUpdateOutcome outcome, T? document)
{
Outcome = outcome;
Document = document;
}
public CultUpdateOutcome Outcome { get; }
public T? Document { get; }
}
public sealed class CultCommitRequest public sealed class CultCommitRequest
{ {
internal CultCommitRequest( internal CultCommitRequest(
@ -2537,19 +2560,24 @@ namespace GameCult.Caching
return Task.CompletedTask; return Task.CompletedTask;
} }
public void FlushAllBackingStores() => Held(() => public void FlushAllBackingStores(bool soft = false) => Held(() =>
{ {
foreach (var (store, _) in _stores) foreach (var (store, _) in _stores)
{ {
if (!store.IsReadOnly && (store.IsDirty || store.HasStagedChecks)) if (!store.IsReadOnly && (store.IsDirty || store.HasStagedChecks))
store.PushAll(); store.PushAll(soft);
} }
return true; return true;
}); });
public Task FlushAsync() /// <summary>
/// Flushes every dirty backing store. With <paramref name="soft"/> the flush has one meaning, decided by each store's
/// own read: a store with nothing at its path is written; a store that reads (empty included) is skipped and its
/// staged writes stay staged; a store that cannot be read refuses with <see cref="CultStoreUnreadableException"/>.
/// </summary>
public Task FlushAsync(bool soft = false)
{ {
FlushAllBackingStores(); FlushAllBackingStores(soft);
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -2558,6 +2586,7 @@ namespace GameCult.Caching
return UpsertAsync(document, handle); return UpsertAsync(document, handle);
} }
// A blind write, last-writer-wins per key; Update is the conditional golden path.
// Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares. // Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares.
public Task<CultRecordHandle<T>> UpsertAsync<T>(T document, CultRecordHandle<T>? handle = null) public Task<CultRecordHandle<T>> UpsertAsync<T>(T document, CultRecordHandle<T>? handle = null)
{ {
@ -2634,6 +2663,7 @@ namespace GameCult.Caching
/// <summary> /// <summary>
/// Commits the staged batch. Returns false when a condition failed: nothing was written, changed in memory, or published. /// Commits the staged batch. Returns false when a condition failed: nothing was written, changed in memory, or published.
/// Without <c>Expect</c> a commit is blind; <see cref="Update{T}"/> retries a conditional one.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// A commit that succeeds and then fails in a journal or observer still stands and is published, and the writer /// A commit that succeeds and then fails in a journal or observer still stands and is published, and the writer
@ -2651,6 +2681,59 @@ namespace GameCult.Caching
return Land(stage, wait: false); return Land(stage, wait: false);
} }
/// <summary>
/// Reads the record at <paramref name="key"/>, applies <paramref name="apply"/> to a private copy of it, and commits the result only
/// if the record is still the one that was read; when another writer got there first it re-reads and runs <paramref name="apply"/>
/// again, up to <paramref name="maxAttempts"/> times. The write is durable when Update returns.
/// </summary>
/// <remarks>
/// <paramref name="apply"/> may run up to <paramref name="maxAttempts"/> times. It gets a private copy (null when the key holds
/// nothing), which it may change and return. It must not write to the cache or have other side effects. Return null to write
/// nothing. A store with staged blind writes refuses Update until it is flushed, as it refuses any conditional commit. The cache
/// needs a codec to copy with. Running out of attempts returns <see cref="CultUpdateOutcome.LostRace"/>; every other failure throws.
/// </remarks>
public CultUpdateResult<T> Update<T>(CultRecordKey key, Func<T?, T?> apply, int maxAttempts = 5) where T : class
{
if (apply == null) throw new ArgumentNullException(nameof(apply));
if (maxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(maxAttempts), maxAttempts, "At least one attempt is required.");
var codec = _codec ?? throw new InvalidOperationException("This cache has no codec; create it with CultCacheMessagePack.Create.");
for (var attempt = 1; ; attempt++)
{
var (version, copy) = Held(() =>
{
if (!_entries.TryGetValue(key.Value, out var held))
return ((string?)null, (T?)null);
if (held.Variant != null || held.Document is not T)
throw new InvalidOperationException($"Record {key.Value} is a variant or not a {typeof(T).Name}; Update cannot copy it.");
return (held.StoredVersion, (T?)codec.Clone(held.Document, held.Descriptor.DocumentType));
});
var next = apply(copy);
if (next == null)
return new CultUpdateResult<T>(CultUpdateOutcome.Declined, Get<T>(key));
CultCommitOutcome outcome;
try
{
outcome = Land(batch =>
{
batch.Expected.Add((key, version));
batch.Upsert(next, new CultRecordHandle<T>(key));
}, wait: true);
}
catch (CultWriteConflictException conflict) when (conflict.InnerException == null)
{
outcome = CultCommitOutcome.Mismatch;
}
if (outcome == CultCommitOutcome.Committed)
return new CultUpdateResult<T>(CultUpdateOutcome.Updated, next);
if (outcome != CultCommitOutcome.Mismatch)
throw new InvalidOperationException($"Update of {key.Value} was refused: {outcome}.");
Held(() => { Home(next.GetType())?.PullAll(); return true; });
if (attempt >= maxAttempts)
return new CultUpdateResult<T>(CultUpdateOutcome.LostRace, Get<T>(key));
}
}
public CultRecordHandle<T>? TryGetHandle<T>(T document) public CultRecordHandle<T>? TryGetHandle<T>(T document)
{ {
if (document == null) throw new ArgumentNullException(nameof(document)); if (document == null) throw new ArgumentNullException(nameof(document));
@ -2671,7 +2754,8 @@ namespace GameCult.Caching
/// when that store holds it unowned (undeclared drift that drops persisted slots, or a payload carrying slots past this build's, /// 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 /// 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 /// (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. /// the variant itself is writable. It does not look at store paths: a link at a store path is not predicted, and the write refuses it
/// with <see cref="CultStoreLinkedException"/>. Live and advisory: the write still decides.
/// </summary> /// </summary>
public bool IsWritable(CultRecordKey key) public bool IsWritable(CultRecordKey key)
{ {
@ -3945,7 +4029,13 @@ namespace GameCult.Caching
public abstract void PullAll(); public abstract void PullAll();
public abstract void Push(CultStoredDocument entry); public abstract void Push(CultStoredDocument entry);
public abstract void Delete(CultStoredDocument entry); public abstract void Delete(CultStoredDocument entry);
public abstract void PushAll(); public void PushAll() => PushAll(soft: false);
/// <summary>
/// Applies the staged writes. A soft flush writes only where nothing is at the path: over a store it reads it returns without
/// writing and leaves the staged writes staged and dirty for a later plain flush, and over a file it cannot read it refuses.
/// </summary>
public abstract void PushAll(bool soft);
/// <summary> /// <summary>
/// One durable step under the store's exclusive lock: the batch and every staged write land onto the store as it is then, /// One durable step under the store's exclusive lock: the batch and every staged write land onto the store as it is then,
@ -4184,7 +4274,7 @@ namespace GameCult.Caching
/// </para> /// </para>
/// <para> /// <para>
/// A writable store kind that reads its durable state under a lock overrides this to call <see cref="StageChecks"/>, and /// A writable store kind that reads its durable state under a lock overrides this to call <see cref="StageChecks"/>, and
/// judges <see cref="StagedChecks"/> at its next <see cref="PushAll"/> or <see cref="CommitBatch"/> as it judges /// judges <see cref="StagedChecks"/> at its next <see cref="PushAll(bool)"/> or <see cref="CommitBatch"/> as it judges
/// <see cref="CultCommitRequest.Checks"/>: under the lock, with the keys it writes, refused through the same refusal, never /// <see cref="CultCommitRequest.Checks"/>: under the lock, with the keys it writes, refused through the same refusal, never
/// removed. It calls <see cref="MarkFlushSucceeded"/> when that step succeeds, which clears them. A read-only store has no /// removed. It calls <see cref="MarkFlushSucceeded"/> when that step succeeds, which clears them. A read-only store has no
/// such step and keeps the default. Both shipped store kinds do this. A batch's own checks are judged through /// such step and keeps the default. Both shipped store kinds do this. A batch's own checks are judged through
@ -4661,7 +4751,7 @@ namespace GameCult.Caching
// only per staged key. A key whose durable record is under an id this cache does not declare refuses the write, and in a store // only per staged key. A key whose durable record is under an id this cache does not declare refuses the write, and in a store
// holding a variant it lands only onto the store as this cache last read it (both ApplyWriteSet). // holding a variant it lands only onto the store as this cache last read it (both ApplyWriteSet).
// A file it cannot read (truncated, trailing bytes, not a store, a format it does not read) is refused and left as it is. // A file it cannot read (truncated, trailing bytes, not a store, a format it does not read) is refused and left as it is.
public override void PushAll() public override void PushAll(bool soft)
{ {
ThrowIfReadOnly(); ThrowIfReadOnly();
Held(() => Held(() =>
@ -4669,6 +4759,8 @@ namespace GameCult.Caching
using (AcquireLock(wait: true)) using (AcquireLock(wait: true))
{ {
var disk = ReadSnapshot(); var disk = ReadSnapshot();
if (soft && disk != null)
return;
var (upserts, removals) = StagedWrites(Array.Empty<CultStoredDocument>(), Array.Empty<CultStoredDocument>()); var (upserts, removals) = StagedWrites(Array.Empty<CultStoredDocument>(), Array.Empty<CultStoredDocument>());
Wrote(ApplyWriteSet(disk, upserts, removals, Array.Empty<string>())); Wrote(ApplyWriteSet(disk, upserts, removals, Array.Empty<string>()));
} }
@ -5132,7 +5224,7 @@ namespace GameCult.Caching
var attributes = FileInfo.Attributes; var attributes = FileInfo.Attributes;
if ((int)attributes == -1) if ((int)attributes == -1)
return null; return null;
if ((attributes & FileAttributes.Directory) != 0 && (attributes & FileAttributes.ReparsePoint) == 0) if ((attributes & FileAttributes.Directory) != 0 && Directory.Exists(FileInfo.FullName))
throw new IOException("The store path is a directory, not a store file."); throw new IOException("The store path is a directory, not a store file.");
try try
{ {
@ -5154,25 +5246,27 @@ namespace GameCult.Caching
} }
// Something is at the path even when the path cannot be opened: a link keeps the reparse-point attribute whether or not its // Something is at the path even when the path cannot be opened: a link keeps the reparse-point attribute whether or not its
// target is there. A path with nothing at it reports no attributes at all (-1). // target is there.
private bool PathIsALink() private bool PathIsALink() => CultStoreLinks.IsLink(FileInfo.FullName);
{
FileInfo.Refresh(); // A write never goes through a link at the store path: replacing it would silently detach the store from its link.
var attributes = FileInfo.Attributes; private void RefuseLinkedStore() => CultStoreLinks.Refuse(FileInfo.FullName);
return (int)attributes != -1 && (attributes & FileAttributes.ReparsePoint) != 0;
}
// The lock is a sidecar opened exclusively, which excludes other handles in this process and in others alike. // The lock is a sidecar opened exclusively, which excludes other handles in this process and in others alike.
// Every write takes the lock first, so the link is refused here twice: before the lock file or anything else is made, and
// again once the lock is held, because a link can be put at the path while this writer waited on another's lock.
private FileStream? AcquireLock(bool wait) private FileStream? AcquireLock(bool wait)
{ {
RefuseLinkedStore();
Directory.CreateDirectory(FileInfo.DirectoryName!); Directory.CreateDirectory(FileInfo.DirectoryName!);
var lockPath = FileInfo.FullName + ".lock"; var lockPath = FileInfo.FullName + ".lock";
var started = Stopwatch.StartNew(); var started = Stopwatch.StartNew();
while (true) while (true)
{ {
FileStream held;
try try
{ {
return new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1); held = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1);
} }
catch (IOException) when (!wait) catch (IOException) when (!wait)
{ {
@ -5181,7 +5275,20 @@ namespace GameCult.Caching
catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30)) catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30))
{ {
Thread.Sleep(10); Thread.Sleep(10);
continue;
} }
try
{
RefuseLinkedStore();
}
catch
{
held.Dispose();
throw;
}
return held;
} }
} }
@ -5204,14 +5311,7 @@ namespace GameCult.Caching
stream.Flush(true); stream.Flush(true);
} }
if (File.Exists(path)) StoreReplace.Replace(tempPath, path);
{
File.Replace(tempPath, path, null);
}
else
{
File.Move(tempPath, path);
}
} }
finally finally
{ {

View file

@ -352,7 +352,7 @@ namespace GameCult.Caching
} }
} }
public object Clone(object document, Type type) => _deserialize(type, _serialize(document, type, type)); public object Clone(object document, Type type) => _codec.Clone(document, type);
public CultInspectorEdit BeginEdit(CultStoredDocument record) public CultInspectorEdit BeginEdit(CultStoredDocument record)
{ {

View file

@ -0,0 +1,24 @@
using System.IO;
namespace GameCult.Caching
{
/// <summary>
/// A write was attempted at a store path that is a link (a symbolic link to a file or a directory, a Windows junction or any
/// other reparse-point link). The write is refused before the lock, the staging file or the replace, and again once the lock
/// is held, so a link that exists when a writer takes the lock leaves the link and its target untouched. A link created by
/// something that ignores the lock after that check, while the commit builds and writes the new image, is still replaced;
/// that window cannot be closed without native no-follow opens. Configure the link's target as the store path to write to
/// it; reads through a live link are unchanged.
/// </summary>
public sealed class CultStoreLinkedException : IOException
{
public CultStoreLinkedException(string path)
: base($"CultCache store {path} is a link, and a write at it would replace the link; configure the link's target as the store path.")
{
Path = path;
}
/// <summary>The store path that is a link.</summary>
public string Path { get; }
}
}

View file

@ -0,0 +1,22 @@
using System.IO;
namespace GameCult.Caching
{
// The one decision on whether a store-owned file is a link, shared by every store kind that replaces files.
internal static class CultStoreLinks
{
// A path with nothing at it reports no attributes at all (-1). Any reparse point counts as a link, whether or not its
// target is there.
internal static bool IsLink(string path)
{
var attributes = new FileInfo(path).Attributes;
return (int)attributes != -1 && (attributes & FileAttributes.ReparsePoint) != 0;
}
internal static void Refuse(string path)
{
if (IsLink(path))
throw new CultStoreLinkedException(path);
}
}
}

View file

@ -29,6 +29,11 @@ namespace GameCult.Caching
// (document payload, slot and encoded value pairs) // (document payload, slot and encoded value pairs)
public Func<byte[], IReadOnlyList<KeyValuePair<int, byte[]>>, byte[]> Overlay { get; } public Func<byte[], IReadOnlyList<KeyValuePair<int, byte[]>>, byte[]> Overlay { get; }
// The one copy of a document: its encoding, decoded again. Update's private argument and CultInspectorModel.Clone share it.
internal object Clone(object document, Type type) =>
Deserialize(type, type, Serialize(document, type, type))
?? throw new InvalidOperationException($"The codec returned null for {type.FullName}.");
/// <summary> /// <summary>
/// Decodes a document payload read from a store file, as a cache load decodes it: the ids a load mints are minted from the key /// Decodes a document payload read from a store file, as a cache load decodes it: the ids a load mints are minted from the key
/// and their path, so every reader of one store mints the same ids, and a payload a cache write would refuse is refused here too. /// and their path, so every reader of one store mints the same ids, and a payload a cache write would refuse is refused here too.

View file

@ -13,6 +13,7 @@ Cacheable models are plain classes annotated with cache intent.
using GameCult.Caching; using GameCult.Caching;
using MessagePack; using MessagePack;
[MessagePackObject]
[CultDocument("gamecult.item_data", "gamecult.item_data.v1")] [CultDocument("gamecult.item_data", "gamecult.item_data.v1")]
public sealed class ItemData public sealed class ItemData
{ {
@ -131,18 +132,33 @@ using GameCult.Caching;
using GameCult.Caching.MessagePack; using GameCult.Caching.MessagePack;
var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack"); var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack");
var key = new CultRecordKey("potion");
// The golden path: read, change a private copy, commit only if the record is unchanged, re-run on a lost race.
// Durable when it returns; no flush.
var result = cache.Update<ItemData>(key, item =>
{
item ??= new ItemData { Name = "Potion", Sku = "POT-1", Category = "Consumable" };
item.Value += 10;
return item;
});
// The blind opt-out: last writer wins per key. Name the key, or a new key claims the same unique Sku and is refused.
var handle = await cache.UpsertAsync(new ItemData var handle = await cache.UpsertAsync(new ItemData
{ {
Name = "Potion", Name = "Potion",
Sku = "POT-1", Sku = "POT-1",
Category = "Consumable", Category = "Consumable",
Value = 50 Value = 50
}); }, new CultRecordHandle<ItemData>(key));
await cache.FlushAsync(); await cache.FlushAsync();
``` ```
`Update` may run your function more than once, so it must have no side effects. Returning null writes nothing, and a
lost race that outlasts `maxAttempts` comes back as `CultUpdateOutcome.LostRace`. `UpsertAsync` plus `FlushAsync` never
checks what it overwrites.
If you want the explicit assembly rite, the lower-level `CultCache` + If you want the explicit assembly rite, the lower-level `CultCache` +
`SingleFileMessagePackBackingStore` path is still there. The helper is just the `SingleFileMessagePackBackingStore` path is still there. The helper is just the
blessed front door. blessed front door.
@ -294,21 +310,29 @@ Useful surfaces:
- `cache.IsDirty` - `cache.IsDirty`
- `store.IsDirty` - `store.IsDirty`
- `cache.Document<T>(key)` - `cache.Document<T>(key)`
- `cache.Soa<T>()` - `cache.Update<T>(key, apply)`
- `cache.UpsertAsync(document)` - `cache.UpsertAsync(document)` (blind write)
- `cache.FlushAllBackingStores()` - `cache.FlushAllBackingStores()`
- `cache.FlushAsync()` - `cache.FlushAsync()`
- `cache.FlushBackingStore(store)`
- `cache.PrepareForReloadOrShutdown()`
- `cache.PrepareForReloadOrShutdownAsync()`
- `cache.FlushAttachedStoresOnDispose` - `cache.FlushAttachedStoresOnDispose`
- `store.FlushOnDispose` - `store.FlushOnDispose`
This is built for callers like Aquarium that want durable runtime state while This is built for callers like Aquarium that want durable runtime state while
still choosing *when* to pay the write cost. still choosing *when* to pay the write cost.
Single-file MessagePack remains a single-writer format. The atomic replace path A single-file store admits any number of writers, in one process or many, one at
protects against partial writes, not competing writers trampling each other. a time. Every write (flush, `PushAll`, `CommitBatch`, cacheless `WriteRecord`)
takes an exclusive advisory lock on the sidecar `<path>.lock`, reads the file as
it is then, applies its own staged keys onto it and replaces the file by a
write-to-temp-then-rename. Two writers never interleave bytes, and a record
another writer put there survives; a key both staged holds the last write
(`Contracts/cultcache-store-composition.md`, "Locking"). What the lock does not
give is cross-key or cross-store atomicity, a lock that binds a program that
ignores it, or protection on a filesystem without working `flock`. A second
writer waits (30 seconds, then `IOException`; `TryCommit` reports `Contended`
instead of waiting); readers take no lock and always see one complete file. The
operator procedures for a leftover temp file and for backing up two stores are
in the same contract document, "Operating a single-file store".
## Schema migration diagnostics ## Schema migration diagnostics

View file

@ -0,0 +1,182 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
namespace GameCult.Caching
{
/// <summary>The kernel32 calls a Windows replace needs, each answering 0 or a Win32 error code.</summary>
internal interface IReplaceKernel
{
/// <summary>CreateFileW(staged, DELETE|SYNCHRONIZE), FileRenameInfoEx with REPLACE_IF_EXISTS|POSIX_SEMANTICS, CloseHandle.</summary>
int RenamePosix(string staged, string destination);
/// <summary>MoveFileExW(staged, destination, flags).</summary>
int MoveFileEx(string staged, string destination, uint flags);
void Sleep(int milliseconds);
}
/// <summary>
/// Owns how a staged file becomes the store, for the single-file and directory stores. Windows tries a POSIX-semantics
/// rename first (which makes a moment with the path absent rare for a lock-free reader, not impossible: a stalled rename was
/// measured leaving it absent for 5-47 ms), falls back to write-through MoveFileExW where that is unsupported or does not
/// resolve, falls back to the portable File.Replace if MoveFileExW itself does not resolve, and retries transient sharing
/// errors; other OSes keep rename. Mirrors cultcache-ts store-file.ts
/// replaceStoreFile (ruling variants:ruling:windows-replace-posix-first).
/// </summary>
internal static class StoreReplace
{
private const int ErrorAccessDenied = 5;
private const int ErrorSharingViolation = 32;
private const int ErrorLockViolation = 33;
private const int ErrorNotSupported = 50;
private const int ErrorInvalidParameter = 87;
private const int ReplaceAttempts = 25;
private const int ReplaceRetryMs = 20;
/// <summary>MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH.</summary>
internal const uint MoveFileReplaceWriteThrough = 0x9;
internal static readonly IReplaceKernel WindowsKernel = new Kernel32Replace();
public static void Replace(string staged, string destination)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
ReplaceWindows(staged, destination, WindowsKernel);
}
else
{
ReplacePortable(staged, destination);
}
}
private static void ReplacePortable(string staged, string destination)
{
if (File.Exists(destination))
{
File.Replace(staged, destination, null);
}
else
{
File.Move(staged, destination);
}
}
private static bool Unresolved(Exception ex) => ex is DllNotFoundException || ex is EntryPointNotFoundException;
internal static void ReplaceWindows(string staged, string destination, IReplaceKernel kernel)
{
var posix = true;
var lastCode = 0;
for (var attempt = 1; attempt <= ReplaceAttempts; attempt++)
{
int code;
try
{
code = posix ? RenamePosixOrUnsupported(kernel, staged, destination) : kernel.MoveFileEx(staged, destination, MoveFileReplaceWriteThrough);
if (posix && (code == 1 || code == ErrorNotSupported || code == ErrorInvalidParameter))
{
// POSIX semantics are unsupported here (FAT, exFAT, before Windows 10 1607): the rest of this call is write-through.
posix = false;
code = kernel.MoveFileEx(staged, destination, MoveFileReplaceWriteThrough);
}
}
catch (Exception ex) when (Unresolved(ex))
{
// kernel32's MoveFileExW does not resolve here (a sandboxed player): the runtime-owned replace is all that is left.
ReplacePortable(staged, destination);
return;
}
if (code == 0)
{
return;
}
if (code != ErrorAccessDenied && code != ErrorSharingViolation && code != ErrorLockViolation)
{
throw new IOException($"CultCache could not replace the store: Win32 error {code}.");
}
lastCode = code;
kernel.Sleep(ReplaceRetryMs);
}
throw new IOException($"CultCache could not replace the store after {ReplaceAttempts} attempts: last Win32 error {lastCode}.");
}
private static int RenamePosixOrUnsupported(IReplaceKernel kernel, string staged, string destination)
{
try
{
return kernel.RenamePosix(staged, destination);
}
catch (Exception ex) when (Unresolved(ex))
{
// The POSIX call does not resolve: the same answer as "unsupported", so the replace falls back to MoveFileExW.
return ErrorNotSupported;
}
}
private sealed class Kernel32Replace : IReplaceKernel
{
private const uint DeleteSynchronize = 0x00110000;
private const uint ShareAll = 0x7;
private const uint OpenExisting = 3;
private const uint FileAttributeNormal = 0x80;
private const int FileRenameInfoEx = 22;
private const uint RenameReplacePosix = 0x3;
public int RenamePosix(string staged, string destination)
{
var handle = CreateFileW(staged, DeleteSynchronize, ShareAll, IntPtr.Zero, OpenExisting, FileAttributeNormal, IntPtr.Zero);
if (handle == new IntPtr(-1))
{
return Marshal.GetLastWin32Error();
}
try
{
// FILE_RENAME_INFO: Flags u32 at 0, RootDirectory handle, FileNameLength u32, FileName (UTF-16, offsets for this pointer size).
var name = System.Text.Encoding.Unicode.GetBytes(Path.GetFullPath(destination));
var lengthOffset = 2 * IntPtr.Size;
var nameOffset = lengthOffset + 4;
var size = nameOffset + name.Length + 2;
var info = Marshal.AllocHGlobal(size);
try
{
for (var i = 0; i < size; i++) Marshal.WriteByte(info, i, 0);
Marshal.WriteInt32(info, 0, (int)RenameReplacePosix);
Marshal.WriteInt32(info, lengthOffset, name.Length);
Marshal.Copy(name, 0, info + nameOffset, name.Length);
return SetFileInformationByHandle(handle, FileRenameInfoEx, info, (uint)size) ? 0 : Marshal.GetLastWin32Error();
}
finally { Marshal.FreeHGlobal(info); }
}
finally { CloseHandle(handle); }
}
public int MoveFileEx(string staged, string destination, uint flags)
=> MoveFileExW(staged, destination, flags) ? 0 : Marshal.GetLastWin32Error();
public void Sleep(int milliseconds) => Thread.Sleep(milliseconds);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr CreateFileW(string fileName, uint access, uint share, IntPtr security, uint disposition, uint flags, IntPtr template);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetFileInformationByHandle(IntPtr handle, int informationClass, IntPtr information, uint size);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool MoveFileExW(string existing, string target, uint flags);
}
}
}

View file

@ -12,7 +12,7 @@
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" /> <ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="..\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" /> <ProjectReference Include="..\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<PackageReference Include="MessagePack" Version="3.1.7" /> <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> </ItemGroup>
<Import Project="..\GameCult.Caching.MessagePack\buildTransitive\GameCult.Caching.MessagePack.targets" /> <Import Project="..\GameCult.Caching.MessagePack\buildTransitive\GameCult.Caching.MessagePack.targets" />

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