Compare commits

..
Author SHA1 Message Date
12f3801893 Rust: a cache must pull its stores before it writes (cache-must-pull-before-write) (#133) 2026-11-12 08:01:32 +00:00
66bea107d5 TypeScript: a payload is one MessagePack value (bytes-after-ts) (#130) 2026-11-12 06:02:51 +00:00
363a8d6fe5 Rust: a payload is one MessagePack value (bytes-after-rs) (#131) 2026-11-12 05:43:55 +00:00
13ce5ea50a Python: a payload is one MessagePack value (bytes-after-py) (#129) 2026-11-12 05:23:25 +00:00
676f611c1f TypeScript: a record with unread slots is held read-only (slot-readonly-ts) (#128) 2026-11-12 05:03:29 +00:00
09b825d689 Rust: a record with unread slots is held read-only (slot-readonly-rs) (#127) 2026-11-12 04:44:03 +00:00
892765ba97 Python: a record with unread slots is held read-only (slot-readonly-py) (#126) 2026-11-12 04:24:07 +00:00
5e10909fe9 Bytes after the value: a payload is one MessagePack value (C#) (#125) 2026-11-12 04:01:36 +00:00
a8c2825745 Slot tolerance: a record with unread slots is held read-only, not shed (#124) 2026-11-12 02:45:08 +00:00
c371832736 Rust cache: one record per key, foreign carry, key-identity vector walked (#123) 2026-11-12 01:05:46 +00:00
a0184785a2 Python cache: one record per key, foreign carry, key-identity vector walked (#122) 2026-11-12 00:45:11 +00:00
7a63290469 TypeScript cache: one record per key, foreign carry, key-identity vector walked (#121) 2026-11-12 00:03:19 +00:00
6d1684e1c2 Key-identity vector: retype refusal, foreign carry, key in two stores (C# rows, 12) (#120) 2026-11-11 23:42:09 +00:00
871d81807b Portability CI: use the runner image's Node and Python, venv, job timeout 60 (#119) 2026-11-11 21:55:30 +00:00
d3025d3c19 Fork card: null-answerer vectors for Revision and Retraction (additive) (#118) 2026-11-11 18:30:21 +00:00
3a51bbabad Fork card: decider_count, answerer_count, nullable answerer (reader views) (#117) 2026-11-11 18:12:00 +00:00
19b95ba2ab Walk rows lows: Rust @directory error kind and delete, stale known-gaps (#116) 2026-11-11 18:11:55 +00:00
af9142539e Fork card: raised_at, required nullable instant outside options_hash (#115) 2026-11-11 18:03:41 +00:00
f4eb2a04d1 Readability manifest: legacy-catalog c0 rows and directory-at-path, walked by all four runtimes (#114) 2026-11-11 17:25:05 +00:00
8beb972c68 cultnet-ts test: reconnecting client sends only after its own handshake (#113) 2026-11-11 16:05:36 +00:00
5fa7057ed1 Duplicate-key residuals: changelogs, retype by schema id, Rust CAS retype (#112) 2026-11-11 15:58:39 +00:00
0ebc7d63e8 Variants cut doc: C4 records Nibu's ruling on where hull families live (#111) 2026-11-11 15:08:01 +00:00
4246a37284 Single-file readers refuse duplicate keys in all four runtimes; c0 bad-canonical row (#110) 2026-11-11 15:07:48 +00:00
b88c0a8ee2 CI: one portability run per change; quic-native only for native paths (#109)
All checks were successful
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 36s
2026-11-11 14:24:23 +00:00
f2bb0dd1dd cultcache-ts: tests pin the single-put judge's alias and key-pair order (#108)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Successful in 25m21s
2026-11-11 13:56:35 +00:00
7c2e514941 Unique-index vector: three-holder normal-form and astral-alias rows 34-37; walk counts raised in all four runtimes (#107)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 13:41:10 +00:00
17caa0c874 cultcache-rs: judge_against names the least violation by UTF-16 order; walk refusals.msgpack (#102)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 12:16:57 +00:00
1211e8c6dc cultcache-ts: a load and an index registration name the least violation of the resulting state; walk refusals.msgpack (#103)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 12:00:00 +00:00
3deb270458 cultcache-py: a unique-index refusal names the least violation of the resulting state; walk refusals.msgpack (#101)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 11:48:38 +00:00
7225835d11 org.gamecult.cultlib 3.0.0: plugins carry CultElementId (#106)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 11:42:43 +00:00
f1edfa458d CultMath 0.5.1 (math.fbm), Unity package rebuilt (#105)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 11:42:34 +00:00
a57397aa7f Unique-index vector: group identity, distinct values, normal-form keys, single-put alias order (rows 26-33) (#104)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 11:42:27 +00:00
Libby
8eb77546eb org.gamecult.cultlib 3.0.0: plugins regenerated by build-unity-package.ps1 -UpdateTemplate (CultElementId, element ids, write sets)
All checks were successful
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Successful in 24m7s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m27s
The native QUIC bridge DLL is not restaged: its source is unchanged since 2.0.1 and the rebuild is not byte-stable.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 11:36:06 +00:00
Libby
6e0e2feb74 org.gamecult.cultlib 3.0.0: release prep (version, changelog heading, install pin)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 11:21:25 +00:00
Libby
3cb5a1753a CultMath 0.5.1: Unity plugin DLL and pdb rebuilt on Windows at 0.5.1
All checks were successful
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Successful in 28m45s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 11:06:16 +00:00
063835f679 Unique-index vector: holder identity and replaced set are ordinal (rows 23-25) (#100)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 10:47:13 +00:00
Libby
8adf014c66 CultMath 0.5.1: version, changelog (fbm), cultlib dependency pin
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 10:42:09 +00:00
23d254a501 CultCache: a unique-index refusal names the least violation; shared refusals vector (#99)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-11 10:05:32 +00:00
7002c252ed Element ids: the existing-member route and one-way v3/v5 rollback, documented (#98)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-11 08:53:13 +00:00
a6861adadb Unique-index leftovers: TS registerIndex refuses a role, commit vector in TS/Rust, nil document in the change schema (#97)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 08:43:52 +00:00
5e610b1b6e CI: run cultcache-py, cultcache-rs and GameCult.Caching.Tests on push and PR (#87)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 08:12:07 +00:00
447b310b4d cultcache-py: load order, role-alias register_index refusal, C# two-change commit vector (#96)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 08:05:39 +00:00
43aa451fb9 cultcache-rs: #[cultcache(index)] is unique at commit and load; re-registration re-judges held records (#95)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 07:43:22 +00:00
a7c22e6ee5 cultmesh-browser test: make the unallocatable-size refusal certain on hosts that overcommit (#93)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-11 07:35:33 +00:00
ba25b43369 cultcache-py, cultnet-py: indexes means unique, roles names selection aliases; commitId/commitCount on change messages (#94)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-11 06:59:08 +00:00
31d86e2a50 cultcache-ts and cultnet-ts: indexes means unique, roles names selection aliases; commitId/commitCount on change messages (#92)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 14m50s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 51s
2026-11-11 05:13:08 +00:00
abd1a92eb2 Replica commit hardening: Reconcile sends its diff as one tagged commit; straggler guard inside the lock; real-server and budget pins (#91)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 04:57:59 +00:00
7760219b7c CultCache: [CultIndex] is unique at commit and load; [CultRole] names a selection role; replicas apply a commit's changes as one batch (#90)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 20m15s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 48s
2026-11-11 03:48:08 +00:00
de8a062ab9 Fork card: stale answer over an oversize re-read is Refused(stale_fork), never counted; refusal text is informational (#89)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 17m31s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 57s
2026-11-11 02:10:37 +00:00
f6c9bf5f97 Fork card: fork_too_large (422) for a list whose first entry, or a read whose entry, is past the reply cap (#88)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 01:29:35 +00:00
9d6ee72eb3 Fork card: order is a unique rank per Mind; duplicate-order and order-0 vectors (#86)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-11 00:33:36 +00:00
44149afb7b cultcache-ts: opening a legacy store never rewrites it (#85)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-10 23:56:57 +00:00
e57fbaee90 Merge pull request 'tools/sprite-silhouette: sprite alpha to CSS shape-outside polygon' (#8) from libby/sprite-silhouette into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-10 23:14:06 +00:00
0c19eddb77 Fork card: a stripped read serves note: null (#84)
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-10 20:59:52 +00:00
soul
de00274cd0 Merge remote-tracking branch 'origin/main' into libby/sprite-silhouette
All checks were successful
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Successful in 21m53s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m19s
2026-11-10 17:40:26 +00:00
Libby
86faf53f86 tools/sprite-silhouette: README sample uses the real void/neutral size
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 10:50:08 +00:00
Libby
62c734f5b2 tools/sprite-silhouette: sprite alpha silhouette to a CSS shape-outside polygon
Zero-dependency Node tool: PNG alpha, threshold 0.35, outer contour of the largest
8-connected shape, Douglas-Peucker to a pixel tolerance, percent coordinates keyed by
cast-art pose id. Ten tests, including every pose in the shared catalogue.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 10:49:52 +00:00
224 changed files with 23546 additions and 3176 deletions

View file

@ -1,11 +1,18 @@
name: CultMesh portability and performance name: CultMesh portability and performance
# The forge runs one job at a time and this workflow holds the slot 14 to 30 minutes, so it runs once per change:
# on the pull request (never again on the push that merges it; a break that merges from a stale base waits for the
# schedule), skipped for design-note-only changes, and on a
# daily schedule so main is still exercised when nobody opens a pull request.
on: on:
push:
branches:
- main
- "codex/**"
pull_request: pull_request:
paths-ignore:
# Not `**/*.md` or `docs/**`: docs/start/README.md is read by the first-call tests of every runtime.
- "docs/*.md"
- "research/**"
- ".voidbot/**"
schedule:
- cron: "17 3 * * *"
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@ -18,7 +25,9 @@ jobs:
reactive-and-resource-lifetime: reactive-and-resource-lifetime:
name: ${{ matrix.os }} reactive semantics and leases name: ${{ matrix.os }} reactive semantics and leases
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 30 # The job dies at the lower of this value and the forge runner's own cap, which Eureka is raising from 30 to 60 minutes
# (run 1378 carried 60 and was still cancelled at 30:02); the two must both say 60 before a full run can finish.
timeout-minutes: 60
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -38,17 +47,41 @@ jobs:
with: with:
dotnet-version: 10.0.x dotnet-version: 10.0.x
# The forge runner image (catthehacker/ubuntu:act-latest) carries Node 24 and Python 3.12 on PATH, and these two
# setup actions downloaded them again from github.com anyway (about 2.7 and 2.8 minutes, run 639) because the
# image's tool cache is not the one they read. They run only where the image does not carry the tools (the
# workflow_dispatch Windows leg); the check below fails a Linux image that stops carrying them.
- name: Set up Node.js - name: Set up Node.js
if: runner.os != 'Linux'
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
cache: npm cache: npm
- name: Set up Python - name: Set up Python
if: runner.os != 'Linux'
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: "3.12"
- name: Check the image carries Node 24 and Python 3.12
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
node --version | grep -E '^v24\.'
python --version | grep -E '^Python 3\.12\.'
# The image's Python is distro-managed (PEP 668): pip refuses to install into it, so the Linux leg installs the
# CultLib packages into a venv under RUNNER_TEMP that later steps find first on PATH.
- name: Create the Python environment
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
python -m venv "$RUNNER_TEMP/venv"
echo "$RUNNER_TEMP/venv/bin" >> "$GITHUB_PATH"
- name: Install TypeScript workspace dependencies - name: Install TypeScript workspace dependencies
run: npm install --no-package-lock run: npm install --no-package-lock
@ -115,6 +148,26 @@ jobs:
- name: Verify the CultCache document owner with the TypeScript cache - name: Verify the CultCache document owner with the TypeScript cache
run: npm run test --workspace packages/cultcache-ts run: npm run test --workspace packages/cultcache-ts
# The Python cache's unit suite ran only in cultnet-interop.yml (windows-latest, dispatch-only), so on Linux
# it ran nowhere. It sits after the TypeScript cache's test so the built cultcache-ts store holder exists for the
# cross-runtime store-lock test, which skips without it.
- name: Verify the CultCache document owner with the Python cache
run: python -m unittest discover -s packages/cultcache-py/tests
# cargo test for packages/cultcache-rs ran in no workflow: the Rust cache's own unit and doc tests were unwatched
# (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
shell: bash
run: CARGO_TARGET_DIR="$RUNNER_TEMP/cultcache-rs-target" cargo test --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
# forge workflow; only the Mesh tests above exercised the reference.
# The forge runner is globalization-invariant, so ADeterministicIdIsTheSameUnderEveryCulture... cannot construct a
# culture and throws CultureNotFoundException; it is excluded here and still runs wherever ICU is present
# (variants:follow_up:culture-test-invariant-image).
- name: Verify the CultCache document owner with the C# reference
run: dotnet test tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj --filter "FullyQualifiedName!~ADeterministicIdIsTheSameUnderEveryCulture" --logger "console;verbosity=minimal"
# Packs the four TypeScript packages and installs them into a clean consumer, so a package that builds but does # Packs the four TypeScript packages and installs them into a clean consumer, so a package that builds but does
# not install or load fails here. # not install or load fails here.
- name: Verify the packed TypeScript packages install and load from a clean consumer - name: Verify the packed TypeScript packages install and load from a clean consumer

View file

@ -9,13 +9,21 @@ name: CultMesh QUIC native
# from the pinned debian:13 container below, never from a local or Yggdrasil # from the pinned debian:13 container below, never from a local or Yggdrasil
# build. # build.
# Only a change to the committed binaries, their manifest or this workflow can change what verify-committed checks,
# so a branch push or pull request runs it for those paths alone; a tag always runs it.
on: on:
push: push:
branches: branches:
- main - main
paths:
- "packages/cultmesh-ts/native/**"
- ".github/workflows/quic-native.yml"
tags: tags:
- "cultmesh-quic-native-v*" - "cultmesh-quic-native-v*"
pull_request: pull_request:
paths:
- "packages/cultmesh-ts/native/**"
- ".github/workflows/quic-native.yml"
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:

View file

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

View file

@ -140,6 +140,15 @@ A malformed put or delete is `message_invalid`, not a per-operation code: the pe
- A shard snapshot represents a log sequence. Applying it replaces the local - A shard snapshot represents a log sequence. Applying it replaces the local
shard view and advances the replica cursor to that sequence. shard view and advances the replica cursor to that sequence.
- Realtime subscriptions publish domain changes, not storage envelopes. - Realtime subscriptions publish domain changes, not storage envelopes.
- A subscription's `database_change_raw` messages may carry `commitId` and `commitCount`: the changes of one cache
commit that this subscription delivers (`commitCount` of them, at least two). Both fields are optional and additive; a
peer that does not know them ignores them and applies each change alone. A replica that knows them applies the
commit's changes as one commit, so the replica judges the result (a swap of two unique-index values) and not the state
between records. A replica whose cache refuses a commit is faulted, never silent. `commitId` is opaque and valid only
within the subscription. The C# subscription server and database server tag commits; other runtimes adopt next.
- The C# writer sets every key of a `database_change_raw` message, nil where unset: a removed change carries `document`
nil, an added or updated one `recordKey` and `schemaId` nil. The schema accepts those nils; an added or updated change
still needs a document, and a removed one a `recordKey` and `schemaId`.
- Client-owned input documents may be predicted locally only inside explicit - Client-owned input documents may be predicted locally only inside explicit
`CultNetClientAuthorityScope` declarations. `CultNetClientAuthorityScope` declarations.
- Predicted input state is not authoritative shared state. The shard log still - Predicted input state is not authoritative shared state. The shard log still

View file

@ -10,15 +10,29 @@
"messageId": { "type": "string", "minLength": 1 }, "messageId": { "type": "string", "minLength": 1 },
"subscriptionId": { "type": "string", "minLength": 1 }, "subscriptionId": { "type": "string", "minLength": 1 },
"changeKind": { "enum": ["added", "updated", "removed"] }, "changeKind": { "enum": ["added", "updated", "removed"] },
"document": { "$ref": "./cultnet.raw-document-record.schema.json" }, "document": {
"recordKey": { "type": "string", "minLength": 1 }, "oneOf": [{ "type": "null" }, { "$ref": "./cultnet.raw-document-record.schema.json" }]
"schemaId": { "type": "string", "minLength": 1 } },
"recordKey": { "type": ["string", "null"], "minLength": 1 },
"schemaId": { "type": ["string", "null"], "minLength": 1 },
"commitId": { "type": ["string", "null"], "minLength": 1 },
"commitCount": { "type": ["integer", "null"], "minimum": 2 }
}, },
"allOf": [ "oneOf": [
{ {
"if": { "properties": { "changeKind": { "const": "removed" } } }, "properties": {
"then": { "required": ["recordKey", "schemaId"] }, "changeKind": { "const": "removed" },
"else": { "required": ["document"] } "recordKey": { "type": "string" },
"schemaId": { "type": "string" }
},
"required": ["recordKey", "schemaId"]
},
{
"properties": {
"changeKind": { "enum": ["added", "updated"] },
"document": { "$ref": "./cultnet.raw-document-record.schema.json" }
},
"required": ["document"]
} }
] ]
} }

File diff suppressed because one or more lines are too long

View file

@ -15,7 +15,7 @@ All documents are CultNet map-mode: every key written, nil where absent, closed
| `gamecult.fork.answer_request.v0` | the answer a decider sends | the client; Bifrost forwards it unchanged to Huginn's second door | | `gamecult.fork.answer_request.v0` | the answer a decider sends | the client; Bifrost forwards it unchanged to Huginn's second door |
| `gamecult.desk_answer.v0` | the stored answer with one tagged outcome | Huginn only | | `gamecult.desk_answer.v0` | the stored answer with one tagged outcome | Huginn only |
| `gamecult.fork.answer_result.v0` | what the sender gets back for a stored answer | Bifrost, from the stored `desk_answer` | | `gamecult.fork.answer_result.v0` | what the sender gets back for a stored answer | Bifrost, from the stored `desk_answer` |
| `gamecult.fork.refusal.v0` | a body refused at the door; nothing stored | Bifrost | | `gamecult.fork.refusal.v0` | a body refused at the door, nothing stored (but `fork_too_large` on a stale answer, which sits over a stored `Refused(stale_fork)`) | Bifrost |
| `gamecult.fork.record.v0` | a fork's answers plus the derived counted answer | a projection of the stored `desk_answer`s | | `gamecult.fork.record.v0` | a fork's answers plus the derived counted answer | a projection of the stored `desk_answer`s |
| `gamecult.fork.ruling_cite.v0` | the citation a ruling carries | the Mind admitting the ruling | | `gamecult.fork.ruling_cite.v0` | the citation a ruling carries | the Mind admitting the ruling |
| `gamecult.fork.list_request.v0` | list a Mind's forks (Transport) | the desk | | `gamecult.fork.list_request.v0` | list a Mind's forks (Transport) | the desk |
@ -26,13 +26,15 @@ All documents are CultNet map-mode: every key written, nil where absent, closed
### card ### card
`{schemaVersion, fork_id, title, context, options[{id, text}], recommendation, depends[], leans, flag, raised_in, deciders[], status, ruling, ruled_from[], order, options_hash}`. `{schemaVersion, fork_id, title, context, options[{id, text}], recommendation, depends[], leans, flag, raised_in, raised_at, deciders[], decider_count, status, ruling, ruled_from[], order, options_hash}`.
- `fork_id`: the mind's `question` key, `<campaign>:question:<label>`. - `fork_id`: the mind's `question` key, `<campaign>:question:<label>`.
- **Hashed (the shown members):** `title`, `context`, `options` (each `{id, text}`, in shown order), `recommendation` (an option id, **never nil**: the stored `question` requires `recommended`), `depends` (strings, each one of the stored question's `depends` lines verbatim, at most 8, each at most 1000 UTF-8 bytes (the stored `Line`), in shown order, empty when none; the stored kind holds prose lines, so a line may read like a reference such as `campaign:kind:label` but is hashed as the text it is). - **Hashed (the shown members):** `title`, `context`, `options` (each `{id, text}`, in shown order), `recommendation` (an option id, **never nil**: the stored `question` requires `recommended`), `depends` (strings, each one of the stored question's `depends` lines verbatim, at most 8, each at most 1000 UTF-8 bytes (the stored `Line`), in shown order, empty when none; the stored kind holds prose lines, so a line may read like a reference such as `campaign:kind:label` but is hashed as the text it is).
- **Not hashed:** `fork_id` (bound beside the hash), `leans` (an option id or nil), `flag`, `raised_in`, `deciders`, `status` (`open` or `closed`), `ruling` (the ruling id when a ruling closed the fork; nil when the card is open, and nil on a closed card whose question was withdrawn or otherwise resolved with no ruling), `ruled_from` (answer ids a ruling has been admitted from; non-empty only on a closed card), `order`, and `options_hash` itself. - **Not hashed:** `fork_id` (bound beside the hash), `leans` (an option id or nil), `flag`, `raised_in`, `raised_at` (the instant the question was first admitted, an RFC3339 instant string as `answered_at`, or nil when the mind holds no admission time, as for a card projected before the field; the key is always written; it never moves when the question is edited; age is the client's: now minus `raised_at`), `deciders`, `decider_count` (the number of deciders of the call, set beside `deciders`; neither it nor the emptiness of `deciders` enters the hash, so the hash a non-decider recomputes is a decider's), `status` (`open` or `closed`), `ruling` (the ruling id when a ruling closed the fork; nil when the card is open, and nil on a closed card whose question was withdrawn or otherwise resolved with no ruling), `ruled_from` (answer ids a ruling has been admitted from; non-empty only on a closed card), `order`, and `options_hash` itself.
- **`deciders` is read-only data.** The list is set outside the Mind, through Bifrost's admin path, and **Bifrost sends it per call**: Huginn keeps no copy (M5). The card projection copies the list the call carried. **The list holds one to 64 ids** (Huginn's bound), on the card and in the per-call list alike (vectors `deciders_at_the_cap_of_64`, `deciders_65`); the per-call list has no schema here, so the bound is stated for the cut that builds it. A question's own `audience` may only narrow it. A Mind never writes it, so a Mind cannot add itself and answer its own fork. - **`deciders` is read-only data.** The list is set outside the Mind, through Bifrost's admin path, and **Bifrost sends it per call**: Huginn keeps no copy (M5). The card projection copies the list the call carried. **The per-call list holds one to 64 ids** (Huginn's bound; vectors `deciders_at_the_cap_of_64`, `deciders_65`); the per-call list has no schema here, so the bound is stated for the cut that builds it. **`decider_count` is the size of that list, an integer 1 to 64, always written** (vectors `decider_count_missing`, `_zero`, `_65`, `_string`, `_null`). A fork with no decider is still refused, now as `decider_count` 0. **`deciders` holds that list for a decider and is empty for a reader who is not one: empty means "the ids are withheld from this reader", never "no deciders"** (vector `withheld_deciders`). Rule R-card: when `deciders` is non-empty it holds exactly `decider_count` ids; a visible list is whole (vectors `decider_count_fewer_than_the_ids`, `decider_count_more_than_the_ids_shown`). A question's own `audience` may only narrow it. A Mind never writes it, so a Mind cannot add itself and answer its own fork.
- Rules a schema cannot say, each with a negative vector: a `depends` line is at most 1000 UTF-8 bytes (the schema bounds 8 lines and 1000 characters; bytes need the rule); option ids are unique; `recommendation` and `leans` name an option; deciders are unique; an open card names no `ruling` and its `ruled_from` is empty (a ruling that answers a fork closes it); `ruled_from` is non-empty only when `ruling` is set; a closed card may name no ruling (the question was withdrawn, superseded or deferred: step 6 reads `status`, not the ruling); `options_hash` equals the hash of the shown members. - Rules a schema cannot say, each with a negative vector: a `depends` line is at most 1000 UTF-8 bytes (the schema bounds 8 lines and 1000 characters; bytes need the rule); option ids are unique; `order` is unique among a Mind's cards (a list with two cards on one `order` is refused, naming both fork ids); `recommendation` and `leans` name an option; deciders are unique; an open card names no `ruling` and its `ruled_from` is empty (a ruling that answers a fork closes it); `ruled_from` is non-empty only when `ruling` is set; a closed card may name no ruling (the question was withdrawn, superseded or deferred: step 6 reads `status`, not the ruling); `options_hash` equals the hash of the shown members; R-card (above); R-answer-result: `answer_result.answer.answerer` is never nil, being the sender's own stamped account (vector `answer_result_with_a_nil_answerer`); R-record, R-entry and R-list ("Reader views" below).
**Reader views.** A card and its record are shown in one of two views. The **decider view** carries the ids: `deciders` whole, every `answerer` named, and the counts equal to the ids. The **non-decider view** withholds them: `deciders` empty, every `answerer` nil, the counts kept (`decider_count`; `answerer_count` on the record). Who is a decider is Huginn's decision, not this contract's: a reader is a decider exactly when the call's subject is byte-equal to one of the call's deciders (`eureka-substrate:ruling:desk-read-visibility-withheld-ids`); Huginn's door withholds, Bifrost cannot (the card is opaque to it). There is no viewer flag on any document: the view is read off whether `deciders` is empty. This contract carries both views and refuses a view that leaks: R-record: if every `answerer` is named, `answerer_count` is the number of distinct ids; if all are nil and there is at least one answer, `answerer_count` is between 1 and the number of answers; with no answers it is 0 (vectors `answerer_count_disagrees_with_the_ids`, `answerer_count_counts_answers_not_people`, `withheld_count_over_the_answers`, `withheld_count_zero_over_answers`, `withheld_count_over_no_answers`); R-entry, the leak rule: an entry with `deciders` non-empty names every answerer, an entry with `deciders` empty names none (vectors `decider_card_with_withheld_answers`, `withheld_card_with_named_answers`, `withheld_card_with_a_named_answerer_among_nils`); R-list: all entries of one page agree on the view (vector `a_page_mixes_views`). The view does not move the page: `non_decider_page` has the counts, order, cursor and stamp of the decider page it twins, and the vectors' no-leak check finds no id string in a non-decider vector's bytes. `options_hash` is the same in both views, and the bound-answer and `stale_fork` machinery is untouched.
### Options hash (canonical bytes) ### Options hash (canonical bytes)
@ -65,7 +67,9 @@ The stamped `authority` field of a stored answer is an **attribution**: the role
`received_at` is Huginn's admission time; "first" means first by admission order (the `n<seq>` ordinal), never by `answered_at`. `claim_in_future` is true when `answered_at` is more than five minutes after `received_at`: accepted but flagged. It is a function of the two stored values, so the writer compares against the `received_at` it stores, at the precision it stores it (a flag decided against an unrounded clock and a stored second-truncated `received_at` disagrees with the document it sits in). **Not stored, and no answer id:** a body refused at step 1 or 2 (Bifrost; it never reaches Huginn), and a body naming an unknown fork (step 3; Huginn answers it from its stored forks, but with no question there is nothing to key a record under). Each gets a `refusal` and is dropped. A malformed body is therefore never stored, and neither is an unknown fork. Every refusal from step 4 on is stored. **`unavailable`** (503) is not a refusal and not in the refusal set: Bifrost answers it when the second door or Huginn did not answer after the auth steps and the body shape passed (a bad body is still refused first). **It says the outcome is NOT CONFIRMED, never that nothing was stored**: a timeout after the request reached Huginn may have stored it. It carries no answer id. The desk retries the same request with the same `request_id` and learns the outcome: the stored one if Huginn had it, a fresh one if not. It covers **calls 1 and 2 as well as the answer POST** when Huginn cannot be reached (Transport; a read has no outcome to confirm, so a retry is plain). `retryAfterSeconds` is nil when Bifrost has no hint. `received_at` is Huginn's admission time; "first" means first by admission order (the `n<seq>` ordinal), never by `answered_at`. `claim_in_future` is true when `answered_at` is more than five minutes after `received_at`: accepted but flagged. It is a function of the two stored values, so the writer compares against the `received_at` it stores, at the precision it stores it (a flag decided against an unrounded clock and a stored second-truncated `received_at` disagrees with the document it sits in). **Not stored, and no answer id:** a body refused at step 1 or 2 (Bifrost; it never reaches Huginn), and a body naming an unknown fork (step 3; Huginn answers it from its stored forks, but with no question there is nothing to key a record under). Each gets a `refusal` and is dropped. A malformed body is therefore never stored, and neither is an unknown fork. Every refusal from step 4 on is stored. **`unavailable`** (503) is not a refusal and not in the refusal set: Bifrost answers it when the second door or Huginn did not answer after the auth steps and the body shape passed (a bad body is still refused first). **It says the outcome is NOT CONFIRMED, never that nothing was stored**: a timeout after the request reached Huginn may have stored it. It carries no answer id. The desk retries the same request with the same `request_id` and learns the outcome: the stored one if Huginn had it, a fresh one if not. It covers **calls 1 and 2 as well as the answer POST** when Huginn cannot be reached (Transport; a read has no outcome to confirm, so a retry is plain). `retryAfterSeconds` is nil when Bifrost has no hint.
**The mind's record and this document.** Huginn keeps the answer as its own record kind (`epiphany.pipeline.desk_answer.v2`, key `<campaign>:desk_answer:<question-label>.n<seq>`, the outcome as one externally tagged value, the fork as `fork`). The second door projects it to this document and nothing else crosses: `answer_id` is the key's `n<seq>` part, `fork_id` is the stored `fork`, the tagged outcome flattens into `outcome`, `of`, `retracts` and `code`, and the six refusal codes are spelt as in the schema (snake_case; the record's enum spells them in the mind's own PascalCase). On the wire an answer is named by its `n<seq>` alone, always inside the fork whose `fork_id` the document carries; a runtime that holds the full record key converts at the door, in one place. `body_sha256` and any other digest Huginn keeps to decide "same body" stay in the record: Huginn alone decides a replay or a `request_id_conflict` and Bifrost never compares bodies, so no digest is a field of this document. A digest is a valid way to decide it only if it covers `fork_id`, `options_hash`, `answer.choice`, `answer.note`, `retracted`, `retracts` and `answered_at` (every field of the request but `request_id`), is computed by Huginn and never read from a client. **The record.** `record` is `{schemaVersion, fork_id, options_hash, counted_answer_id, answerer_count, answers[]}`. `answerer_count` is an integer of at least 0: the number of distinct answerer ids over every stored answer of the fork, refusals included (a refusal stores the stamped answerer too), and it is the same number in both views (vectors `answerer_count_missing`, `_negative`, `_null`). `answers[].answerer` is a string or nil (nil only in the non-decider view).
**The mind's record and this document.** Huginn keeps the answer as its own record kind (`epiphany.pipeline.desk_answer.v2`, key `<campaign>:desk_answer:<question-label>.n<seq>`, the outcome as one externally tagged value, the fork as `fork`). The second door projects it to this document and nothing else crosses: `answer_id` is the key's `n<seq>` part, `fork_id` is the stored `fork`, the tagged outcome flattens into `outcome`, `of`, `retracts` and `code`, and the six refusal codes are spelt as in the schema (snake_case; the record's enum spells them in the mind's own PascalCase). On the wire an answer is named by its `n<seq>` alone, always inside the fork whose `fork_id` the document carries; a runtime that holds the full record key converts at the door, in one place. Huginn's stored `desk_answer` keeps a non-nil `answerer`; nil exists only in a record projected to a non-decider. `body_sha256` and any other digest Huginn keeps to decide "same body" stay in the record: Huginn alone decides a replay or a `request_id_conflict` and Bifrost never compares bodies, so no digest is a field of this document. A digest is a valid way to decide it only if it covers `fork_id`, `options_hash`, `answer.choice`, `answer.note`, `retracted`, `retracts` and `answered_at` (every field of the request but `request_id`), is computed by Huginn and never read from a client.
**Huginn's digest, pinned in the vectors (`bodyDigests`; informative, not a wire document).** SHA-256 of one compact JSON object as `serde_json::to_string` writes it (UTF-8, no spaces), lowercase hex, keys in this order, `null` for a missing value, strings verbatim: `fork`, `options_hash`, `answered_at`, `choice`, `note`, `retracts` (the **full stored key** `<campaign>:desk_answer:<label>.n<seq>`: the door converts the wire's `n<seq>` before the digest), then, **only for the three illegal forms** (R1: a retraction carrying an answer, a retraction naming no target, a plain answer carrying a target), `illegal_retraction: true` and `retracted: true|false`. `request_id` is not in it. A plain answer and a legal retraction carry neither trailing key, so their digests are the ones Huginn main pins (`8a4d5b2e`, `9f807538`) and do not move. `9f807538` is pinned for a target no door produces (`Q1.1`); the golden for the real legal retraction is the door's form, the wire's `n1` converted to `Q1.n1` (`84576840`, vector `legal_retraction_door_form`, also main-valid). Source: Eureka's `desk-body-digest-order-20261110.md`, computed with `sha256sum`; the generator, the C# suite and the TypeScript suite recompute every digest from the input fields. **Status:** the three `retracted` goldens (`935cab1a`, `82b24ccc`, `34ef86ed`, and `79eee623` for form (i) with a null target) are the form of `cut-desk-digest-retracted.r2`, merged as Huginn #30. Eureka reports that these four and the door-form legal retraction (`84576840`) reproduce byte for byte on Huginn main `f0ca504` (the #30 fix); that run is hers, reported to us, and was not rerun here. Before #30, Huginn main (`7ea11b4`) wrote `illegal_retraction` alone, so forms (i) and (iii) collided there (`69074cc6`, pinned as `illegal_i_and_iii_alike_on_main`). The wire contract does not wait on it: the conflict scenarios are pinned in `scenarios`. **Huginn's digest, pinned in the vectors (`bodyDigests`; informative, not a wire document).** SHA-256 of one compact JSON object as `serde_json::to_string` writes it (UTF-8, no spaces), lowercase hex, keys in this order, `null` for a missing value, strings verbatim: `fork`, `options_hash`, `answered_at`, `choice`, `note`, `retracts` (the **full stored key** `<campaign>:desk_answer:<label>.n<seq>`: the door converts the wire's `n<seq>` before the digest), then, **only for the three illegal forms** (R1: a retraction carrying an answer, a retraction naming no target, a plain answer carrying a target), `illegal_retraction: true` and `retracted: true|false`. `request_id` is not in it. A plain answer and a legal retraction carry neither trailing key, so their digests are the ones Huginn main pins (`8a4d5b2e`, `9f807538`) and do not move. `9f807538` is pinned for a target no door produces (`Q1.1`); the golden for the real legal retraction is the door's form, the wire's `n1` converted to `Q1.n1` (`84576840`, vector `legal_retraction_door_form`, also main-valid). Source: Eureka's `desk-body-digest-order-20261110.md`, computed with `sha256sum`; the generator, the C# suite and the TypeScript suite recompute every digest from the input fields. **Status:** the three `retracted` goldens (`935cab1a`, `82b24ccc`, `34ef86ed`, and `79eee623` for form (i) with a null target) are the form of `cut-desk-digest-retracted.r2`, merged as Huginn #30. Eureka reports that these four and the door-form legal retraction (`84576840`) reproduce byte for byte on Huginn main `f0ca504` (the #30 fix); that run is hers, reported to us, and was not rerun here. Before #30, Huginn main (`7ea11b4`) wrote `illegal_retraction` alone, so forms (i) and (iii) collided there (`69074cc6`, pinned as `illegal_i_and_iii_alike_on_main`). The wire contract does not wait on it: the conflict scenarios are pinned in `scenarios`.
@ -105,28 +109,28 @@ Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-sp
| call | route (proposal) | request | response | token | | call | route (proposal) | request | response | token |
|---|---|---|---|---| |---|---|---|---|---|
| 1 list | `GET /api/desk/{mind}/forks?status=&cursor=&limit=` | `list_request`: `mind` from the path, the rest from the query string | `list` | audience `bifrost-desk`, scope `desk.read` | | 1 list | `GET /api/desk/{mind}/forks?status=&cursor=&limit=` | `list_request`: `mind` from the path, the rest from the query string | `list` | audience `bifrost-desk`, scope `desk.read` |
| 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) | `desk.read` | | 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) or `fork_too_large` (422) | `desk.read` |
| 3 answer | `POST /api/desk/{mind}/answers` | `answer_request` as the body; `fork_id` stays in the body, the Mind is the route's and is no body field | `answer_result` (200 for `Counted`, `Revision` and `Retraction`; a stored `Refused` answer keeps the `answer_result` body and takes its code's status from the counting-rules table: `stale_fork` 409, `not_a_decider` 403, `fork_closed` 409, `choice_invalid` 400, `bad_retraction` 400, `already_retracted` 409, as the vectors' `results` pin; a replay returns the same status as the first call; the desk reads `outcome` and `code` from the body), or a `refusal`, or `unavailable` | audience `bifrost-desk`, scope `desk.answer` | | 3 answer | `POST /api/desk/{mind}/answers` | `answer_request` as the body; `fork_id` stays in the body, the Mind is the route's and is no body field | `answer_result` (200 for `Counted`, `Revision` and `Retraction`; a stored `Refused` answer keeps the `answer_result` body and takes its code's status from the counting-rules table: `stale_fork` 409, `not_a_decider` 403, `fork_closed` 409, `choice_invalid` 400, `bad_retraction` 400, `already_retracted` 409, as the vectors' `results` pin; a replay returns the same status as the first call; the desk reads `outcome` and `code` from the body), or a `refusal` (`fork_unknown`, or `fork_too_large` when a stale answer's card re-read is past the cap), or `unavailable` | audience `bifrost-desk`, scope `desk.answer` |
**The `{mind}` segment** (Heimdall's rulings M1 to M5; vectors `transport.mindRoutes`). **The `{mind}` segment** (Heimdall's rulings M1 to M5; vectors `transport.mindRoutes`).
- **M1.** `{mind}` is looked up in a **fixed configured list** of Mind ids, each mapped in configuration to that Mind's socket on the peer-checked door. The segment is percent-decoded once and compared, ordinally, with the list's ids: no case folding, trimming or Unicode normalisation, and a decoding failure is a miss. It is **never joined into a path, a socket name or a query**, so a value off the list opens nothing (`../x`, `a/b`, `a%2fb`, `eureka%00`, a 4 KB string, `GAMECULT-CHAT` for `gamecult-chat`, a trailing space, bytes that are not UTF-8: all unknown, 404, no socket opened). - **M1.** `{mind}` is looked up in a **fixed configured list** of Mind ids, each mapped in configuration to that Mind's socket on the peer-checked door. The segment is percent-decoded once and compared, ordinally, with the list's ids: no case folding, trimming or Unicode normalisation, and a decoding failure is a miss. It is **never joined into a path, a socket name or a query**, so a value off the list opens nothing (`../x`, `a/b`, `a%2fb`, `eureka%00`, a 4 KB string, `GAMECULT-CHAT` for `gamecult-chat`, a trailing space, bytes that are not UTF-8: all unknown, 404, no socket opened).
- **M2.** An unknown Mind gets **the same refusal as an unknown fork**: `fork_unknown`, 404, the same bytes (the document's `error` names neither the Mind nor the fork). The list of Minds is not probeable. Auth is judged first, so a caller without a valid token, scope or Active membership learns nothing about which Minds exist. - **M2.** An unknown Mind gets **the same refusal as an unknown fork**: `fork_unknown`, 404, the same bytes (the document's `error` names neither the Mind nor the fork). The list of Minds is not probeable. Auth is judged first, so a caller without a valid token, scope or Active membership learns nothing about which Minds exist.
- **M3.** On the POST the route's Mind must hold `fork_id`: step 3 is judged against **that Mind's** stored forks, so a `fork_id` the route's Mind does not hold is `fork_unknown` and nothing is stored, in either Mind. A fork cannot be answered through another Mind's route. A body that names `mind` is `body_unknown_field`: the Mind is the route's. - **M3.** On the POST the route's Mind must hold `fork_id`: step 3 is judged against **that Mind's** stored forks, so a `fork_id` the route's Mind does not hold is `fork_unknown` and nothing is stored, in either Mind. A fork cannot be answered through another Mind's route. A body that names `mind` is `body_unknown_field`: the Mind is the route's.
- **M4.** Scopes are **not per Mind**: `desk.read` and `desk.answer` open the routes of every configured Mind; every reader sees every fork's card and outcome, and which forks a caller may answer is the decider list. - **M4.** Scopes are **not per Mind**: `desk.read` and `desk.answer` open the routes of every configured Mind; every reader sees every fork's card and outcome (a non-decider sees no account id, "Reader views"), and which forks a caller may answer is the decider list.
- **M5. The decider list is sent per call.** Bifrost holds the admin-set list and sends it with **every answer, read and list request** to Huginn, as a typed field on the same peer-checked door; Huginn keeps no copy of it. Because it travels with read and list too, a projected card's `deciders` is the list of that call, and step 5 judges an answer against the list the same call carried. It comes only from Bifrost's admin-set state: never from a Mind and never from any request body a client controls: no HTTP field, header or query parameter on any desk route sets or extends it, and a body naming `deciders` is `body_unknown_field` (scenario `M5_...`). A request carrying it from a Mind's own door or session is refused. **This is Bifrost and Huginn work and no vector here can test it** (the typed field and the door's wire are pending, open choice 3); the rule is stated so that the cut that builds the request has it. (Heimdall's ruling, #minds 2026-11-10 19:26Z; it replaces the earlier reading of a list Huginn receives once and holds.) **Envelope names:** the desk's envelope on that door uses the names this page uses, `mind`, `authority`, `subject` and `deciders`, with no conversion at the door (Eureka, #minds 2026-11-10 19:58Z); `deciders` is a per-call list of short ids at the envelope's top level beside `subject`, never inside the request body, and Huginn keeps no copy. - **M5. The decider list is sent per call.** Bifrost holds the admin-set list and sends it with **every answer, read and list request** to Huginn, as a typed field on the same peer-checked door; Huginn keeps no copy of it. Because it travels with read and list too, a projected card's `deciders` is the list of that call for a decider and empty for a reader who is not one, with `decider_count` travelling with both views, and step 5 judges an answer against the list the same call carried. It comes only from Bifrost's admin-set state: never from a Mind and never from any request body a client controls: no HTTP field, header or query parameter on any desk route sets or extends it, and a body naming `deciders` is `body_unknown_field` (scenario `M5_...`). A request carrying it from a Mind's own door or session is refused. **This is Bifrost and Huginn work and no vector here can test it** (the typed field and the door's wire are pending, open choice 3); the rule is stated so that the cut that builds the request has it. (Heimdall's ruling, #minds 2026-11-10 19:26Z; it replaces the earlier reading of a list Huginn receives once and holds.) **Envelope names:** the desk's envelope on that door uses the names this page uses, `mind`, `authority`, `subject` and `deciders`, with no conversion at the door (Eureka, #minds 2026-11-10 19:58Z); `deciders` is a per-call list of short ids at the envelope's top level beside `subject`, never inside the request body, and Huginn keeps no copy.
Calls 1 and 2 are enough for a read-only desk. Every response is the pinned MessagePack document or its JSON form with the same keys (open choice 1 of `vote-api.md`: the media type is unsettled). Calls 1 and 2 are enough for a read-only desk. Every response is the pinned MessagePack document or its JSON form with the same keys (open choice 1 of `vote-api.md`: the media type is unsettled).
**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document) and `request_id_conflict` are added to the door refusals. **Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document; an unknown key, `authority` included, is `request_invalid` here, whatever Huginn's own decode does with it) and `request_id_conflict` are added to the door refusals, and so is `fork_too_large` (422, list and read; see List and Read). **`request_id_conflict` is answer-only**: the idempotency key exists only on the answer. A door `request_id_conflict` on a read or list is a door fault and reaches the client as 503 `unavailable`, never as `request_id_conflict` (Persona, 2026-11-11, agreed with Heimdall; vectors `transport.doorFaults`, each carrying the client response's bytes). The `unavailable` text's "same request_id" retry hint is for answers; reads and lists carry no request_id..
**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`** (the admission ordinal). `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c<N>`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s<N>`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`). **List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c<N>`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s<N>`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. **A page may hold fewer than `limit` entries** when the next entry would push the encoded response past the receiver's response budget (Huginn: 65536 bytes); it then holds at least one, `next_cursor` is `c<order of the last entry returned>` so the next page resumes below it, and the counts and the stamp are those of the full list, unchanged. **`fork_too_large`** (HTTP 422) refuses a page whose **first** entry alone is past the budget: 422 because the request is well formed and authorised but names a resource the list cannot carry, and 413 is for a request body that is too large, which this is not; retrying returns the same refusal. Its `error` names the fork id and its `order`, for example `fork_too_large: fork <fork_id> (order <n>) alone is past the response budget`. **A refusal's `error` text is informational and not pinned**: the contract pins the code, the HTTP status, that the text names the fork id (and the order on a list), and the `c<order>` continuation on a list; the vectors' wording is a sample, and the judges check those four things, not the bytes of the text. The client continues the list with cursor `c<n>`, the cards strictly below that order, which skips this fork; it is then readable only by id (the read route returns the whole entry, and refuses with the same code only when that entry alone is past the cap, see Read); `c<n>` is valid only when n is at least 1, so for order 0 there is nothing below and the client has reached the end. `limit` 0 returns no entries and never refuses. (Ruled by Persona, 2026-11-11.) An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`).
**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands. **Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Huginn withholds account ids from a reader who is not a decider and returns counts ("Reader views"); Bifrost still strips notes.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). **A read is refused `fork_too_large` (422) when the whole entry is past the reply cap** (Huginn's daemon caps every reply at 65536 bytes; the cap, not the page budget, is what a read meets). Its `error` names the fork id and has no order and no cursor to carry, because there is nothing to continue with (sample: `fork_too_large: fork <fork_id> alone is past the response budget`) (vector `transport.readTooLarge`; the cap is in no vector). **The answer route meets the same cap:** when an answer is `stale_fork`, Bifrost re-reads the current card to attach it, and if that read is past the cap the answer is refused `fork_too_large` (422) with the read form of the text, the same document as a read. Huginn has already stored the answer as `Refused(stale_fork)`, as for any stale answer, and it is never counted; the client is told `fork_too_large` instead of receiving the current card, and a replay of the same `request_id` gets the same 422 while the fork stays oversize. It is never `unavailable` (vector `transport.answerTooLarge`). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands.
**Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n<seq>` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way. **Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n<seq>` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way.
**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid`; `listScenarios` (five scenarios over one Mind's five cards: paged open-only, recorded-only, all, `limit` 0, an empty Mind, each response with its bytes, the cursor of page two being page one's `next_cursor`); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked. **Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by <first fork id> and <second fork id>`); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `listShortPages` (three scenarios: a page that stops short and resumes below its last entry; `fork_too_large` on a first entry of order 3, continued with `c3`; and a short page ending at order 1 followed by `fork_too_large` on order 0, where the client is at the end; each step carries its bytes, and the receiver's budget is in no vector, so the judges check a short page as a nonempty strict prefix of the unbudgeted page and a refusal as naming the entry the page would open with); `doorFaults` (three cases: a door `request_id_conflict` is `unavailable` 503 on read and list and itself 409 on the answer); `readTooLarge` (one case: a read whose entry is past the cap, refused `fork_too_large` 422 with the read-form text and its bytes); `answerTooLarge` (one case: a stale answer whose card re-read is past the cap, the same 422 document over a stored `Refused(stale_fork)`: `outcome` `Refused`, `code` `stale_fork`, `counted` false); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked.
**Bifrost must still build:** all three routes; the `bifrost-desk` audience and the `desk.read` and `desk.answer` scopes on the OIDC token it issues to chat; the configured Mind list and its sockets (M1); the projection of cards and records from Huginn, the stamp, the cursor and the counts; the `DeskAnswer` call on the second door and the pass-through of its outcome; the decider list, sent with every answer, read and list request from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the per-call decider list it receives with each answer, read and list request, keeping no copy (M5), and the read side the projection uses (Eureka's cut spec, after her record PR). **The desk may build today** against the vectors: nothing in them needs a live service. **Bifrost must still build:** all three routes; the `bifrost-desk` audience and the `desk.read` and `desk.answer` scopes on the OIDC token it issues to chat; the configured Mind list and its sockets (M1); the projection of cards and records from Huginn, the stamp, the cursor and the counts; the `DeskAnswer` call on the second door and the pass-through of its outcome; the decider list, sent with every answer, read and list request from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the per-call decider list it receives with each answer, read and list request, keeping no copy (M5), and the read side the projection uses (Eureka's cut spec, after her record PR). **The desk may build today** against the vectors: nothing in them needs a live service.
@ -147,9 +151,11 @@ The old desk (`index.html`) read `questions/<id>` and wrote `answers/<id>` in a
| `lean` ("Self leans" mark) | none | `leans` (an id or nil), not hashed | | `lean` ("Self leans" mark) | none | `leans` (an id or nil), not hashed |
| `flag` (the provisional line) | none | `flag` (a string or nil), not hashed | | `flag` (the provisional line) | none | `flag` (a string or nil), not hashed |
| `status` open or closed, `ruling` | derived from the resolution (status is never written): any resolution of the question closes it; the ruling is the one that answered it | `status`, `ruling`, both a projection (`ruling` is nil on an open card and on a card closed with no ruling) | | `status` open or closed, `ruling` | derived from the resolution (status is never written): any resolution of the question closes it; the ruling is the one that answered it | `status`, `ruling`, both a projection (`ruling` is nil on an open card and on a card closed with no ruling) |
| `order` | none (admission ordinal) | `order` | | `order` | none (unique rank per Mind, from 0) | `order` |
| none | `raised_in`, `asked_on` | `raised_in`; `asked_on` is not carried | | none | `raised_in`, `asked_on` | `raised_in`; `asked_on` is not carried |
| none | the question's admission time (no timestamp in the old format; `asked_on` is a date and stays uncarried) | `raised_at`, an instant or nil, not hashed |
| none | none | `deciders` (outside the Mind), `ruled_from`, `options_hash` | | none | none | `deciders` (outside the Mind), `ruled_from`, `options_hash` |
| none | none | `decider_count`, `answerer_count` (counts that stand in for withheld ids) |
| answer `{choice: key or null, note, question, updatedAt}`, one mutable document per question | none | `answer_request` then an append-only `desk_answer` | | answer `{choice: key or null, note, question, updatedAt}`, one mutable document per question | none | `answer_request` then an append-only `desk_answer` |
| `note`: her words verbatim | | `answer.note`, verbatim | | `note`: her words verbatim | | `answer.note`, verbatim |
| "Clear pick": `choice` back to null so the note stands alone | | an `answer` with `choice` nil and `note` set, as the first answer or as a `Revision` | | "Clear pick": `choice` back to null so the note stands alone | | an `answer` with `choice` nil and `note` set, as the first answer or as a `Revision` |
@ -178,6 +184,8 @@ Option B, **Bifrost receives the answer and Huginn stores it** (the design in th
- `request_id` (on the request and the stored `desk_answer`) and `request_id_conflict` are this page's: his file has no idempotency key, and a retry after `unavailable` needs one. Eureka's `desk_answer` kind and Huginn's request type must carry it. - `request_id` (on the request and the stored `desk_answer`) and `request_id_conflict` are this page's: his file has no idempotency key, and a retry after `unavailable` needs one. Eureka's `desk_answer` kind and Huginn's request type must carry it.
- Transport: the routes, the `{mind}` segment, the scopes, the audience and the auth order are his rulings (4.1 to 4.3a); the documents (list, read, `unavailable`) are this page's. The note limit is 4000 UTF-8 bytes here, as his 1.1 leaves the unit unsaid. - Transport: the routes, the `{mind}` segment, the scopes, the audience and the auth order are his rulings (4.1 to 4.3a); the documents (list, read, `unavailable`) are this page's. The note limit is 4000 UTF-8 bytes here, as his 1.1 leaves the unit unsaid.
- The recommendation is never nil. The binding file's earlier vectors D and E used a nil recommendation; its updated D and E use `"a"`, as here. - The recommendation is never nil. The binding file's earlier vectors D and E used a nil recommendation; its updated D and E use `"a"`, as here.
- `raised_at` (2026-11-11) is the desk-feed addition (Bifrost PR 67): a required key, an instant or nil, outside `options_hash` (the five hashed members and every digest are unchanged; vectors `raised_at_nil`, `raised_at_epoch_integer`, `raised_at_date_only`, `raised_at_missing`). The card is spec-authored and no runtime deploys it, so the schema id stays `gamecult.fork.card.v0`.
- Reader views (2026-11-11) are the desk-feed addition behind Huginn PR 56 and `eureka-substrate:ruling:desk-read-visibility-withheld-ids`: the card gains `decider_count` and `deciders` may be empty; the record gains `answerer_count` and `answerer` may be nil, the counts beside the collections they summarise (not on the list envelope, which neither the read entry nor the `stale_fork` card travels in). `options_hash` and every digest are unchanged. The schema ids stay `gamecult.fork.card.v0` and `gamecult.fork.record.v0` for the same reason as `raised_at`.
- Hash vectors: A, B, C, D, E, F and G of the binding file are reproduced digest for digest by the Python generator, the C# suite and the TypeScript suite (the generator prints `same` for each; none differs). - Hash vectors: A, B, C, D, E, F and G of the binding file are reproduced digest for digest by the Python generator, the C# suite and the TypeScript suite (the generator prints `same` for each; none differs).
## Open choices ## Open choices
@ -186,10 +194,13 @@ Option B, **Bifrost receives the answer and Huginn stores it** (the design in th
2. NFC across runtimes relies on each runtime's normaliser; a pinned table, as `write-receipt.md` does for letters, would remove the reliance. The risk is only for code points assigned after a runtime's Unicode version. 2. NFC across runtimes relies on each runtime's normaliser; a pinned table, as `write-receipt.md` does for letters, would remove the reliance. The risk is only for code points assigned after a runtime's Unicode version.
3. How Huginn learns the verified subject: the second door's authenticated context (the peer credential names Bifrost the service, not the member), so the subject must travel beside the request. This contract has no envelope for it. 3. How Huginn learns the verified subject: the second door's authenticated context (the peer credential names Bifrost the service, not the member), so the subject must travel beside the request. This contract has no envelope for it.
4. Whether `fork_unknown` and shape refusals should leave an audit record somewhere other than a `desk_answer`. 4. Whether `fork_unknown` and shape refusals should leave an audit record somewhere other than a `desk_answer`.
5. Whether `leans` and `flag`, which the booth shows, should join the hash. The binding file's five members do not include them; a changed lean would not make an answer stale. 5. Whether `leans` and `flag`, which the booth shows, should join the hash. The binding file's five members do not include them; a changed lean would not make an answer stale. `raised_at` is immutable, so it was kept out of the hash for the same reason it need not be in: it can never make a shown card stale.
6. What the person sees of a retracted counted answer when the next answer counts: this contract only says the count moves. 6. What the person sees of a retracted counted answer when the next answer counts: this contract only says the count moves.
7. `received_at` is not in the binding file's stored fields; it is kept for `claim_in_future` and could be dropped if the ordinal alone decides order. 7. `received_at` is not in the binding file's stored fields; it is kept for `claim_in_future` and could be dropped if the `n<seq>` answer ordinal alone decided answer order.
8. The stamped `authority` field is an attribution, not an authority (above); renaming it `attribution` would say so, at the price of a new key the door must also refuse in a body. 8. The stamped `authority` field is an attribution, not an authority (above); renaming it `attribution` would say so, at the price of a new key the door must also refuse in a body.
9. The claims Bifrost verifies are PR #24's open item 5. (The audience `bifrost-desk`, the three routes and the scope names are ruled by Heimdall.) 9. The claims Bifrost verifies are PR #24's open item 5. (The audience `bifrost-desk`, the three routes and the scope names are ruled by Heimdall.)
10. The second door's wire between Bifrost and Huginn (pending Eureka's cut spec). `unavailable` is a document of its own, outside the refusal set, so a desk can retry it; calls 1 and 2 use it too when Huginn is down (vectors `huginn_down_*`). 10. The second door's wire between Bifrost and Huginn (pending Eureka's cut spec). `unavailable` is a document of its own, outside the refusal set, so a desk can retry it; calls 1 and 2 use it too when Huginn is down (vectors `huginn_down_*`).
11. While a known Mind's door is down its routes answer `unavailable` (503) and an unknown Mind answers `fork_unknown` (404), so the two are distinguishable for that period. M2 makes them identical only when every door is up; making them identical when one is down means answering `unavailable` for any `{mind}` not on the list, which tells a client nothing about which Minds exist but also hides a typo. Heimdall's to rule; the vectors do not pin it. 11. While a known Mind's door is down its routes answer `unavailable` (503) and an unknown Mind answers `fork_unknown` (404), so the two are distinguishable for that period. M2 makes them identical only when every door is up; making them identical when one is down means answering `unavailable` for any `{mind}` not on the list, which tells a client nothing about which Minds exist but also hides a typo. Heimdall's to rule; the vectors do not pin it.
12. A fork's answers are unbounded in number, so a fork's entry can grow past the reply cap and become unreadable (`fork_too_large` on read, and on list when it is the first entry of a page). Whether the contract should cap the answers per fork is for Persona and Heimdall to rule; nothing here decides it.
13. The read form of `fork_too_large` is a target, not current behaviour: Huginn's door answers an over-cap read as `Refused(ResponseTooLarge{bytes, limit})`, which carries no fork id and is not `fork_too_large`. Bifrost maps it to `fork_too_large` (422) using the route's `fork_id`; until it does, a read past the cap reaches the client as 503 `unavailable`. The same holds for the answer route: a `stale_fork` answer whose card re-read is past the cap is a Bifrost target too (`fork_too_large` 422 over a stored `Refused(stale_fork)`), and until it is built the client sees 503 `unavailable`.
14. What a non-decider can still infer: the card's `status` and `order`, the `authority` role on each answer (a role, not an id, and the ruling leaves it whole), and `answerer_count`. Whether a role plus a count is too much to show is Eureka's ruling to revisit.

View file

@ -70,7 +70,9 @@
"leans", "leans",
"flag", "flag",
"raised_in", "raised_in",
"raised_at",
"deciders", "deciders",
"decider_count",
"status", "status",
"ruling", "ruling",
"ruled_from", "ruled_from",
@ -143,15 +145,30 @@
} }
] ]
}, },
"raised_at": {
"oneOf": [
{
"$ref": "#/$defs/instant"
},
{
"type": "null"
}
]
},
"deciders": { "deciders": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 0,
"items": { "items": {
"type": "string", "type": "string",
"minLength": 1 "minLength": 1
}, },
"maxItems": 64 "maxItems": 64
}, },
"decider_count": {
"type": "integer",
"minimum": 1,
"maximum": 64
},
"status": { "status": {
"enum": [ "enum": [
"open", "open",
@ -373,7 +390,14 @@
"type": "boolean" "type": "boolean"
}, },
"answerer": { "answerer": {
"$ref": "#/$defs/id" "oneOf": [
{
"$ref": "#/$defs/id"
},
{
"type": "null"
}
]
}, },
"authority": { "authority": {
"$ref": "#/$defs/id" "$ref": "#/$defs/id"
@ -459,7 +483,14 @@
"type": "boolean" "type": "boolean"
}, },
"answerer": { "answerer": {
"$ref": "#/$defs/id" "oneOf": [
{
"$ref": "#/$defs/id"
},
{
"type": "null"
}
]
}, },
"authority": { "authority": {
"$ref": "#/$defs/id" "$ref": "#/$defs/id"
@ -531,7 +562,14 @@
"type": "boolean" "type": "boolean"
}, },
"answerer": { "answerer": {
"$ref": "#/$defs/id" "oneOf": [
{
"$ref": "#/$defs/id"
},
{
"type": "null"
}
]
}, },
"authority": { "authority": {
"$ref": "#/$defs/id" "$ref": "#/$defs/id"
@ -631,7 +669,14 @@
"type": "boolean" "type": "boolean"
}, },
"answerer": { "answerer": {
"$ref": "#/$defs/id" "oneOf": [
{
"$ref": "#/$defs/id"
},
{
"type": "null"
}
]
}, },
"authority": { "authority": {
"$ref": "#/$defs/id" "$ref": "#/$defs/id"
@ -714,6 +759,7 @@
"body_unknown_field", "body_unknown_field",
"body_invalid", "body_invalid",
"fork_unknown", "fork_unknown",
"fork_too_large",
"insufficient_scope", "insufficient_scope",
"forbidden", "forbidden",
"request_invalid", "request_invalid",
@ -734,6 +780,7 @@
"fork_id", "fork_id",
"options_hash", "options_hash",
"counted_answer_id", "counted_answer_id",
"answerer_count",
"answers" "answers"
], ],
"properties": { "properties": {
@ -756,6 +803,10 @@
} }
] ]
}, },
"answerer_count": {
"type": "integer",
"minimum": 0
},
"answers": { "answers": {
"type": "array", "type": "array",
"items": { "items": {

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

@ -701,6 +701,7 @@ checked by the operator in the editor.
- Run `MintElementIds` once over `GameData/Aetheria.cc`. - Run `MintElementIds` once over `GameData/Aetheria.cc`.
- AetherDb migrations skip variants or use the variant API (Q1a). - AetherDb migrations skip variants or use the variant API (Q1a).
- Re-author the families listed in section 6. - Re-author the families listed in section 6.
- Hull families live in each ship's own `.cc`, not in `GameData/Aetheria.cc`, because hulls are mod-owned packages (Nibu's ruling about her data, 2026-11-11, #minds).
### C5. CultNet deltas (C# reference) ### C5. CultNet deltas (C# reference)
@ -1608,7 +1609,7 @@ The one door per runtime, and who walks through it:
- **Shared paths.** cultnet-ts reaches the header decision only through `readSingleFileStore` (AUTH:4, :321) and checks only record count, schema id and type. cultnet-ts therefore needs a cultcache-ts release that exports it, which is the release that ships with this change; the manifests move to that version then (c0-ships-with-c2a), not before. - **Shared paths.** cultnet-ts reaches the header decision only through `readSingleFileStore` (AUTH:4, :321) and checks only record count, schema id and type. cultnet-ts therefore needs a cultcache-ts release that exports it, which is the release that ships with this change; the manifests move to that version then (c0-ships-with-c2a), not before.
- **Cut line.** `merge-r1` h3 (Rust) and h4 (TS, cultnet-ts); spec `cut-merge-r1.r3` authority map. - **Cut line.** `merge-r1` h3 (Rust) and h4 (TS, cultnet-ts); spec `cut-merge-r1.r3` authority map.
- **Pinned by.** `tests/vectors/document-variants-c2a/readability/manifest.txt`, walked by `StoreReadabilityTests.OpenReadsExactlyTheFilesTheVectorsSayItReads` (C#), `CultMeshSingleFileReadabilityTests.AWriteReplacesAFileExactlyWhenItReads` (Mesh), `a_file_is_replaced_exactly_when_it_reads` (RS:5132), "SingleFileMessagePackBackingStore replaces a file exactly when it reads" (TS test :2321) and `test_single_file_replaces_a_file_exactly_when_it_reads` (Python). AUTH cases: `packages/cultnet-ts/test/idunn-runtime-authority.test.ts`. Declared divergences in the manifest: `legacy-envelopes.bin` (C# refuses; the others read and replace it) and `variant-slot-v3.bin` (C# reads; the others refuse). - **Pinned by.** `tests/vectors/document-variants-c2a/readability/manifest.txt`, walked by `StoreReadabilityTests.OpenReadsExactlyTheFilesTheVectorsSayItReads` (C#), `CultMeshSingleFileReadabilityTests.AWriteReplacesAFileExactlyWhenItReads` (Mesh), `a_file_is_replaced_exactly_when_it_reads` (RS:5132), "SingleFileMessagePackBackingStore replaces a file exactly when it reads" (TS test :2321) and `test_single_file_replaces_a_file_exactly_when_it_reads` (Python). AUTH cases: `packages/cultnet-ts/test/idunn-runtime-authority.test.ts`. Declared divergences in the manifest: `legacy-envelopes.bin` (C# refuses; the others read and replace it) and `variant-slot-v3.bin` (C# reads; the others refuse).
- **Known gaps.** `variants:follow_up:c0-bad-canonical-in-manifest` (`current-catalog-bad-canonical.msgpack` is not in the manifest; TS reads it, Rust refuses). `variants:follow_up:legacy-vectors-all-runtimes` (the three `legacy-catalog-*` c0 vectors have a verdict only in C# tests; all four runtimes refuse them, probed). `variants:follow_up:range-check-in-ci` (`npm run test:pack:ts` builds every admitted tag and checks exports, but `.github/workflows/publish-packages.yml` does not run it). - **Known gaps.** `variants:follow_up:range-check-in-ci` (`npm run test:pack:ts` builds every admitted tag and checks exports, but `.github/workflows/publish-packages.yml` does not run it).
### B. Existence and links (what "nothing at the path" means) ### B. Existence and links (what "nothing at the path" means)
@ -1639,7 +1640,7 @@ The one door per runtime, and who walks through it:
- **Shared paths.** Open, commit, Mesh read and write. - **Shared paths.** Open, commit, Mesh read and write.
- **Cut line.** `mesh-single-file-reader` h1 (the C# clause). Rust, TS and Python were correct by construction of their reads. - **Cut line.** `mesh-single-file-reader` h1 (the C# clause). Rust, TS and Python were correct by construction of their reads.
- **Pinned by.** C#: `ADirectoryAtTheStorePathIsAnIoErrorOnOpenAndOnCommitNotAnEmptyStore` (`StoreReadabilityTests.cs:236`). Mesh: `ADirectoryAtThePathIsAnIoErrorOnReadAndWriteAndIsLeftAlone` (:149). Rust: `a_write_over_a_directory_stops_at_the_read_and_leaves_no_staging_file` RS:5726. - **Pinned by.** C#: `ADirectoryAtTheStorePathIsAnIoErrorOnOpenAndOnCommitNotAnEmptyStore` (`StoreReadabilityTests.cs:236`). Mesh: `ADirectoryAtThePathIsAnIoErrorOnReadAndWriteAndIsLeftAlone` (:149). Rust: `a_write_over_a_directory_stops_at_the_read_and_leaves_no_staging_file` RS:5726.
- **Known gaps.** TS and Python have no test for a directory at the store path. No follow-up is registered; the natural home is `write-set-vectors` with the other readability pins. - **Known gaps.** None open. The `@directory` manifest row is walked by all four runtimes: every operation including delete is refused and leaves the directory untouched; Rust pins an I/O failure rather than a typed unreadable store.
### E. Lock ### E. Lock

View file

@ -4,6 +4,8 @@ Data for the per-consumer sweep in docs/document-variants-cut.md, C2. Every obje
Method: a static scan of `F:\Projects\<consumer>` C# files for types marked `[CultDocument(`, walked through keyed members, base classes, dictionary values and `[Union]` subtypes. Read-only; no consumer was edited. Worktree copies (`Aetheria-catalog`, `CultLib-*`, and so on) are excluded. Struct elements are values and need no id. Non-C# consumers carry the id as an ordinary member and are not listed. The scan is text-based: a document registered without a literal `[CultDocument(` attribute, or a type whose members lack an integer `[Key]`, is missed, so the sweep pass should confirm each repo by registering its types once. Method: a static scan of `F:\Projects\<consumer>` C# files for types marked `[CultDocument(`, walked through keyed members, base classes, dictionary values and `[Union]` subtypes. Read-only; no consumer was edited. Worktree copies (`Aetheria-catalog`, `CultLib-*`, and so on) are excluded. Struct elements are values and need no id. Non-C# consumers carry the id as an ordinary member and are not listed. The scan is text-based: a document registered without a literal `[CultDocument(` attribute, or a type whose members lack an integer `[Key]`, is missed, so the sweep pass should confirm each repo by registering its types once.
A type that already has a readable string id keeps it and adds a trailing keyed string member marked `[CultElementId(nameof(ExistingId))]`; see the Element ids section of `src/GameCult.Caching/README.md`.
Lines: `Type file:line (via Owner.Member)`, paths relative to the consumer root. Lines: `Type file:line (via Owner.Member)`, paths relative to the consumer root.

View file

@ -25,5 +25,13 @@ closure through UPM rather than NuGet restore.
Install the Unity package from the immutable release tag: Install the Unity package from the immutable release tag:
```text ```text
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0 https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v3.0.0
``` ```
## Rolling back across the store format
A consumer that upgrades and writes a store holding an element id leaves a
`cultcache.store.v3` single-file store (`v5` directory store). Refusal by name depends on the store kind and the pin. A **directory** store (`v5`) is refused by every Unity pin from `cultlib-unity-v1.0.57` and `caching-unity-v1.2.0` on (the earliest tags of each line whose `DirectoryMessagePackBackingStore` reads only the `v4` stamp: "only ... is readable"). A **single-file** store (`v3`) is refused only from `cultlib-unity-v2.0.0` and `caching-unity-v2.0.0`: `cultlib-unity-v1.0.61` and `caching-unity-v1.4.0` read the header string and never compare it. The NuGet line (`cultlib-v1.0.32`, the last `cultlib-v` tag) refuses neither: its directory store writes `cultcache.store.v1.directory` and never checks the stamp on read. Rolling back a single-file store to any of those pins reads it silently, drops the ids, and can rewrite the elements without them. That is the dangerous case. There is
no general carry-back; restore from a copy taken before upgrading, and upgrade
readers before writers. Detail: the Store format and rollback section of
`src/GameCult.Caching/README.md`.

View file

@ -16,7 +16,7 @@ Cross-runtime parity has three layers:
Every store reader refuses, by name, a header or record slot it does not Every store reader refuses, by name, a header or record slot it does not
understand (vectors: `tests/vectors/document-variants-c0`). understand (vectors: `tests/vectors/document-variants-c0`).
Element ids: a store that can hold them declares `cultcache.store.v3`; TypeScript, Element ids: a store that can hold them declares `cultcache.store.v3`; TypeScript,
Python and Rust read it and keep the header, and readers older than ids refuse it Python and Rust read it and keep the header, and the C# runtime decides the mark from content, and these runtimes never drop it. A C# reader from `cultlib-unity-v2.0.0` on refuses a v3 single-file store it cannot read (earlier pins do not)
(vectors: `tests/vectors/document-variants-c2a`). (vectors: `tests/vectors/document-variants-c2a`).
Document variants: C# resolves them (`cultcache.store.v2`); every other runtime, Document variants: C# resolves them (`cultcache.store.v2`); every other runtime,
CultMesh single-file reads, the directory store and CultNet refuse a variant by name CultMesh single-file reads, the directory store and CultNet refuse a variant by name

View file

@ -44,7 +44,7 @@ using MessagePack;
public sealed class Item public sealed class Item
{ {
[Key(0)] [CultName] public string Name = string.Empty; [Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] [CultIndex] public string Category = string.Empty; [Key(1)] public string Category = string.Empty;
[Key(2)] public int Value; [Key(2)] public int Value;
} }
@ -94,7 +94,6 @@ const itemDocument = defineDocumentType({
schemaVersion: "gamecult.first_call_item.v1", schemaVersion: "gamecult.first_call_item.v1",
schema: z.object({ name: z.string(), category: z.string(), value: z.number().int() }), schema: z.object({ name: z.string(), category: z.string(), value: z.number().int() }),
name: "name", name: "name",
indexes: { category: "category" },
}); });
const registry = defineDocumentRegistry(itemDocument); const registry = defineDocumentRegistry(itemDocument);
@ -168,8 +167,8 @@ fn first_call() -> anyhow::Result<()> {
## Python first call ## Python first call
A document is a definition with an encoder and decoder; `name=` and `indexes=` register the name lookup A document is a definition with an encoder and decoder; `name=` registers the name lookup, `indexes=` unique lookups and
and indexes. The source is `roles=` many-row selection aliases. The source is
[`packages/cultcache-py/tests/test_first_call.py`](../../packages/cultcache-py/tests/test_first_call.py), [`packages/cultcache-py/tests/test_first_call.py`](../../packages/cultcache-py/tests/test_first_call.py),
run by `python -m unittest tests.test_first_call` in `packages/cultcache-py` with `src` on `PYTHONPATH` run by `python -m unittest tests.test_first_call` in `packages/cultcache-py` with `src` on `PYTHONPATH`
and `msgpack` installed (the store needs it). A second test fails if the block below stops matching the and `msgpack` installed (the store needs it). A second test fails if the block below stops matching the
@ -195,7 +194,6 @@ item_doc = define_document_type(
encode=lambda item: asdict(item), encode=lambda item: asdict(item),
decode=lambda raw: Item(**raw), decode=lambda raw: Item(**raw),
name="name", name="name",
indexes={"category": "category"},
) )

View file

@ -33,9 +33,9 @@ Recovered from the legacy "Database Tools" window (Aetheria `d3db1730`,
**The attribute is not `[CultIndex]`.** `[CultIndex]` means unique (operator, 2026-09-30; **The attribute is not `[CultIndex]`.** `[CultIndex]` means unique (operator, 2026-09-30;
`CultDocumentContracts.cs:34`). Grouping is `CultInspectorGroupByAttribute`, in `CultDocumentContracts.cs:34`). Grouping is `CultInspectorGroupByAttribute`, in
`CultInspectorAttributes.cs`, read only by `CultInspectorModel`. It changes no schema id, `CultInspectorAttributes.cs`, read only by `CultInspectorModel`. It changes no schema id,
catalog entry, index, or store byte. The index cut's deletion of grouping-only `[CultIndex]` catalog entry, index, or store byte. The index cut converts the grouping and selection `[CultIndex]`
declarations (CultMesh, Geometry) adds no `[CultInspectorGroupBy]` in their place: those are declarations (CultMesh, Geometry) to `[CultRole]` rather than deleting them, and adds no
service stores, not Studio-authored catalogs. `[CultInspectorGroupBy]` in their place: those are service stores, not Studio-authored catalogs.
### Invariants ### Invariants

View file

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

View file

@ -2,6 +2,10 @@
## Unreleased ## Unreleased
### Added
- `read_payload_tree(type_name, payload, decoder=None)`: the exported owner of the one-value, depth-first payload read that `decode_payload` uses, for payloads read outside a definition (`cultnet-py`'s witness bundle decode needs it, so `cultnet-py` now requires `cultcache-py>=0.4.0`). The manifest version is 0.4.0, the minor these Unreleased breaking changes imply, set in this change so the workspace install resolves the new bound; the `## Unreleased` heading becomes `## [0.4.0]` when it is tagged.
### Changed ### Changed
- `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import. - `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import.
@ -9,7 +13,18 @@
### Breaking ### Breaking
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name. - A record whose payload carries a slot past its reader's declared slots, at any depth (the document, a nested record, a list element, a union arm, a unit arm), is read, held read-only, and every write or removal of its key (`put`, `put_global`, `update`, `put_envelope`, `put_envelopes`, `delete`, `delete_global`) is refused with `SchemaConflictError` before any store is touched (`cultlib-gaps:ruling:extra-trailing-slots`). Reading dropped those slots silently and the next write shed them. `SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` and the message names the type and both counts, never a slot value. A depth-0 mark is consented, and the record writable, only when the record is stored under an id the document lists in `compatible_schema_ids` and that id's catalog entry has slots past the document's, covering the mark's slot count (`cultlib-gaps:ruling:slot-mark-consent`); a nested mark, or a mark under the document's own id, keeps the record read-only whatever is declared. Missing trailing slots keep their declared defaults. An untouched marked record keeps its stored bytes through every other write. Added `CultCache.is_writable(document, key)` (advisory; the write still decides: it also answers False for a foreign-carried key, for a key held as a type the document does not own, and for a global document at a non-global key; `delete` asks the same judges, so removing a key held as another type is refused with `SchemaConflictError` unless the document owns that record's schema id (its own or one it lists compatible), in which case the removal removes that record, its file row and its lookups, as a write of the key would replace it; it returned silently and removed nothing), `DocumentDefinition.decode_payload_marked`, and `UnreadSlots`. A document with a custom `decode` (no slot codec) never marks. A store holding a variant record is still unreadable here. Implies a minor bump while the version is 0.x (0.3.0 to 0.4.0): code that wrote back a record from a newer writer now gets `SchemaConflictError`.
- A payload is one MessagePack value: `DocumentDefinition.decode_payload` refuses bytes after it, by count, with `ValueError` `<type> payload has N bytes after its first MessagePack value (refused on read).` (never the bytes, never the depth refusal; a payload that ends early stays the decoder's), as the C# reference does (`tests/vectors/union-parity/bytes-after-outcomes.msgpack`). It raised `msgpack.ExtraData` (a `ValueError`, so only a caller naming `msgpack.ExtraData` is affected); this is a minor-version bump while pre-1.0. The refused set is unchanged. Cache loads, `get` and every definition built on the MessagePack decoder go through it; a user `payload_decoder` that raises `ExtraData` is refused the same way. Depth is judged first, so a value nested past 256 with a tail is the depth refusal, as in C#.
- A cache holds one record per key whatever its type (`variants:ruling:cache-refuses-retype`). A `put`, `put_envelope`, `put_envelopes` or `put_global` of a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store, and changes nothing; retype a key by deleting it, then putting the new type. A write may replace another registered type's record only when the written type owns its schema id (declares it or lists it compatible) and both types share one home store. A load that reads one key from two stores, whatever the types, is refused with `CultCacheError` naming the key and both types, and admits nothing. A store that holds a record no registered type claims now loads and is carried: the cache does not serve it, the store keeps its bytes through every write, `delete` or `put` at its key is refused with `SchemaConflictError`, and registering a type that declares its schema id claims it at the next load. It raised `CultCacheError` "Unknown persisted document type". Two global types share the key `__global__`, so a second global type in one cache is now refused, and a store that already holds two global types at `__global__` (a JSONL store written by caches that never pulled could produce one) no longer loads: the pull raises `CultCacheError` and admits nothing. C# refuses a second persisted global on pull the same way (`cultcache-store-composition.md`, "Globals never invented"); repair such a file by deleting one of the two records with the runtime that wrote it.
- Added `foreign_records()` and the frozen `ForeignRecord(key, schema_id, schema_name, stored_at)`, sorted by key in UTF-16 code-unit order. `stores.retyped` and `stores.written_owns` lose their underscore: the cache judge and the single-file store's commit guard ask one predicate.
- A single-file store holding two records under one key is unreadable: the v1 reader and the legacy envelope-array reader refuse it with `StoreUnreadableError` (a key is compared by its exact code points, no case folding or normalisation). A catalog slot whose canonical schema is not a string is refused the same way. A store that loaded before can now be refused: repair it with the runtime that wrote the duplicate.
- A `put` or `push_all` that would land a second type under a key the store holds is refused with `SchemaConflictError`, and the file is left as it was. Retype a key by deleting it, then putting the new type. A write replaces the record at a key only when the written type owns it: its catalog entry declares the stored schema id (its own, or one it lists compatible), so a renamed type that lists the old id compatible may replace the record, or it has the stored record's schema name. This runtime does not compare shapes, so a same-name type is never refused for dropping persisted slots as C# refuses it.
- Writes of the same type replace by key: the last record wins, in a batch as in the file.
- `indexes` are unique, as C# `[CultIndex]`: a write, load or `register_index` that leaves two records of a type holding one non-blank value under one alias is refused (`CultCacheError`) naming both keys and the alias, and writes nothing. Index text is the C# canonical text (`True`/`False`, integers as digits, floats as the exact expansion, blank values never indexed), so an empty or whitespace string is no longer a value. A new `roles` map names the many-row selection aliases (never indexed for lookup, same catalog `index_alias`). Classify every `indexes` key per the README before updating. The verdict is the contract, the text is informational.
- A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading.
- A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `push_all` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. - A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `push_all` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again.
- Added `StoreUnreadableError` (a `ValueError`) for a store file this runtime cannot read, and `SchemaConflictError` (a `ValueError`) for a - Added `StoreUnreadableError` (a `ValueError`) for a store file this runtime cannot read, and `SchemaConflictError` (a `ValueError`) for a
write the catalog cannot describe. A store is exactly three slots; a schema is never recovered from a record's payload. write the catalog cannot describe. A store is exactly three slots; a schema is never recovered from a record's payload.

View file

@ -19,7 +19,12 @@ discovery, sessions, local server) build on it, mirroring `cultcache-rs` /
`[format, schemaCatalog, records]` `[format, schemaCatalog, records]`
- records store raw MessagePack payload bytes under schema-catalog identity - records store raw MessagePack payload bytes under schema-catalog identity
- payloads decode only through the registered document definition for that type - payloads decode only through the registered document definition for that type
- unknown persisted types fail closed - a payload is exactly one MessagePack value: bytes after it are refused on read, by count
- a cache holds one record per key whatever its type: a write of a second type at a held key is refused with
`SchemaConflictError` (in memory too), and two stores holding one key refuse the load
- a store record no registered type claims loads and is carried as foreign: `cache.foreign_records()` lists it, the store
keeps its bytes through every write, a write or removal at its key is refused, and registering a type that declares its
schema id claims it at the next load
- global documents are singleton-style per type - global documents are singleton-style per type
- one home store per document type: a store added with `add_backing_store` - one home store per document type: a store added with `add_backing_store`
owns its types, otherwise the generic store is the home; a type claimed by a owns its types, otherwise the generic store is the home; a type claimed by a
@ -73,7 +78,7 @@ store.
- `register_document_type(...)` - `register_document_type(...)`
- `register_registry(...)` - `register_registry(...)`
- `register_name_lookup(...)` - `register_name_lookup(...)`
- `register_index(...)` - `register_index(...)` (a unique index)
- `add_backing_store(...)` - `add_backing_store(...)`
- `add_generic_store(...)` - `add_generic_store(...)`
- `pull_all_backing_stores()` - `pull_all_backing_stores()`
@ -93,8 +98,36 @@ store.
- `update_global(...)` - `update_global(...)`
- `delete(...)` - `delete(...)`
- `delete_global(...)` - `delete_global(...)`
- `is_writable(document, key)` (advisory: would a write or removal of the key be refused; see below)
- `snapshot()` - `snapshot()`
## Indexes And Roles
`indexes=` are unique lookups (variants:ruling:unique-indexes; C# `[CultIndex]`): within one document type and one alias
no two records hold one non-blank value, judged on the result of every write (a batch is judged whole, so a swap inside
one `put_envelopes` lands) and of every load and `register_index`. A refusal is a `CultCacheError`,
`Records <k1> and <k2> (<schema name>) both hold unique index '<alias>'; an index value names one record.`, keys in
ordinal order, never the value; it writes nothing, and a refused load or registration installs nothing. The contract is
the verdict; the text is informational. Blank values (absent, empty, or only C# `White_Space` characters) are never
indexed and never conflict. Two types may share an alias and a value. Values compare as the C# canonical text: strings as
is (never trimmed), `True`/`False`, integers as digits, a non-integral float as its exact decimal expansion, NaN and
infinities no value.
`roles=` name selection aliases (C# `[CultRole]`): many records may share a value, nothing is indexed or refused,
`get_by_index` on a role answers `None`, and the member's catalog `index_alias` is the same either way, so catalog bytes
do not move. An alias is declared in one of the two. `CultMeshDatabase.watch_by_index` reads `indexes` only.
Migrating an existing `indexes` key, per key, before bumping:
1. Nothing looks records up by the key (`get_by_index`, `get_key_by_index`, `node.get_by_index`, `watch_by_index`): move it to `roles`.
2. Something looks it up: it stays unique only when two live records can never hold one non-blank value (the record key
is derived from that value alone, or it is an outside identity: email, sku, external id). Enums, statuses, kinds,
categories, parent or foreign ids, shared hashes and timestamps are roles; select them with `get_all` plus a filter.
3. Open each real `.cc` once with the new definitions. A refusal names both keys and the alias and writes nothing.
Misclassifying: a role declared unique makes a store that holds a shared value refuse to open until the key is
reclassified; a unique key declared a role answers no lookups and stops refusing duplicates.
## Persistence Model ## Persistence Model
Backing stores persist envelopes, not caller domain objects. The cache decodes payloads through registered document definitions and rejects unknown type discriminators. This keeps Python's dynamic runtime from turning persistence into an open polymorphic sewer with a cheerful docstring. Backing stores persist envelopes, not caller domain objects. The cache decodes payloads through registered document definitions and rejects unknown type discriminators. This keeps Python's dynamic runtime from turning persistence into an open polymorphic sewer with a cheerful docstring.
@ -112,6 +145,10 @@ used by the TypeScript, Rust, and C# runtimes:
Large corpora should use a sharded store or a real database. A single snapshot file is a scalpel, not a forklift. Large corpora should use a sharded store or a real database. A single snapshot file is a scalpel, not a forklift.
## Store format and rollback
A store whose elements hold an id is marked `cultcache.store.v3` by the C# runtime. This runtime reads and writes a v3 single-file store, keeps the header it read and carries the ids through a rewrite; it never drops the mark, so it has no carry-back. A cultcache-py from before element ids refuses a v3 store by name, so rolling back to it after a store has been written as v3 is one-way: restore from a copy taken before upgrading, and upgrade every reader before any writer. (The C# line is different: CultLib 1.x does not refuse; see the GameCult.Caching README.)
## DatabaseEntry Slot Contract ## DatabaseEntry Slot Contract
For cross-runtime cache entries, use `define_database_entry_type(...)` instead For cross-runtime cache entries, use `define_database_entry_type(...)` instead
@ -145,6 +182,16 @@ The payload is a MessagePack array. Field keys are durable slot indexes:
- newly added fields should use new keys - newly added fields should use new keys
- fields with defaults tolerate missing or nil slots when older payloads are read - fields with defaults tolerate missing or nil slots when older payloads are read
A payload carrying a slot past the slots a reader declares, at any depth (the document, a nested record, a list element, a
union arm, a unit arm), was written by a newer build. It is read, and the record is held read-only: `put`, `update`,
`put_envelope(s)`, `delete` and their global forms refuse its key with `SchemaConflictError` before any store is touched, and
`SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` naming the
first type read with extra slots (never a slot value). `CultCache.is_writable(document, key)` answers the same question before
the write: False for such a record, for a key a store carries foreign, and for a key held as another type's record; the write
still decides. `DocumentDefinition.decode_payload_marked(payload)` returns the value and its `UnreadSlots` (or None). Declaring the
stored id in `compatible_schema_ids` consents to the loss of depth-0 slots that id's catalog entry reports; a nested mark never is.
A store keeps a read-only record's bytes through every other write. A document with a custom `decode` never marks.
That matches the Rust `#[derive(DatabaseEntry)]` formatter shape and the C# That matches the Rust `#[derive(DatabaseEntry)]` formatter shape and the C#
`[Key(n)]` intent: schema evolution comes from stable field slots, not source `[Key(n)]` intent: schema evolution comes from stable field slots, not source
member order. member order.

View file

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

View file

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

View file

@ -1,13 +1,15 @@
from __future__ import annotations from __future__ import annotations
import functools import functools
import math
import threading import threading
from decimal import Decimal
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
from typing import Any, Callable, Generic, TypeVar from typing import Any, Callable, Generic, Iterable, TypeVar
from .backing_store import BackingStore, CultCacheEnvelope from .backing_store import BackingStore, CultCacheEnvelope
from .documents import DocumentDefinition, extract_value from .documents import DocumentDefinition, UnreadSlots, extract_value
from .stores import SchemaConflictError from .stores import SchemaConflictError, retyped, written_owns
T = TypeVar("T") T = TypeVar("T")
F = TypeVar("F", bound=Callable[..., Any]) F = TypeVar("F", bound=Callable[..., Any])
@ -52,6 +54,52 @@ class CultCacheError(RuntimeError):
pass pass
# Blank is no value: empty or only C# White_Space characters (the 25 chars string.IsNullOrWhiteSpace is true for).
# An explicit set, not str.isspace(): Python also counts U+001C-001F, which C# does not, and omits nothing C# counts.
_BLANK_INDEX_CHARS = frozenset(
chr(code)
for code in (
*range(0x09, 0x0E),
0x20,
0x85,
0xA0,
0x1680,
*range(0x2000, 0x200B),
0x2028,
0x2029,
0x202F,
0x205F,
0x3000,
)
)
def _utf16_order(text: str) -> bytes:
"""Sorts as C# StringComparer.Ordinal does: by UTF-16 code unit, not code point."""
return text.encode("utf-16-be", "surrogatepass")
def index_text(value: Any) -> str | None:
"""The canonical text of an index value, as C# IndexValue and RenderCanonicalNumber, or None when it names no
value: a string is as is (never trimmed), a boolean is `True`/`False`, an integer is its digits, a non-integral
finite float is its exact decimal expansion, NaN and infinities and blank strings are no value."""
if value is None:
return None
if isinstance(value, bool): # before int: bool is an int subclass
return "True" if value else "False"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
if not math.isfinite(value):
return None
if value.is_integer():
return str(int(value)) # -0.0 is 0
text = format(Decimal(value), "f") # Decimal(float) is exact
return text.rstrip("0").rstrip(".") if "." in text else text
text = value if isinstance(value, str) else str(value)
return None if all(char in _BLANK_INDEX_CHARS for char in text) else text
@dataclass @dataclass
class _State: class _State:
documents: dict[str, DocumentDefinition[Any]] = field(default_factory=dict) documents: dict[str, DocumentDefinition[Any]] = field(default_factory=dict)
@ -72,6 +120,24 @@ class _State:
# of that global removes it from the home store. Remove once no store holds a global under a key # of that global removes it from the home store. Remove once no store holds a global under a key
# other than GLOBAL_KEY. # other than GLOBAL_KEY.
legacy_global_keys: dict[str, str] = field(default_factory=dict) legacy_global_keys: dict[str, str] = field(default_factory=dict)
# The records the last pull read that no registered type claims, sorted by key. Derived from the pull and swapped with the
# rest of the state, written nowhere else; it decides only refusals at its keys. A store keeps their bytes.
foreign: list["ForeignRecord"] = field(default_factory=list)
# The records held read-only, by key (a key holds one record) with the type that holds it: the payload carried slots past the
# declared ones and nothing consents to the loss. Derived from the load or the write that admitted the record and swapped with
# the rest of the state; it decides only refusals at its keys, and any other key is absent.
unread: dict[str, tuple[str, UnreadSlots]] = field(default_factory=dict)
@dataclass(frozen=True)
class ForeignRecord:
"""A record a store holds that no registered type claims: the cache does not serve it, refuses a write or a removal at its
key, and a store keeps its bytes. Registering a type that declares its schema id claims it at the next load."""
key: str
schema_id: str
schema_name: str
stored_at: str
@dataclass(frozen=True) @dataclass(frozen=True)
@ -186,6 +252,8 @@ class CultCache:
@_mutating @_mutating
def register_index(self, document: DocumentDefinition[Any], index: str, extractor: str | Any) -> None: def register_index(self, document: DocumentDefinition[Any], index: str, extractor: str | Any) -> None:
self._assert_registered(document) self._assert_registered(document)
if index in document.roles:
raise CultCacheError(f"Index '{index}' of {document.type} is declared as a role; a role is never a unique index.")
index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()} index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()}
index_extractors.setdefault(document.type, {})[index] = extractor index_extractors.setdefault(document.type, {})[index] = extractor
self._install_lookups(self._state.name_extractors, index_extractors) self._install_lookups(self._state.name_extractors, index_extractors)
@ -195,7 +263,11 @@ class CultCache:
) -> None: ) -> None:
"""Derives every lookup under the candidate extractors, then installs extractors and lookups """Derives every lookup under the candidate extractors, then installs extractors and lookups
together. An extractor that raises on a held value installs nothing.""" together. An extractor that raises on a held value installs nothing."""
names, indexes, lookup_keys = self._derive_lookups(self._state.values, name_extractors, index_extractors) names, indexes, lookup_keys = self._derive_lookups(
[(type, key, value) for type, values in self._state.values.items() for key, value in values.items()],
name_extractors,
index_extractors,
)
( (
self._state.name_extractors, self._state.name_extractors,
self._state.index_extractors, self._state.index_extractors,
@ -263,19 +335,29 @@ class CultCache:
structures, running every check and every user extractor; the cache's state is replaced structures, running every check and every user extractor; the cache's state is replaced
only once that build finishes, so a refused load admits nothing.""" only once that build finishes, so a refused load admits nothing."""
loaded_values: dict[str, dict[str, Any]] = {} loaded_values: dict[str, dict[str, Any]] = {}
read_values: dict[tuple[str, str], Any] = {} # (type, key) -> value, one per key, the last read wins; the judge is order-free
loaded_envelopes: dict[str, dict[str, CultCacheEnvelope]] = {} loaded_envelopes: dict[str, dict[str, CultCacheEnvelope]] = {}
loaded_legacy_global_keys: dict[str, str] = {} loaded_legacy_global_keys: dict[str, str] = {}
routes, generic = self._state.stores_by_type, self._state.generic_store routes, generic = self._state.stores_by_type, self._state.generic_store
seen_globals: set[str] = set() seen_globals: set[str] = set()
foreign: list[ForeignRecord] = []
loaded_unread: dict[str, tuple[str, UnreadSlots]] = {}
type_at_key: dict[str, tuple[str, int]] = {} # one record per key across every store: its type (or schema name) and store
stores: list[BackingStore] = [] stores: list[BackingStore] = []
for candidate in [*self._state.stores_by_type.values(), self._state.generic_store]: for candidate in [*self._state.stores_by_type.values(), self._state.generic_store]:
if candidate is not None and all(candidate is not store for store in stores): if candidate is not None and all(candidate is not store for store in stores):
stores.append(candidate) stores.append(candidate)
for store in stores: for position, store in enumerate(stores):
for envelope in store.pull_all(): for envelope in store.pull_all():
document = self._resolve_document_for_envelope(envelope) document = self._resolve_document_for_envelope(envelope)
if document is None: if document is None:
raise CultCacheError(f"Unknown persisted document type: {envelope.type}") entry = envelope.catalog_entry
held_name = entry.schema_name if entry is not None else envelope.type
self._claim_key(type_at_key, envelope.key, held_name, position)
foreign.append(
ForeignRecord(envelope.key, envelope.schema_id or envelope.type, held_name, envelope.stored_at)
)
continue
if envelope.type != document.type: if envelope.type != document.type:
envelope = CultCacheEnvelope( envelope = CultCacheEnvelope(
key=envelope.key, key=envelope.key,
@ -298,12 +380,16 @@ class CultCache:
if envelope.key != GLOBAL_KEY: if envelope.key != GLOBAL_KEY:
loaded_legacy_global_keys[envelope.type] = envelope.key loaded_legacy_global_keys[envelope.type] = envelope.key
envelope = replace(envelope, key=GLOBAL_KEY) envelope = replace(envelope, key=GLOBAL_KEY)
envelope = self._stamped(document, envelope) self._claim_key(type_at_key, envelope.key, envelope.type, position)
value = document.decode_payload(envelope.payload) arrived, envelope = envelope, self._stamped(document, envelope)
value, unread = self._decode_marked(document, arrived)
if unread is not None:
loaded_unread[envelope.key] = (envelope.type, unread)
loaded_values.setdefault(envelope.type, {})[envelope.key] = value loaded_values.setdefault(envelope.type, {})[envelope.key] = value
read_values[(envelope.type, envelope.key)] = value
loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope
loaded_names, loaded_indexes, loaded_lookup_keys = self._derive_lookups( loaded_names, loaded_indexes, loaded_lookup_keys = self._derive_lookups(
loaded_values, self._state.name_extractors, self._state.index_extractors [(type, key, value) for (type, key), value in read_values.items()], self._state.name_extractors, self._state.index_extractors
) )
# Plain attribute assignments: nothing fallible runs once the swap begins. # Plain attribute assignments: nothing fallible runs once the swap begins.
( (
@ -313,7 +399,30 @@ class CultCache:
self._state.indexes, self._state.indexes,
self._state.lookup_keys, self._state.lookup_keys,
self._state.legacy_global_keys, self._state.legacy_global_keys,
) = (loaded_values, loaded_envelopes, loaded_names, loaded_indexes, loaded_lookup_keys, loaded_legacy_global_keys) self._state.foreign,
self._state.unread,
) = (
loaded_values,
loaded_envelopes,
loaded_names,
loaded_indexes,
loaded_lookup_keys,
loaded_legacy_global_keys,
sorted(foreign, key=lambda record: _utf16_order(record.key)),
loaded_unread,
)
@staticmethod
def _claim_key(type_at_key: dict[str, tuple[str, int]], key: str, type: str, store: int) -> None:
"""A key holds one record whatever its type, across every store a load reads. The refusal names the key and both types,
never a payload."""
held, held_store = type_at_key.setdefault(key, (type, store))
if held != type or held_store != store:
raise CultCacheError(f"Record {key!r} is held by both {held} and {type}: a key holds one record; the load admitted nothing.")
def foreign_records(self) -> list[ForeignRecord]:
"""The records the last load read that no registered type claims, sorted by key (UTF-16 code-unit order)."""
return list(self._state.foreign)
def get(self, document: DocumentDefinition[T], key: str) -> T | None: def get(self, document: DocumentDefinition[T], key: str) -> T | None:
self._assert_registered(document) self._assert_registered(document)
@ -391,9 +500,9 @@ class CultCache:
def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T: def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T:
self._assert_registered(document) self._assert_registered(document)
self._check_envelope(document, envelope) self._check_envelope(document, envelope)
envelope = self._stamped(document, envelope) arrived, envelope = envelope, self._stamped(document, envelope)
value = document.decode_payload(envelope.payload) value, unread = self._decode_marked(document, envelope)
self._write(document, [(envelope, value)], batch=False) self._write(document, [(envelope, value)], batch=False, unread={envelope.key: unread})
return value return value
@_mutating @_mutating
@ -401,13 +510,33 @@ class CultCache:
self._assert_registered(document) self._assert_registered(document)
values: list[T] = [] values: list[T] = []
stamped: list[CultCacheEnvelope] = [] stamped: list[CultCacheEnvelope] = []
unread: dict[str, UnreadSlots | None] = {}
for envelope in envelopes: for envelope in envelopes:
self._check_envelope(document, envelope) self._check_envelope(document, envelope)
stamped.append(self._stamped(document, envelope)) stamped.append(self._stamped(document, envelope))
values.append(document.decode_payload(envelope.payload)) value, unread[envelope.key] = self._decode_marked(document, stamped[-1])
self._write(document, list(zip(stamped, values)), batch=True) values.append(value)
self._write(document, list(zip(stamped, values)), batch=True, unread=unread)
return values return values
@staticmethod
def _decode_marked(document: DocumentDefinition[Any], held_as: CultCacheEnvelope) -> tuple[Any, UnreadSlots | None]:
"""The payload decoded under the document, with the mark of any slot it carried past the declared ones, or no mark when the
loss is consented. `held_as` is the envelope under the id the record is stored under: a load's arrival, a write's stamped
envelope, so a key is writable in memory exactly when it is after a reload. A depth-0 mark is consented only when that id is
one the document lists compatible and its catalog entry has slots past the document's that cover the mark's slot count (the
loss a compatible-id declaration consents to, cultlib-gaps:ruling:slot-mark-consent). Any other mark keeps the record read-only."""
value, slots = document.decode_payload_marked(held_as.payload)
if slots is None:
return value, None
entry, stored = document.catalog_entry(), held_as.catalog_entry
if slots.depth0_only and stored is not None and held_as.schema_id in entry.compatible_schema_ids and held_as.schema_id != entry.schema_id:
declared = {member.slot for member in entry.members}
reported = [member.slot for member in stored.members if member.slot not in declared]
if reported and slots.slot_count <= max(reported) + 1:
return value, None
return value, slots
@staticmethod @staticmethod
def _stamped(document: DocumentDefinition[Any], envelope: CultCacheEnvelope) -> CultCacheEnvelope: def _stamped(document: DocumentDefinition[Any], envelope: CultCacheEnvelope) -> CultCacheEnvelope:
"""The record under its registered schema's id, whatever id the envelope arrived under, so the catalog entry a store """The record under its registered schema's id, whatever id the envelope arrived under, so the catalog entry a store
@ -427,17 +556,32 @@ class CultCache:
raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}") raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
def _write( def _write(
self, document: DocumentDefinition[Any], records: list[tuple[CultCacheEnvelope, Any]], *, batch: bool self,
document: DocumentDefinition[Any],
records: list[tuple[CultCacheEnvelope, Any]],
*,
batch: bool,
unread: dict[str, UnreadSlots | None] | None = None,
) -> None: ) -> None:
"""Validates every record (home and name and index extractors included) before the store is """Validates every record (home and name and index extractors included) before the store is
touched; once the store accepts, applying to the cache has no fallible step.""" touched; once the store accepts, applying to the cache has no fallible step."""
type = document.type type = document.type
# A batch naming a key twice lands the last record for it, as C# CultCacheBatch stages one operation per key. The
# judge, the store and the apply all read this one list.
records = list({envelope.key: (envelope, value) for envelope, value in records}.values())
self._refuse_unread([envelope.key for envelope, _ in records])
name_extractor = self._state.name_extractors.get(type) name_extractor = self._state.name_extractors.get(type)
index_extractors = self._state.index_extractors.get(type, {}) index_extractors = self._state.index_extractors.get(type, {})
admitted = [ admitted = [
(envelope, value, self._lookup_keys_for(value, name_extractor, index_extractors)) (envelope, value, self._lookup_keys_for(value, name_extractor, index_extractors))
for envelope, value in records for envelope, value in records
] ]
displaced = self._refuse_retype(document, [envelope for envelope, _ in records])
self._refuse_index_sharing(
[(type, envelope.key, keys) for envelope, _, keys in admitted],
self._state.indexes,
{(type, envelope.key) for envelope, _ in records},
)
store = self._store_for_type(type) store = self._store_for_type(type)
legacy_key = self._state.legacy_global_keys.get(type) legacy_key = self._state.legacy_global_keys.get(type)
if store is not None: if store is not None:
@ -451,16 +595,99 @@ class CultCache:
values = self._state.values.setdefault(type, {}) values = self._state.values.setdefault(type, {})
envelopes = self._state.envelopes.setdefault(type, {}) envelopes = self._state.envelopes.setdefault(type, {})
lookup_keys = self._state.lookup_keys.setdefault(type, {}) lookup_keys = self._state.lookup_keys.setdefault(type, {})
for other, key in displaced:
self._drop(other, key)
for envelope, value, keys in admitted: for envelope, value, keys in admitted:
self._remove_lookups(type, envelope.key) self._remove_lookups(type, envelope.key)
values[envelope.key] = value values[envelope.key] = value
envelopes[envelope.key] = envelope envelopes[envelope.key] = envelope
# A held mark refused this write above, so a key reaching here carries none to clear.
if (mark := (unread or {}).get(envelope.key)) is not None:
self._state.unread[envelope.key] = (type, mark)
lookup_keys[envelope.key] = keys lookup_keys[envelope.key] = keys
if keys.name is not None: if keys.name is not None:
self._state.names[(type, keys.name)] = envelope.key self._state.names[(type, keys.name)] = envelope.key
for index, index_value in keys.indexes.items(): for index, index_value in keys.indexes.items():
self._state.indexes[(type, index, index_value)] = envelope.key self._state.indexes[(type, index, index_value)] = envelope.key
def _refuse_retype(self, document: DocumentDefinition[Any], written: list[CultCacheEnvelope]) -> list[tuple[str, str]]:
"""The one key-identity judge (variants:ruling:cache-refuses-retype): a key holds one record whatever its type, so a write
may not land at a key this cache holds another type's record at, unless the written type owns that record's schema id
(the single-file store's commit guard asks the same question of the file), and may not land at a key a store carries
foreign. Returns the (type, key) records an owning write replaces; refuses, changing nothing, otherwise."""
replaced: list[tuple[str, str]] = []
foreign = {record.key: record for record in self._state.foreign}
for envelope in written:
carried = foreign.get(envelope.key)
if carried is not None:
raise self._foreign_refusal(carried)
for other, held_envelopes in self._state.envelopes.items():
held = held_envelopes.get(envelope.key)
if other == document.type or held is None:
continue
if not written_owns(held, envelope) or self._store_for_type(other) is not self._store_for_type(document.type):
raise retyped(held, envelope)
replaced.append((other, envelope.key))
return replaced
@_locked
def is_writable(self, document: DocumentDefinition[Any], key: str) -> bool:
"""Whether a write or removal of the key by this document would pass the judges that run before any store is touched: False for
a record held read-only (its payload carried slots past the declared ones, at any depth, and nothing consents to the loss, so
writing it would drop data a newer writer stored), for a key a store carries foreign, and for a key held as another type's
record this document does not own. Advisory (the write still decides): it answers at call time, and a key nothing holds is
writable."""
self._assert_registered(document)
try:
self._refuse_at(document, key)
except (SchemaConflictError, CultCacheError):
return False
return True
def _refuse_at(self, document: DocumentDefinition[Any], key: str) -> list[tuple[str, str]]:
"""The judges a write or removal of the key by this document passes before any store is touched, asked by `is_writable` and
by `delete`: a global document writes only its global key, a record held read-only is never replaced, and a key held as
another type's record or carried foreign is refused (`_refuse_retype`)."""
if document.global_document and key != GLOBAL_KEY:
raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
self._refuse_unread([key])
entry = document.catalog_entry()
probe = CultCacheEnvelope.create(key=key, type=document.type, payload=b"", schema_id=entry.schema_id, catalog_entry=entry)
return self._refuse_retype(document, [probe])
def _refuse_unread(self, keys: list[str]) -> None:
"""The one check every write and removal asks before any store is touched."""
for key in keys:
held = self._state.unread.get(key)
if held is None:
continue
type, slots = held
raise SchemaConflictError(
self._state.envelopes[type][key].schema_id or type,
[self._state.documents[type].catalog_entry().schema_name],
key,
message=(
f"{self._state.documents[type].catalog_entry().schema_name} record {key} is read-only: {slots.type_name} was read with "
f"{slots.slot_count} slots where this build declares {slots.declared_count} ({slots.occurrences} object(s) in the "
"record). Writing it would drop data a newer writer stored; read it with types that declare those slots, or "
"write it from that build."
),
unread_slots=slots,
)
@staticmethod
def _foreign_refusal(record: ForeignRecord) -> SchemaConflictError:
return SchemaConflictError(
record.schema_id,
[record.schema_name],
record.key,
message=(
f"Record {record.key!r} is held by the store as a {record.schema_name} (schema id {record.schema_id!r}) that no "
"registered type claims; a write or removal there would replace what this cache cannot read. Register the type "
"that owns the schema id, or remove the record with the runtime that wrote it."
),
)
@staticmethod @staticmethod
def _retire_legacy_global(store: BackingStore, type: str, legacy_key: str) -> None: def _retire_legacy_global(store: BackingStore, type: str, legacy_key: str) -> None:
"""The first write of a global loaded from a legacy key removes that record in the same write. If """The first write of a global loaded from a legacy key removes that record in the same write. If
@ -500,19 +727,29 @@ class CultCache:
def _delete(self, document: DocumentDefinition[Any], key: str) -> None: def _delete(self, document: DocumentDefinition[Any], key: str) -> None:
self._assert_registered(document) self._assert_registered(document)
# A key held as another type's record this document owns is removed, as a write of the key would replace it (C# Remove is by key).
owned = self._refuse_at(document, key)
store = self._store_for_type(document.type) store = self._store_for_type(document.type)
legacy_key = self._state.legacy_global_keys.get(document.type) if key == GLOBAL_KEY else None legacy_key = self._state.legacy_global_keys.get(document.type) if key == GLOBAL_KEY else None
if store is not None: if store is not None:
store.delete(document.type, key if legacy_key is None else legacy_key) # One record per key: the owned record when this document does not hold the key, else its own, in one store call.
holder = owned[0][0] if owned else document.type
store.delete(holder, key if legacy_key is None else legacy_key)
if legacy_key is not None: if legacy_key is not None:
del self._state.legacy_global_keys[document.type] del self._state.legacy_global_keys[document.type]
self._remove_lookups(document.type, key) for other, _ in owned:
self._drop(other, key)
self._drop(document.type, key)
def _drop(self, type: str, key: str) -> None:
"""Removes the held record and its lookups from the cache's maps; the store is the caller's."""
self._remove_lookups(type, key)
for by_type in (self._state.values, self._state.envelopes, self._state.lookup_keys): for by_type in (self._state.values, self._state.envelopes, self._state.lookup_keys):
held = by_type.get(document.type) held = by_type.get(type)
if held is not None: if held is not None:
held.pop(key, None) held.pop(key, None)
if not held: if not held:
by_type.pop(document.type, None) by_type.pop(type, None)
@_mutating @_mutating
def delete_global(self, document: DocumentDefinition[Any]) -> None: def delete_global(self, document: DocumentDefinition[Any]) -> None:
@ -552,36 +789,76 @@ class CultCache:
name = None if name_extractor is None else extract_value(value, name_extractor) name = None if name_extractor is None else extract_value(value, name_extractor)
indexes: dict[str, str] = {} indexes: dict[str, str] = {}
for index, extractor in index_extractors.items(): for index, extractor in index_extractors.items():
index_value = extract_value(value, extractor) index_value = index_text(extract_value(value, extractor))
if index_value is not None: if index_value is not None:
indexes[index] = str(index_value) indexes[index] = index_value
return _LookupKeys(None if name is None else str(name), indexes) return _LookupKeys(None if name is None else str(name), indexes)
def _derive_lookups( def _derive_lookups(
self, self,
values_by_type: dict[str, dict[str, Any]], records: list[tuple[str, str, Any]],
name_extractors: dict[str, str | Any], name_extractors: dict[str, str | Any],
index_extractors: dict[str, dict[str, str | Any]], index_extractors: dict[str, dict[str, str | Any]],
) -> tuple[_Names, _Indexes, _LookupKeysByType]: ) -> tuple[_Names, _Indexes, _LookupKeysByType]:
"""Builds name and index lookups for the given values in fresh dicts, touching no cache state.""" """Builds name and index lookups for the given (type, key, value) records, judged whole and order-free, in fresh dicts, touching no cache state."""
names: _Names = {} names: _Names = {}
indexes: _Indexes = {} indexes: _Indexes = {}
lookup_keys: _LookupKeysByType = {} lookup_keys: _LookupKeysByType = {}
for type, values in values_by_type.items(): landing: list[tuple[str, str, _LookupKeys]] = []
name_extractor = name_extractors.get(type) for type, key, value in records:
type_index_extractors = index_extractors.get(type, {}) keys = self._lookup_keys_for(value, name_extractors.get(type), index_extractors.get(type, {}))
for key, value in values.items(): lookup_keys.setdefault(type, {})[key] = keys
keys = self._lookup_keys_for(value, name_extractor, type_index_extractors) landing.append((type, key, keys))
lookup_keys.setdefault(type, {})[key] = keys # Judged whole and order-free, over every type at once and on the file alone (never against what the cache held): a held value can
# have been mutated in place since it was admitted, and another process may have moved a value between records.
self._refuse_index_sharing(landing, {}, ())
for type in lookup_keys:
for key, keys in lookup_keys.get(type, {}).items():
if keys.name is not None: if keys.name is not None:
names[(type, keys.name)] = key names[(type, keys.name)] = key
for index, index_value in keys.indexes.items(): for index, index_value in keys.indexes.items():
indexes[(type, index, index_value)] = key indexes[(type, index, index_value)] = key
return names, indexes, lookup_keys return names, indexes, lookup_keys
def _refuse_index_sharing(
self,
landing: list[tuple[str, str, _LookupKeys]],
held: dict[tuple[str, str, str], str],
replaced: Iterable[tuple[str, str]],
) -> None:
"""The one index rule (variants:ruling:unique-indexes): within one type and one unique alias, no two records hold one
non-blank value, judged on the state the admission would leave and never on staging, file or pull order. The holders
of a value are the landing keys projecting it plus the held key unless this admission replaces it. Of every
(type, alias, value) with two or more distinct holders, the refusal names the least by (alias, its least key, its
second-least key, schema name), each compared by UTF-16 code unit, never the value."""
released = set(replaced)
landed: dict[tuple[str, str, str], list[str]] = {}
for type, key, keys in landing:
for index, index_value in keys.indexes.items():
landed.setdefault((type, index, index_value), []).append(key)
violations: list[tuple[bytes, bytes, bytes, bytes, str, str, str, str]] = []
for (type, index, index_value), keys in landed.items():
held_key = held.get((type, index, index_value))
if held_key is not None and (type, held_key) not in released:
keys = [*keys, held_key]
distinct = sorted(keys, key=_utf16_order)
if len(distinct) > 1:
document = self._state.documents.get(type)
schema_name = (document.schema_name if document is not None else None) or type
violations.append(
(_utf16_order(index), _utf16_order(distinct[0]), _utf16_order(distinct[1]), _utf16_order(schema_name),
index, distinct[0], distinct[1], schema_name)
)
if violations:
*_, index, first, second, schema_name = min(violations, key=lambda violation: violation[:4])
raise CultCacheError(
f"Records {first} and {second} ({schema_name}) both hold unique index '{index}'; "
"an index value names one record."
)
def _remove_lookups(self, type: str, key: str) -> None: def _remove_lookups(self, type: str, key: str) -> None:
"""Removes the lookups the held record produced at admission, handing a name or index value to """Removes the lookups the held record produced at admission, handing a name to another held
another held record that produced it too. Reads stored lookup keys only; runs no extractor.""" record that produced it too (an index value has one holder). Reads stored lookup keys only; runs no extractor."""
held = self._state.lookup_keys.get(type, {}) held = self._state.lookup_keys.get(type, {})
keys = held.get(key) keys = held.get(key)
if keys is None: if keys is None:
@ -596,9 +873,6 @@ class CultCache:
if self._state.indexes.get(lookup_key) != key: if self._state.indexes.get(lookup_key) != key:
continue continue
del self._state.indexes[lookup_key] del self._state.indexes[lookup_key]
for candidate_key, candidate in held.items():
if candidate_key != key and candidate.indexes.get(index) == index_value:
self._state.indexes[lookup_key] = candidate_key
def _assert_registered(self, document: DocumentDefinition[Any]) -> None: def _assert_registered(self, document: DocumentDefinition[Any]) -> None:
if self._state.documents.get(document.type) is not document: if self._state.documents.get(document.type) is not document:

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import threading
from dataclasses import MISSING, dataclass, field from dataclasses import MISSING, dataclass, field
from collections.abc import Mapping, Sequence, Set from collections.abc import Mapping, Sequence, Set
from itertools import chain from itertools import chain
@ -30,6 +31,49 @@ MAX_SLOT_KEY = 65535
_STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,) _STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,)
@dataclass(frozen=True)
class UnreadSlots:
"""What a payload decode found past the slots this runtime declares, at any depth: the first type read with extra slots, how
many slots it carried against how many it declares, and how many objects in the payload did. It holds no slot value, so a
refusal can name it. `depth0_only` is True when every mark sat in the document's own record (the half of consent the payload
decides). A record carrying it is read-only unless the schema comparison explains every mark (see `CultCache.is_writable`)."""
type_name: str
slot_count: int
declared_count: int
occurrences: int
depth0_only: bool
class _SlotScope:
"""Opened by `DocumentDefinition.decode_payload_marked`; the record codec and the union codec note into it. It is thread-local,
so no codec signature carries it, and a codec decoded outside a payload decode notes nothing."""
def __init__(self) -> None:
self.depth = 0 # records entered: 0 while the document's own record is read
self.first: tuple[str, int, int] | None = None
self.occurrences = 0
self.depth0_only = True
def note(self, type_name: str, slot_count: int, declared_count: int, depth0: bool) -> None:
if self.first is None:
self.first = (type_name, slot_count, declared_count)
self.occurrences += 1
self.depth0_only = self.depth0_only and depth0
def result(self) -> UnreadSlots | None:
if self.first is None:
return None
return UnreadSlots(*self.first, self.occurrences, self.depth0_only)
_scope = threading.local()
def _current_scope() -> _SlotScope | None:
return getattr(_scope, "open", None)
_CONTAINERS = (Mapping, Sequence, Set) _CONTAINERS = (Mapping, Sequence, Set)
_SCALAR_SEQUENCES = (str, bytes, bytearray, memoryview) _SCALAR_SEQUENCES = (str, bytes, bytearray, memoryview)
@ -114,6 +158,26 @@ def _messagepack_payload_decoder(payload: bytes) -> Any:
return msgpack.unpackb(payload, raw=False) return msgpack.unpackb(payload, raw=False)
def read_payload_tree(type_name: str, payload: bytes, decoder: Callable[[bytes], Any] | None = None) -> Any:
"""The one read of a payload's tree: exactly one MessagePack value, within the nesting limit. Depth is judged first,
on the value read so far, so a tail never pre-empts it (C# throws at container 257 before it reaches the tail); then a
tail is refused by count and never echoed. Raised outside the handler so no `__context__` keeps the tail."""
after = -1
unpacked: Any = None
try:
tree = (decoder or _messagepack_payload_decoder)(payload)
except _STACK_FAULTS:
raise CultCachePayloadDepthError(type_name, "read") from None
except msgpack.ExtraData as error:
after = len(error.extra)
unpacked = error.unpacked
if after >= 0:
_refuse_past_depth_limit(unpacked, type_name, "read")
raise ValueError(f"{type_name} payload has {after} bytes after its first MessagePack value (refused on read).")
_refuse_past_depth_limit(tree, type_name, "read")
return tree
@dataclass(frozen=True) @dataclass(frozen=True)
class DatabaseEntryField: class DatabaseEntryField:
name: str name: str
@ -157,6 +221,11 @@ class DocumentDefinition(Generic[T]):
global_document: bool = False global_document: bool = False
name: str | Extractor[T] | None = None name: str | Extractor[T] | None = None
indexes: dict[str, str | Extractor[T]] = field(default_factory=dict) indexes: dict[str, str | Extractor[T]] = field(default_factory=dict)
"""Unique lookups (C# `[CultIndex]`): no two records of the type hold one non-blank value, judged at every commit and
load. `get_by_index` answers the one holder."""
roles: dict[str, str | Extractor[T]] = field(default_factory=dict)
"""Selection aliases (C# `[CultRole]`): many records may share a value. Never indexed for lookup, never refuse; the
member's catalog `index_alias` is the same either way."""
payload_encoder: Callable[[Any], bytes] = _json_payload_encoder payload_encoder: Callable[[Any], bytes] = _json_payload_encoder
payload_decoder: Callable[[bytes], Any] = _json_payload_decoder payload_decoder: Callable[[bytes], Any] = _json_payload_decoder
schema_id: str | None = None schema_id: str | None = None
@ -180,15 +249,29 @@ class DocumentDefinition(Generic[T]):
return self.payload_encoder(tree) return self.payload_encoder(tree)
def decode_payload(self, payload: bytes) -> T: def decode_payload(self, payload: bytes) -> T:
return self.decode_payload_marked(payload)[0]
def decode_payload_marked(self, payload: bytes) -> tuple[T, UnreadSlots | None]:
"""The value and what the decode found past the declared slots, at any depth. The payload depth check runs first: a
payload past the limit is refused, never marked."""
tree = read_payload_tree(self.type, payload, self.payload_decoder)
outer, opened = _current_scope(), _SlotScope()
_scope.open = opened
try: try:
tree = self.payload_decoder(payload) value = self.decode(tree)
except _STACK_FAULTS: finally:
raise CultCachePayloadDepthError(self.type, "read") from None _scope.open = outer
_refuse_past_depth_limit(tree, self.type, "read")
value = self.decode(tree)
if self.validate: if self.validate:
self.validate(value) self.validate(value)
return value return value, opened.result()
def __post_init__(self) -> None:
# Judged here so every constructor passes it: the factories, the dataclass constructor and dataclasses.replace.
for alias in self.roles:
if alias in self.indexes:
raise ValueError(
f"Document type {self.type} declares '{alias}' as both an index and a role; an index already names its role."
)
def catalog_entry(self) -> CultCacheSchemaCatalogEntry: def catalog_entry(self) -> CultCacheSchemaCatalogEntry:
if self._catalog_entry is not None: if self._catalog_entry is not None:
@ -241,6 +324,7 @@ def define_document_type(
global_document: bool = False, global_document: bool = False,
name: str | Extractor[T] | None = None, name: str | Extractor[T] | None = None,
indexes: dict[str, str | Extractor[T]] | None = None, indexes: dict[str, str | Extractor[T]] | None = None,
roles: dict[str, str | Extractor[T]] | None = None,
payload_encoder: Callable[[Any], bytes] = _json_payload_encoder, payload_encoder: Callable[[Any], bytes] = _json_payload_encoder,
payload_decoder: Callable[[bytes], Any] = _json_payload_decoder, payload_decoder: Callable[[bytes], Any] = _json_payload_decoder,
schema_id: str | None = None, schema_id: str | None = None,
@ -259,6 +343,7 @@ def define_document_type(
global_document=global_document, global_document=global_document,
name=name, name=name,
indexes=indexes or {}, indexes=indexes or {},
roles=roles or {},
payload_encoder=payload_encoder, payload_encoder=payload_encoder,
payload_decoder=payload_decoder, payload_decoder=payload_decoder,
schema_id=schema_id, schema_id=schema_id,
@ -280,6 +365,7 @@ def define_database_entry_type(
global_document: bool = False, global_document: bool = False,
name: str | Extractor[T] | None = None, name: str | Extractor[T] | None = None,
indexes: dict[str, str | Extractor[T]] | None = None, indexes: dict[str, str | Extractor[T]] | None = None,
roles: dict[str, str | Extractor[T]] | None = None,
schema_id: str | None = None, schema_id: str | None = None,
schema_name: str | None = None, schema_name: str | None = None,
schema_version: str | None = None, schema_version: str | None = None,
@ -296,7 +382,7 @@ def define_database_entry_type(
normalized = _normalize_fields(type, fields) normalized = _normalize_fields(type, fields)
member_name_lookup = name if isinstance(name, str) else None member_name_lookup = name if isinstance(name, str) else None
index_lookup = indexes or {} index_lookup = {**(roles or {}), **(indexes or {})}
def members() -> tuple[CultCacheSchemaCatalogMember, ...]: def members() -> tuple[CultCacheSchemaCatalogMember, ...]:
return tuple( return tuple(
@ -323,6 +409,7 @@ def define_database_entry_type(
global_document=global_document, global_document=global_document,
name=name, name=name,
indexes=indexes, indexes=indexes,
roles=roles,
payload_encoder=_pack_tree, payload_encoder=_pack_tree,
payload_decoder=_messagepack_payload_decoder, payload_decoder=_messagepack_payload_decoder,
schema_id=schema_id, schema_id=schema_id,
@ -401,6 +488,9 @@ def define_union(
raise ValueError(f"Union {name!r} value is malformed: expected [armKey, [armSlots]]") raise ValueError(f"Union {name!r} value is malformed: expected [armKey, [armSlots]]")
arm_name, codec = by_key[key] arm_name, codec = by_key[key]
if codec is None: if codec is None:
scope = _current_scope()
if scope is not None and raw[1]:
scope.note(arm_name, len(raw[1]), 0, False)
return UnionArm(arm_name, None) return UnionArm(arm_name, None)
try: try:
return UnionArm(arm_name, codec.decode(raw[1])) return UnionArm(arm_name, codec.decode(raw[1]))
@ -462,17 +552,26 @@ def _record_codec(
def decode(raw: Any) -> Any: def decode(raw: Any) -> Any:
if not isinstance(raw, list): if not isinstance(raw, list):
raise ValueError(f"DatabaseEntry document type {type!r} expected a MessagePack array") raise ValueError(f"DatabaseEntry document type {type!r} expected a MessagePack array")
scope = _current_scope()
if scope is not None and len(raw) > max_key + 1:
scope.note(type, len(raw), max_key + 1, scope.depth == 0)
values: dict[str, Any] = {} values: dict[str, Any] = {}
for field in normalized: if scope is not None:
if field.key < len(raw): scope.depth += 1
try: try:
values[field.name] = field.codec.decode(raw[field.key]) for field in normalized:
except ValueError as error: if field.key < len(raw):
raise ValueError(f"{type} slot {field.key} ({field.name}): {error}") from None try:
elif field.default is MISSING: values[field.name] = field.codec.decode(raw[field.key])
values[field.name] = None except ValueError as error:
else: raise ValueError(f"{type} slot {field.key} ({field.name}): {error}") from None
values[field.name] = field.default() if callable(field.default) else field.default elif field.default is MISSING:
values[field.name] = None
else:
values[field.name] = field.default() if callable(field.default) else field.default
finally:
if scope is not None:
scope.depth -= 1
if cls is None: if cls is None:
return values return values
return cls(**values) return cls(**values)

View file

@ -13,6 +13,7 @@ from typing import Any, Callable, Iterator, TypeVar
import msgpack import msgpack
from .documents import UnreadSlots
from .backing_store import ( from .backing_store import (
CultCacheEnvelope, CultCacheEnvelope,
CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogEntry,
@ -46,9 +47,19 @@ class SchemaConflictError(ValueError):
"""A write the catalog cannot describe: records of different types under one schema id, two arrived schemas that share an id """A write the catalog cannot describe: records of different types under one schema id, two arrived schemas that share an id
and disagree on the schema name, or a record no chosen entry publishes. Nothing is written. It names the id, the names and disagree on the schema name, or a record no chosen entry publishes. Nothing is written. It names the id, the names
involved (`schema_names`) and a record key (`record_key`). A cache raises it too, with an empty record key, when a involved (`schema_names`) and a record key (`record_key`). A cache raises it too, with an empty record key, when a
document registers a schema id another registered document already carries.""" document registers a schema id another registered document already carries. `unread_slots` is None except for the refusal to
write or remove a record held read-only because its payload carries slots past the declared ones; its message names the type
and both slot counts and never holds a slot value."""
def __init__(self, schema_id: str, schema_names: list[str], record_key: str, *, message: str | None = None) -> None: def __init__(
self,
schema_id: str,
schema_names: list[str],
record_key: str,
*,
message: str | None = None,
unread_slots: UnreadSlots | None = None,
) -> None:
super().__init__( super().__init__(
message message
or f"Schema id {schema_id!r} cannot describe record {record_key!r}: {', '.join(repr(name) for name in schema_names)}" or f"Schema id {schema_id!r} cannot describe record {record_key!r}: {', '.join(repr(name) for name in schema_names)}"
@ -56,6 +67,7 @@ class SchemaConflictError(ValueError):
self.schema_id = schema_id self.schema_id = schema_id
self.schema_names = schema_names self.schema_names = schema_names
self.record_key = record_key self.record_key = record_key
self.unread_slots = unread_slots
class JsonLinesBackingStore: class JsonLinesBackingStore:
@ -245,11 +257,44 @@ def _derive_catalog(records: list[tuple[CultCacheEnvelope, bool]]) -> list[CultC
return [entry for entry, _ in chosen.values()] return [entry for entry, _ in chosen.values()]
def retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConflictError:
"""The refusal of a write that would put a second type under a key the store already holds (C# CacheBackingStore.Retyped)."""
return SchemaConflictError(
_schema_id_for(held),
[held.type],
held.key,
message=(
f"Record '{held.key}' is a {held.type} (schema id '{_schema_id_for(held)}'); writing a {written.type} there would "
"retype the key. Remove the record first, in the same batch or an earlier commit, then add the other type's."
),
)
def written_owns(held: CultCacheEnvelope, written: CultCacheEnvelope) -> bool:
"""Whether a write of `written` may replace `held` at its key (C# WrittenOwns): the written type's entry declares the held
record's schema id (its own id or one it lists compatible), or it is the type of the same schema name. The same-name half
stands in for C#'s "reads the published entry whole": this runtime does not compare shapes, so it never refuses a
same-name type for dropping slots."""
stored_id = _schema_id_for(held)
entry = written.catalog_entry if written.catalog_entry is not None else _default_catalog_entry(written)
return stored_id == entry.schema_id or stored_id in entry.compatible_schema_ids or held.type == written.type
def _encode_snapshot( def _encode_snapshot(
supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope], format_version: str supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope], format_version: str
) -> list[Any]: ) -> list[Any]:
replaced = {(envelope.type, envelope.key) for envelope in supplied} # A key names one record whatever its type, so a supplied record replaces the arrived one by key alone, and only of the
kept = [envelope for envelope in arrived if (envelope.type, envelope.key) not in replaced] # type that owns it: retyping a key is a removal and an add, so a record another type owns under the key refuses the write.
held_by_key = {envelope.key: envelope for envelope in arrived}
landing: dict[str, CultCacheEnvelope] = {}
for envelope in supplied:
for held in (landing.get(envelope.key), held_by_key.get(envelope.key)):
if held is not None and not written_owns(held, envelope):
raise retyped(held, envelope)
landing[envelope.key] = envelope
supplied = list(landing.values())
replaced = set(landing)
kept = [envelope for envelope in arrived if envelope.key not in replaced]
envelopes = [*kept, *supplied] envelopes = [*kept, *supplied]
catalog = [ catalog = [
_encode_catalog_entry(entry) _encode_catalog_entry(entry)
@ -400,6 +445,7 @@ def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None
catalog_by_schema_id.setdefault(compatible_schema_id, entry) catalog_by_schema_id.setdefault(compatible_schema_id, entry)
envelopes: list[CultCacheEnvelope] = [] envelopes: list[CultCacheEnvelope] = []
seen: set[str] = set()
for raw_record in decoded[2]: for raw_record in decoded[2]:
if not isinstance(raw_record, list) or len(raw_record) < 4: if not isinstance(raw_record, list) or len(raw_record) < 4:
raise ValueError("CultCache persisted records must be MessagePack arrays") raise ValueError("CultCache persisted records must be MessagePack arrays")
@ -412,6 +458,9 @@ def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None
key, schema_id, stored_at, payload = raw_record key, schema_id, stored_at, payload = raw_record
if not isinstance(key, str) or not key: if not isinstance(key, str) or not key:
raise ValueError("CultCache persisted records must declare a key") raise ValueError("CultCache persisted records must declare a key")
if key in seen:
raise ValueError(f"CultCache record {key!r} appears more than once")
seen.add(key)
if not isinstance(schema_id, str) or not schema_id: if not isinstance(schema_id, str) or not schema_id:
raise ValueError("CultCache persisted records must declare a schema id") raise ValueError("CultCache persisted records must declare a schema id")
if not isinstance(stored_at, str) or not stored_at: if not isinstance(stored_at, str) or not stored_at:
@ -437,9 +486,13 @@ def _decode_legacy_envelopes(decoded: Any) -> list[CultCacheEnvelope]:
if not isinstance(decoded, list): if not isinstance(decoded, list):
raise ValueError("CultCache MessagePack store is not a recognized snapshot") raise ValueError("CultCache MessagePack store is not a recognized snapshot")
envelopes: list[CultCacheEnvelope] = [] envelopes: list[CultCacheEnvelope] = []
seen: set[Any] = set()
for item in decoded: for item in decoded:
if not isinstance(item, dict): if not isinstance(item, dict):
raise ValueError("CultCache legacy envelopes must be MessagePack maps") raise ValueError("CultCache legacy envelopes must be MessagePack maps")
if item.get("key") in seen:
raise ValueError(f"CultCache record {item['key']!r} appears more than once")
seen.add(item.get("key"))
try: try:
payload = item["payload"] payload = item["payload"]
envelopes.append( envelopes.append(
@ -484,12 +537,14 @@ def _decode_catalog_entry(value: Any) -> CultCacheSchemaCatalogEntry:
raise ValueError(f'CultCache schema catalog entry "{schema_id}" has invalid compatible schema ids') raise ValueError(f'CultCache schema catalog entry "{schema_id}" has invalid compatible schema ids')
if not isinstance(members, list): if not isinstance(members, list):
raise ValueError(f'CultCache schema catalog entry "{schema_id}" has invalid members') raise ValueError(f'CultCache schema catalog entry "{schema_id}" has invalid members')
if not isinstance(canonical_schema_json, str):
raise ValueError(f'CultCache schema catalog entry "{schema_id}" has an invalid canonical schema')
return CultCacheSchemaCatalogEntry( return CultCacheSchemaCatalogEntry(
schema_id=schema_id, schema_id=schema_id,
schema_name=schema_name, schema_name=schema_name,
schema_version=schema_version if isinstance(schema_version, str) and schema_version else f"{schema_name}.v1", schema_version=schema_version if isinstance(schema_version, str) and schema_version else f"{schema_name}.v1",
content_hash=content_hash if isinstance(content_hash, str) and content_hash else schema_id, content_hash=content_hash if isinstance(content_hash, str) and content_hash else schema_id,
canonical_schema_json=canonical_schema_json if isinstance(canonical_schema_json, str) else "", canonical_schema_json=canonical_schema_json,
compatible_schema_ids=tuple(compatible_schema_ids), compatible_schema_ids=tuple(compatible_schema_ids),
members=tuple(_decode_catalog_member(member) for member in members), members=tuple(_decode_catalog_member(member) for member in members),
) )

View file

@ -103,20 +103,20 @@ class CultCacheTests(unittest.TestCase):
self.assertEqual(cache.get_key_by_name(document, "Elixir"), "item:potion") self.assertEqual(cache.get_key_by_name(document, "Elixir"), "item:potion")
self.assertEqual(cache.get_by_index(document, "category", "Rare").name, "Elixir") self.assertEqual(cache.get_by_index(document, "category", "Rare").name, "Elixir")
def test_incremental_lookup_delete_restores_duplicate_owner(self) -> None: def test_incremental_lookup_delete_restores_duplicate_name_owner(self) -> None:
document = item_doc() document = item_doc()
cache = CultCache() cache = CultCache()
cache.register_document_type(document) cache.register_document_type(document)
cache.put(document, "item:first", Item(name="Potion", category="Consumable", value=50)) cache.put(document, "item:first", Item(name="Potion", category="Consumable", value=50))
cache.put(document, "item:second", Item(name="Potion", category="Consumable", value=60)) cache.put(document, "item:second", Item(name="Potion", category="Rare", value=60))
self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:second") self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:second")
self.assertEqual(cache.get_key_by_index(document, "category", "Consumable"), "item:second")
cache.delete(document, "item:second") cache.delete(document, "item:second")
self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:first") self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:first")
self.assertEqual(cache.get_key_by_index(document, "category", "Consumable"), "item:first") self.assertEqual(cache.get_key_by_index(document, "category", "Consumable"), "item:first")
self.assertIsNone(cache.get_key_by_index(document, "category", "Rare"))
def test_database_entry_formatter_uses_slot_indexed_messagepack_array(self) -> None: def test_database_entry_formatter_uses_slot_indexed_messagepack_array(self) -> None:
try: try:
@ -1121,6 +1121,25 @@ class CultCacheTests(unittest.TestCase):
catalog_entry=catalog_entry, catalog_entry=catalog_entry,
) )
def test_pulling_a_synthetic_pre_v1_envelope_array_leaves_the_file_bytes_identical(self) -> None:
try:
import msgpack # type: ignore
except ImportError:
self.skipTest("msgpack optional dependency is not installed")
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "legacy.cc"
legacy = msgpack.packb(
[{"key": "note:hello", "type": "note", "payload": {"title": "Hello"}, "storedAt": "2026-10-20T00:00:00Z"}],
use_bin_type=True,
)
path.write_bytes(legacy)
envelopes = SingleFileMessagePackBackingStore(path).pull_all()
self.assertEqual([e.key for e in envelopes], ["note:hello"])
self.assertEqual(path.read_bytes(), legacy)
self.assertEqual(sorted(p.name for p in Path(tmp).iterdir()), ["legacy.cc"])
def test_legacy_key_global_loads_without_writing_and_first_write_leaves_one_global(self) -> None: def test_legacy_key_global_loads_without_writing_and_first_write_leaves_one_global(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
settings = define_document_type("settings", global_document=True) settings = define_document_type("settings", global_document=True)
@ -1548,15 +1567,19 @@ class CultCacheTests(unittest.TestCase):
# Every vector in the folder has a manifest row. # Every vector in the folder has a manifest row.
self.assertEqual( self.assertEqual(
sorted(path.name for path in root.glob("*.bin")), sorted(path.name for path in root.glob("*.bin")),
sorted(vector for vector, *_ in rows if not vector.startswith("..")), sorted(vector for vector, *_ in rows if not vector.startswith(("..", "@"))),
) )
envelopes = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "v3-base.msgpack").pull_all() envelopes = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "v3-base.msgpack").pull_all()
for vector, _, _, _, python in rows: for vector, _, _, _, python in rows:
content = (root / vector).read_bytes() # "@directory": the walker makes an empty directory at the store path; it is refused and left untouched.
content = None if vector == "@directory" else (root / vector).read_bytes()
for operation in ("pull_all", "push_all", "push", "delete"): for operation in ("pull_all", "push_all", "push", "delete"):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
store_path = Path(tmp) / "store.msgpack" store_path = Path(tmp) / "store.msgpack"
store_path.write_bytes(content) if content is None:
store_path.mkdir()
else:
store_path.write_bytes(content)
store = SingleFileMessagePackBackingStore(store_path) store = SingleFileMessagePackBackingStore(store_path)
run = { run = {
"pull_all": store.pull_all, "pull_all": store.pull_all,
@ -1579,12 +1602,129 @@ class CultCacheTests(unittest.TestCase):
self.assertIn(envelopes[0].key, keys, f"{vector} {operation}") self.assertIn(envelopes[0].key, keys, f"{vector} {operation}")
else: else:
self.assertNotIn(envelopes[0].key, keys, f"{vector} {operation}") self.assertNotIn(envelopes[0].key, keys, f"{vector} {operation}")
elif content is None:
# A path that cannot be reached as a file stays an OSError (_read_store): not a store that failed to decode.
with self.assertRaises(OSError, msg=f"{vector} {operation}"):
run()
self.assertTrue(store_path.is_dir() and not any(store_path.iterdir()), f"{vector} {operation} touched the directory")
else: else:
with self.assertRaises(StoreUnreadableError, msg=f"{vector} {operation}") as refused: with self.assertRaises(StoreUnreadableError, msg=f"{vector} {operation}") as refused:
run() run()
self.assertIsNotNone(refused.exception.__cause__, f"{vector} {operation}") self.assertIsNotNone(refused.exception.__cause__, f"{vector} {operation}")
self.assertEqual(store_path.read_bytes(), content, f"{vector} {operation} rewrote a file it cannot read") self.assertEqual(store_path.read_bytes(), content, f"{vector} {operation} rewrote a file it cannot read")
@staticmethod
def _rec(type: str, key: str, second: int = 0) -> CultCacheEnvelope:
return CultCacheEnvelope(key=key, type=type, payload=b"\x90", stored_at=f"2026-09-30T00:00:0{second}Z")
def test_a_supplied_record_replaces_an_arrived_one_of_the_same_type_by_key(self) -> None:
import msgpack # type: ignore
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
store = SingleFileMessagePackBackingStore(path)
store.push(self._rec("first", "k"))
store.push(self._rec("first", "k", 1))
store.push_all([self._rec("first", "k", 2), self._rec("first", "k", 3)])
records = msgpack.unpackb(path.read_bytes(), raw=False)[2]
self.assertEqual([record[0] for record in records], ["k"])
self.assertEqual([(e.type, e.stored_at) for e in store.pull_all()], [("first", "2026-09-30T00:00:03Z")])
def test_a_write_that_would_retype_a_key_is_refused_and_leaves_the_file(self) -> None:
from cultcache_py import SchemaConflictError
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
store = SingleFileMessagePackBackingStore(path)
store.push(self._rec("first", "k"))
content = path.read_bytes()
for run in (
lambda: store.push(self._rec("second", "k", 1)),
lambda: store.push_all([self._rec("other", "j"), self._rec("second", "k", 1)]),
):
with self.assertRaises(SchemaConflictError) as refused:
run()
self.assertEqual(refused.exception.record_key, "k")
self.assertEqual(refused.exception.schema_names, ["first"])
self.assertIn("would retype the key", str(refused.exception))
self.assertEqual(path.read_bytes(), content)
# Retyping is a removal and an add.
store.delete("first", "k")
store.push(self._rec("second", "k", 1))
self.assertEqual([e.type for e in SingleFileMessagePackBackingStore(path).pull_all()], ["second"])
def test_a_retype_is_judged_by_the_stored_schema_id_the_written_type_declares_not_by_name(self) -> None:
from cultcache_py import SchemaConflictError
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
store = SingleFileMessagePackBackingStore(path)
store.push(self._writer_record("k", "old", "id-old", self._writer_entry("id-old", "old", "h1", ("id-old",))))
content = path.read_bytes()
# A renamed type that lists the stored id compatible owns the record, so its write replaces it.
renamed = self._writer_record("k", "new", "id-new", self._writer_entry("id-new", "new", "h2", ("id-new", "id-old")))
# A type that lists nothing of the stored id and has another name does not.
stranger = self._writer_record("k", "stranger", "id-x", self._writer_entry("id-x", "stranger", "h3", ("id-x",)))
for run in (lambda: store.push(stranger), lambda: store.push_all([stranger])):
with self.assertRaises(SchemaConflictError):
run()
self.assertEqual(path.read_bytes(), content)
store.push(renamed)
self.assertEqual([(e.type, e.schema_id) for e in SingleFileMessagePackBackingStore(path).pull_all()], [("new", "id-new")])
def test_a_batch_naming_one_key_under_two_types_is_refused_whole(self) -> None:
from cultcache_py import SchemaConflictError
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
store = SingleFileMessagePackBackingStore(path)
with self.assertRaises(SchemaConflictError):
store.push_all([self._rec("first", "k"), self._rec("second", "k", 1)])
self.assertFalse(path.exists())
def test_a_cache_put_of_a_second_type_under_a_key_is_refused_and_persists_nothing_new(self) -> None:
from cultcache_py import SchemaConflictError
other = define_document_type("other", encode=lambda item: asdict(item), decode=lambda raw: Item(**raw))
document = item_doc()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
def build() -> CultCache:
cache = (
CultCache.builder()
.register_document_type(document)
.register_document_type(other)
.add_generic_store(SingleFileMessagePackBackingStore(path))
.build()
)
cache.pull_all_backing_stores()
return cache
cache = build()
cache.put(document, "shared", Item(name="A", category="c", value=1))
content = path.read_bytes()
with self.assertRaises(SchemaConflictError):
cache.put(other, "shared", Item(name="B", category="c", value=2))
self.assertEqual(path.read_bytes(), content)
self.assertIsNone(cache.get(other, "shared"))
reloaded = build()
self.assertEqual(reloaded.get_required(document, "shared").value, 1)
self.assertIsNone(reloaded.get(other, "shared"))
cache.delete(document, "shared")
cache.put(other, "shared", Item(name="B", category="c", value=2))
self.assertEqual(build().get_required(other, "shared").value, 2)
def test_a_catalog_entry_with_a_non_string_canonical_schema_is_unreadable(self) -> None:
# Direct probe of the c0 vector; its manifest row is added by the TS cut.
vector = self._C2A_VECTORS.parent / "document-variants-c0" / "current-catalog-bad-canonical.msgpack"
content = vector.read_bytes()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
path.write_bytes(content)
with self.assertRaises(StoreUnreadableError):
SingleFileMessagePackBackingStore(path).pull_all()
self.assertEqual(path.read_bytes(), content)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -24,7 +24,6 @@ item_doc = define_document_type(
encode=lambda item: asdict(item), encode=lambda item: asdict(item),
decode=lambda raw: Item(**raw), decode=lambda raw: Item(**raw),
name="name", name="name",
indexes={"category": "category"},
) )

View file

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

View file

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

View file

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

View file

@ -0,0 +1,664 @@
from __future__ import annotations
import itertools
import shutil
import tempfile
import unittest
from dataclasses import asdict, dataclass
from decimal import Decimal
from pathlib import Path
import msgpack
from cultcache_py import (
CultCache,
CultCacheEnvelope,
SingleFileMessagePackBackingStore,
define_database_entry_type,
define_document_type,
leaves as t,
database_entry_field,
)
from cultcache_py.cache import CultCacheError, index_text
# variants:ruling:unique-indexes. `indexes` are unique within one type, `roles` are selection aliases. The vectors in
# tests/vectors/unique-index are written by the C# reference; the rest of the behaviour is pinned here.
VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "unique-index"
@dataclass
class Item:
sku: object = None
serial: object = None
group: object = None
def refusal(key_a: str, key_b: str, schema_name: str, alias: str) -> str:
return f"Records {key_a} and {key_b} ({schema_name}) both hold unique index '{alias}'; an index value names one record."
def item_doc(type: str = "item", *, indexes=None, roles=None):
return define_document_type(
type,
encode=asdict,
decode=lambda raw: Item(**raw),
indexes={"sku": "sku", "serial": "serial"} if indexes is None else indexes,
roles={"group": "group"} if roles is None else roles,
)
def envelope(document, key: str, item: Item) -> CultCacheEnvelope:
return CultCacheEnvelope.create(key=key, type=document.type, payload=document.encode_payload(item))
def cache_of(*documents, store=None) -> CultCache:
builder = CultCache.builder()
for document in documents:
builder.register_document_type(document)
if store is not None:
builder.add_generic_store(store)
return builder.build()
class RecordingStore:
"""A store that counts what reaches it; the judge must refuse before any store call."""
def __init__(self) -> None:
self.pushed: list[str] = []
def pull_all(self):
return []
def push(self, envelope) -> None:
self.pushed.append(envelope.key)
def push_all(self, envelopes) -> None:
self.pushed.extend(envelope.key for envelope in envelopes)
def delete(self, type, key) -> None:
pass
class IndexTextTests(unittest.TestCase):
def test_canonical_text(self) -> None:
cases = [
("S-1", "S-1"),
(" a ", " a "),
("", ""),
("\x1f", "\x1f"),
("\x1c", "\x1c"),
("\x1d", "\x1d"),
("\x1e", "\x1e"),
("᠎", "᠎"),
("​", "​"),
(True, "True"),
(False, "False"),
(7, "7"),
(-7, "-7"),
(9223372036854775807, "9223372036854775807"),
(18446744073709551615, "18446744073709551615"),
(0.0, "0"),
(-0.0, "0"),
(2.0, "2"),
(-3.0, "-3"),
(1e21, "1000000000000000000000"),
(0.1, "0.1000000000000000055511151231257827021181583404541015625"),
(-0.5, "-0.5"),
(2.5e-07, "0.00000024999999999999998868702795647156467140348468092270195484161376953125"),
]
for value, text in cases:
with self.subTest(value=value):
self.assertEqual(index_text(value), text)
self.assertIsInstance(index_text(value), str)
def test_no_value(self) -> None:
blank = ["", " ", "\t", "\n", "\x0b", "\x0c", "\r", "\x85", "\xa0", " ", "
", "
", " ",
" ", " ", "  \x85 ", *map(chr, range(0x2000, 0x200B))]
for value in [None, float("nan"), float("inf"), float("-inf"), *blank]:
with self.subTest(value=repr(value)):
self.assertIsNone(index_text(value))
def test_blank_set_is_exactly_the_csharp_set(self) -> None:
csharp = {*range(0x09, 0x0E), 0x20, 0x85, 0xA0, 0x1680, *range(0x2000, 0x200B), 0x2028, 0x2029, 0x202F, 0x205F, 0x3000}
self.assertEqual(len(csharp), 25)
got = {code for code in range(0x110000) if 0xD800 > code or code > 0xDFFF if index_text(chr(code)) is None}
self.assertEqual(got, csharp)
# Python's isspace is the wrong test: it also counts U+001C-001F.
self.assertTrue("\x1f".isspace())
self.assertEqual(index_text("\x1f"), "\x1f")
def test_floats_render_exactly_not_through_str(self) -> None:
for value in (0.1, 1e-7, 123456789.123456789, 5e-324, 1.7976931348623157e308):
with self.subTest(value=value):
text = index_text(value)
self.assertNotIn("e", text.lower())
self.assertEqual(Decimal(text), Decimal(value))
self.assertFalse(text.endswith("0") and "." in text)
def test_bool_is_not_an_integer(self) -> None:
self.assertEqual(index_text(True), "True")
self.assertNotEqual(index_text(True), "1")
class UniqueIndexTests(unittest.TestCase):
def test_a_write_of_a_held_value_at_another_key_is_refused_by_every_write_path(self) -> None:
for alias in ("sku", "serial"): # two unique indexes: each is judged
other = "serial" if alias == "sku" else "sku"
paths = {
"put": lambda cache, d, k, i: cache.put(d, k, i),
"put_envelope": lambda cache, d, k, i: cache.put_envelope(d, envelope(d, k, i)),
"put_envelopes": lambda cache, d, k, i: cache.put_envelopes(d, [envelope(d, k, i)]),
"update": lambda cache, d, k, i: cache.update(d, k, lambda _held: i),
}
for name, write in paths.items():
with self.subTest(alias=alias, path=name):
document = item_doc()
store = RecordingStore()
cache = cache_of(document, store=store)
cache.put(document, "item:b", Item(sku="CANARY-b", serial="canary-b"))
cache.put(document, "item:a", Item(sku="CANARY-a", serial="canary-a"))
before = cache.snapshot()
pushed = list(store.pushed)
taken = "CANARY-b" if alias == "sku" else "canary-b"
landing = Item(**{alias: taken, other: "fresh"})
with self.assertRaises(CultCacheError) as caught:
write(cache, document, "item:a", landing)
self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", alias))
self.assertNotIn("anary", str(caught.exception))
self.assertEqual(cache.snapshot(), before)
self.assertEqual(store.pushed, pushed)
self.assertEqual(cache.get_key_by_index(document, alias, taken), "item:b")
self.assertIsNone(cache.get_key_by_index(document, other, "fresh"))
def test_a_record_rewriting_its_own_key_keeps_its_value(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:a", Item(sku="S", serial="1", group="g"))
cache.put(document, "item:a", Item(sku="S", serial="2", group="h"))
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:a")
self.assertIsNone(cache.get_key_by_index(document, "serial", "1"))
self.assertEqual(cache.get_key_by_index(document, "serial", "2"), "item:a")
def test_the_refusal_names_the_schema_not_the_type(self) -> None:
document = define_document_type(
"t.item", indexes={"sku": "sku"}, schema_name="schema.Item", schema_id="schema.Item.v1"
)
cache = cache_of(document)
cache.put(document, "k:1", {"sku": "S"})
with self.assertRaises(CultCacheError) as caught:
cache.put(document, "k:2", {"sku": "S"})
self.assertEqual(str(caught.exception), refusal("k:1", "k:2", "schema.Item", "sku"))
def test_the_two_smallest_keys_are_named_in_ordinal_order(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:m", Item(sku="S"))
with self.assertRaises(CultCacheError) as caught:
cache.put_envelopes(document, [envelope(document, "item:z", Item(sku="S")), envelope(document, "item:A", Item(sku="S"))])
self.assertEqual(str(caught.exception), refusal("item:A", "item:m", "item", "sku"))
def test_put_envelopes_holding_one_value_twice_is_refused_whole(self) -> None:
document = item_doc()
store = RecordingStore()
cache = cache_of(document, store=store)
with self.assertRaises(CultCacheError) as caught:
cache.put_envelopes(
document,
[
envelope(document, "item:b", Item(sku="S")),
envelope(document, "item:c", Item(sku="T")),
envelope(document, "item:a", Item(sku="S")),
],
)
self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "sku"))
self.assertEqual(cache.snapshot(), {})
self.assertEqual(store.pushed, [])
self.assertIsNone(cache.get_key_by_index(document, "sku", "T"))
def test_verdict_does_not_depend_on_batch_order(self) -> None:
document = item_doc()
rows = [("item:a", Item(sku="S", serial="1")), ("item:b", Item(sku="T", serial="1")), ("item:c", Item(sku="U"))]
for order in itertools.permutations(rows):
with self.subTest(order=[key for key, _ in order]):
cache = cache_of(document)
with self.assertRaises(CultCacheError) as caught:
cache.put_envelopes(document, [envelope(document, key, item) for key, item in order])
self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "serial"))
self.assertEqual(cache.snapshot(), {})
def test_put_envelopes_swapping_two_records_values_lands(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:a", Item(sku="S", serial="1"))
cache.put(document, "item:b", Item(sku="T", serial="2"))
cache.put_envelopes(
document,
[envelope(document, "item:a", Item(sku="T", serial="2")), envelope(document, "item:b", Item(sku="S", serial="1"))],
)
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b")
self.assertEqual(cache.get_key_by_index(document, "sku", "T"), "item:a")
self.assertEqual(cache.get_key_by_index(document, "serial", "1"), "item:b")
self.assertEqual(cache.get_key_by_index(document, "serial", "2"), "item:a")
def test_a_batch_may_take_a_value_the_batch_releases(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:a", Item(sku="S"))
cache.put_envelopes(document, [envelope(document, "item:a", Item(sku="T")), envelope(document, "item:b", Item(sku="S"))])
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b")
self.assertEqual(cache.get_key_by_index(document, "sku", "T"), "item:a")
def test_delete_frees_the_value(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:a", Item(sku="S"))
cache.delete(document, "item:a")
self.assertIsNone(cache.get_key_by_index(document, "sku", "S"))
cache.put(document, "item:b", Item(sku="S"))
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b")
def test_blank_values_never_conflict(self) -> None:
document = item_doc()
cache = cache_of(document)
blanks = ["", " ", "\x85", " ", None]
for number, blank in enumerate(blanks):
cache.put(document, f"item:blank{number}", Item(sku=blank, serial=blank))
cache.put_envelopes(document, [envelope(document, "item:more", Item(sku=" ")), envelope(document, "item:more2", Item(sku=""))])
for blank in ("", " ", "\x85"):
self.assertIsNone(cache.get_key_by_index(document, "sku", blank))
# U+001F and U+FEFF are values.
cache.put(document, "item:us", Item(sku="\x1f"))
cache.put(document, "item:bom", Item(sku=""))
self.assertEqual(cache.get_key_by_index(document, "sku", "\x1f"), "item:us")
self.assertEqual(cache.get_key_by_index(document, "sku", ""), "item:bom")
with self.assertRaises(CultCacheError):
cache.put(document, "item:us2", Item(sku="\x1f"))
def test_values_compare_as_the_canonical_text(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:a", Item(sku=1))
for same in (1, 1.0, "1"):
with self.subTest(same=same), self.assertRaises(CultCacheError):
cache.put(document, "item:b", Item(sku=same))
cache.put(document, "item:true", Item(sku=True))
self.assertEqual(cache.get_key_by_index(document, "sku", "True"), "item:true")
self.assertEqual(cache.get_key_by_index(document, "sku", "1"), "item:a")
cache.put(document, "item:f", Item(sku=0.1))
self.assertEqual(cache.get_key_by_index(document, "sku", "0.1000000000000000055511151231257827021181583404541015625"), "item:f")
def test_two_types_may_share_an_alias_and_a_value(self) -> None:
one, two = item_doc("one"), item_doc("two")
cache = cache_of(one, two)
cache.put(one, "k:1", Item(sku="S", serial="1"))
cache.put(two, "k:2", Item(sku="S", serial="1"))
self.assertEqual(cache.get_key_by_index(one, "sku", "S"), "k:1")
self.assertEqual(cache.get_key_by_index(two, "sku", "S"), "k:2")
cache.delete(one, "k:1")
self.assertEqual(cache.get_key_by_index(two, "sku", "S"), "k:2")
def test_roles_may_share_values_and_are_not_looked_up(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:a", Item(sku="1", group="g"))
cache.put(document, "item:b", Item(sku="2", group="g"))
self.assertIsNone(cache.get_key_by_index(document, "group", "g"))
self.assertIsNone(cache.get_by_index(document, "group", "g"))
def test_an_alias_in_both_indexes_and_roles_is_refused(self) -> None:
with self.assertRaisesRegex(ValueError, "both an index and a role"):
define_document_type("bad", indexes={"a": "a"}, roles={"a": "a"})
with self.assertRaisesRegex(ValueError, "both an index and a role"):
define_database_entry_type("bad", [("a", 0)], indexes={"a": "a"}, roles={"a": "a"})
define_document_type("fine", indexes={"a": "a"}, roles={"b": "a"})
def test_register_index_over_held_duplicates_installs_nothing(self) -> None:
document = item_doc(indexes={}, roles={})
cache = cache_of(document)
cache.put(document, "item:b", Item(sku="S", serial="1"))
cache.put(document, "item:a", Item(sku="S", serial="2"))
with self.assertRaises(CultCacheError) as caught:
cache.register_index(document, "sku", "sku")
self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "sku"))
self.assertIsNone(cache.get_key_by_index(document, "sku", "S"))
cache.register_index(document, "serial", "serial") # an installed alias is not poisoned by the refused one
self.assertEqual(cache.get_key_by_index(document, "serial", "2"), "item:a")
# Nothing was installed, so a write sharing the refused alias's value is still admitted.
cache.put(document, "item:c", Item(sku="S", serial="3"))
def test_registration_names_an_installed_alias_a_caller_mutated_into_a_collision(self) -> None:
document = item_doc(indexes={"sku": "sku"}, roles={})
cache = cache_of(document)
cache.put(document, "item:x", Item(sku="1", serial="s1"))
cache.put(document, "item:y", Item(sku="2", serial="s2"))
cache.get_required(document, "item:y").sku = "1" # the cache hands out its held value
with self.assertRaises(CultCacheError) as caught:
cache.register_index(document, "serial", "serial")
self.assertEqual(str(caught.exception), refusal("item:x", "item:y", "item", "sku"))
def test_derive_names_the_ordinal_first_alias_then_value(self) -> None:
document = item_doc(indexes={}, roles={})
cache = cache_of(document)
cache.put(document, "item:1", Item(sku="a", serial="p", group="x"))
cache.put(document, "item:2", Item(sku="a", serial="p", group="x"))
cache.put(document, "item:3", Item(sku="b", serial="q", group="y"))
cache.put(document, "item:4", Item(sku="b", serial="q", group="y"))
with self.assertRaises(CultCacheError) as caught:
cache.register_index(document, "serial", "serial")
self.assertEqual(str(caught.exception), refusal("item:1", "item:2", "item", "serial"))
def test_a_document_in_two_unique_indexes_loads_and_registers_through_every_path(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "items.cc"
document = item_doc()
cache = cache_of(document, store=SingleFileMessagePackBackingStore(path))
cache.put(document, "item:a", Item(sku="S", serial="1"))
cache.put(document, "item:b", Item(sku="T", serial="2"))
# a store written by a looser definition, then opened by this one
loose = item_doc(indexes={}, roles={})
writer = cache_of(loose, store=SingleFileMessagePackBackingStore(path))
writer.pull_all_backing_stores()
writer.put(loose, "item:c", Item(sku="U", serial="2"))
before = path.read_bytes()
reader = cache_of(document, store=SingleFileMessagePackBackingStore(path))
with self.assertRaises(CultCacheError) as caught:
reader.pull_all_backing_stores()
self.assertEqual(str(caught.exception), refusal("item:b", "item:c", "item", "serial"))
self.assertEqual(path.read_bytes(), before)
self.assertEqual(reader.snapshot(), {})
def test_a_refused_load_keeps_the_held_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "items.cc"
loose = item_doc(indexes={}, roles={})
writer = cache_of(loose, store=SingleFileMessagePackBackingStore(path))
writer.put(loose, "item:a", Item(sku="S"))
writer.put(loose, "item:b", Item(sku="S"))
document = item_doc()
cache = cache_of(document, store=SingleFileMessagePackBackingStore(path))
with self.assertRaises(CultCacheError):
cache.pull_all_backing_stores()
self.assertIsNone(cache.get_key_by_index(document, "sku", "S"))
self.assertEqual(cache.get_all(document), [])
class SimulationFactTests(unittest.TestCase):
def test_simulation_fact_catalog_entry_is_unchanged_by_indexes_to_roles(self) -> None:
import re
from cultnet_py.cultmesh_contracts import SIMULATION_FACT_DOCUMENT_TYPE, simulation_fact_document as document
aliases = {"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"}
self.assertEqual(document.roles, aliases)
self.assertEqual(document.indexes, {})
entry = document.catalog_entry()
snake = lambda name: re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower() # noqa: E731
twin = define_database_entry_type(
SIMULATION_FACT_DOCUMENT_TYPE,
[(snake(m.member_name), m.slot) for m in entry.members],
name="fact_id",
indexes=aliases,
schema_name=entry.schema_name,
schema_version=entry.schema_version,
)
self.assertEqual(entry.canonical_schema_json, twin.catalog_entry().canonical_schema_json)
self.assertEqual(entry.content_hash, twin.catalog_entry().content_hash)
self.assertEqual(entry.members, twin.catalog_entry().members)
self.assertEqual({m.index_alias for m in entry.members if m.index_alias}, set(aliases))
def test_facts_sharing_every_role_value_are_all_admitted(self) -> None:
from cultnet_py.cultmesh_contracts import CultMeshSimulationFact, simulation_fact_document as document
cache = cache_of(document)
for number in range(3):
fact = CultMeshSimulationFact.from_candidate(
{"shardId": "s", "shardEpoch": 1, "frame": 4, "subjectId": "x", "claimKind": "k", "claimHash": f"h{number}",
"witnessCount": 1, "supportWeight": 1.0, "totalWeight": 1.0, "confidence": 1.0}
)
cache.put(document, f"simulation:{number}", fact)
self.assertEqual(len(cache.get_all(document)), 3)
self.assertIsNone(cache.get_key_by_index(document, "shard", "s"))
class VectorTests(unittest.TestCase):
def test_values_rows(self) -> None:
document = item_doc(indexes={"sku": "sku"}, roles={})
for label, kind, value, text in msgpack.unpackb((VECTORS / "values.msgpack").read_bytes(), raw=False):
with self.subTest(label=label):
self.assertEqual(index_text(value), text)
cache = cache_of(document)
cache.put(document, "item:1", Item(sku=value))
if text is None:
cache.put(document, "item:2", Item(sku=value)) # blank: two records commit
self.assertEqual(len(cache.get_all(document)), 2)
else:
self.assertEqual(cache.get_key_by_index(document, "sku", text), "item:1")
with self.assertRaises(CultCacheError):
cache.put(document, "item:2", Item(sku=value))
def test_store_verdicts(self) -> None:
unit = define_database_entry_type
outcomes = msgpack.unpackb((VECTORS / "outcomes.msgpack").read_bytes(), raw=False)
self.assertEqual(len(outcomes), 3)
for name, alias, verdict, key_a, key_b in outcomes:
for roles_first in (False, True): # the verdict does not depend on declaration order
with self.subTest(file=name, alias=alias, roles_first=roles_first), tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / name
shutil.copyfile(VECTORS / name, path)
before = path.read_bytes()
fields = [database_entry_field("sku", 0, codec=t.string), database_entry_field("category", 1, codec=t.string)]
declared = {"indexes": {alias: "sku"} if alias else {}, "roles": {} if alias else {"Sku": "sku"}}
if roles_first:
declared = dict(reversed(list(declared.items())))
document = unit(
"tests.unique_item",
fields,
schema_id="sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882",
schema_name="tests.unique_item",
**declared,
)
cache = cache_of(document, store=SingleFileMessagePackBackingStore(path))
if verdict == "open":
cache.pull_all_backing_stores()
self.assertGreaterEqual(len(cache.get_all(document)), 2)
else:
with self.assertRaises(CultCacheError) as caught:
cache.pull_all_backing_stores()
self.assertEqual(str(caught.exception), refusal(key_a, key_b, "tests.unique_item", alias))
self.assertNotIn("S-1", str(caught.exception))
self.assertEqual(cache.snapshot(), {})
self.assertEqual(path.read_bytes(), before)
def test_refusals(self) -> None:
ids = {
"tests.unique_item": "sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882",
"tests.uq_pair": "sha256:2538cceafb6dcbce22ef11b932ecb804af5f96bc3aaedff692a52d3640433e60",
"tests.uq_alias": "sha256:77a77a394ebd8bf71e14b9b6f2cea4978c25d4e705fd12364f660edbf6c63b1f",
"tests.uq_wide": "sha256:b79eecd11eed5e456902f095d2748da7f59125c4c8972b0da7dddc81d07fc03f",
"tests.uq_alpha": "sha256:53aeaf2ea9b25e85ade6b15fc04c7653e7b206c87e258f1aa74dd32a840a1389",
"tests.uq_beta": "sha256:45ed62a64c91f6d815d3ec975c4a4a1b2ce07b6e7262a32d828673cf10899586",
}
aliases = {
"tests.uq_pair": {"Sku": "sku", "Alt": "second"},
"tests.uq_alias": {"Sku": "sku", "alt": "second"},
"tests.uq_wide": {"\uff21": "sku", "\U00010400": "second"}, # U+FF21 and U+10400: code-unit order puts the astral alias first
}
documents = {
name: define_database_entry_type(
name,
[database_entry_field("sku", 0, codec=t.string), database_entry_field("second", 1, codec=t.string)],
indexes=aliases.get(name, {"Sku": "sku"}),
roles={},
schema_id=schema_id,
schema_name=name,
)
for name, schema_id in ids.items()
}
rows = msgpack.unpackb((VECTORS / "refusals.msgpack").read_bytes(), raw=False)
walked = 0
for label, ops, expected in rows:
if any(op[0] == "commit" and len({schema for _, schema, _, _ in op[1]}) > 1 for op in ops):
continue # put_envelopes takes one type: no batch across types (rows 3 and 26)
with self.subTest(label=label), tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.cc"
state: dict[str, CultCache] = {}
def perform(op) -> None:
if op[0] == "open":
if op[1] is not None:
shutil.copyfile(VECTORS / op[1], path)
state["cache"] = cache_of(*documents.values(), store=SingleFileMessagePackBackingStore(path))
state["cache"].pull_all_backing_stores()
elif op[0] == "pull":
shutil.copyfile(VECTORS / op[1], path)
state["cache"].pull_all_backing_stores()
else:
document = documents[op[1][0][1]]
state["cache"].put_envelopes(
document, [envelope(document, key, {"sku": sku, "second": second}) for key, _, sku, second in op[1]]
)
for op in ops[:-1]:
perform(op)
if expected is None:
perform(ops[-1])
else:
last = ops[-1]
if last[0] == "open":
before = (VECTORS / last[1]).read_bytes()
elif last[0] == "pull":
before = (VECTORS / last[1]).read_bytes()
else:
before = path.read_bytes() if path.exists() else b""
k1, k2, schema, alias = expected
with self.assertRaises(CultCacheError) as caught:
perform(last)
self.assertEqual(str(caught.exception), refusal(k1, k2, schema, alias))
self.assertEqual(path.read_bytes() if path.exists() else b"", before)
walked += 1
self.assertEqual(walked, 35)
class ListStore(RecordingStore):
"""A store whose contents the test swaps between loads: another process rewrote the file."""
def __init__(self, envelopes=()) -> None:
super().__init__()
self.envelopes = list(envelopes)
def pull_all(self):
return list(self.envelopes)
class BatchAndOrderTests(unittest.TestCase):
def test_a_batch_naming_a_key_twice_lands_the_last_record_and_keeps_every_held_lookup(self) -> None:
# C# CultCacheBatch stages one operation per key, the last one named.
document = item_doc()
store = RecordingStore()
cache = cache_of(document, store=store)
cache.put(document, "item:h", Item(sku="S"))
store.pushed.clear()
cache.put_envelopes(
document, [envelope(document, "item:k", Item(sku="S")), envelope(document, "item:k", Item(sku="T"))]
)
self.assertEqual(store.pushed, ["item:k"])
self.assertEqual(cache.get_required(document, "item:k").sku, "T")
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:h")
self.assertEqual(cache.get_key_by_index(document, "sku", "T"), "item:k")
with self.assertRaises(CultCacheError) as caught:
cache.put(document, "item:z", Item(sku="S"))
self.assertEqual(str(caught.exception), refusal("item:h", "item:z", "item", "sku"))
def test_a_batch_naming_a_key_twice_is_judged_on_the_record_that_lands(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:h", Item(sku="S"))
# the first record for k would collide with h; only the last lands, so the batch is admitted
cache.put_envelopes(
document, [envelope(document, "item:k", Item(sku="S")), envelope(document, "item:k", Item(sku="T"))]
)
# and the reverse order lands the colliding record, which is refused
with self.assertRaises(CultCacheError):
cache.put_envelopes(
document, [envelope(document, "item:j", Item(sku="U")), envelope(document, "item:j", Item(sku="S"))]
)
self.assertNotIn("item:j", cache.snapshot().get("item", {}))
def test_a_load_judges_every_type_at_once_aliases_in_ordinal_order(self) -> None:
first = item_doc("first", indexes={"zeta": "sku"}, roles={})
second = item_doc("second", indexes={"alpha": "sku"}, roles={})
loose_first, loose_second = item_doc("first", indexes={}, roles={}), item_doc("second", indexes={}, roles={})
store = ListStore(
[envelope(loose_first, "f:1", Item(sku="S")), envelope(loose_first, "f:2", Item(sku="S"))]
+ [envelope(loose_second, "s:1", Item(sku="S")), envelope(loose_second, "s:2", Item(sku="S"))]
)
cache = cache_of(first, second, store=store)
with self.assertRaises(CultCacheError) as caught:
cache.pull_all_backing_stores()
self.assertEqual(str(caught.exception), refusal("s:1", "s:2", "second", "alpha"))
def test_aliases_sort_by_utf16_code_unit_not_code_point(self) -> None:
astral, bmp = "k\U0001F600", "k\uff01" # code points: bmp < astral; UTF-16 units: D83D < FF01
document = item_doc(indexes={bmp: "sku", astral: "serial"}, roles={})
cache = cache_of(document)
rows = [Item(sku="S", serial="T"), Item(sku="S", serial="T")]
with self.assertRaises(CultCacheError) as caught:
cache.put_envelopes(document, [envelope(document, f"item:{n}", row) for n, row in enumerate(rows)])
self.assertEqual(str(caught.exception), refusal("item:0", "item:1", "item", astral))
def test_a_load_names_the_least_violation_whatever_order_the_store_yields(self) -> None:
alpha, beta = item_doc("alpha", indexes={"k": "sku"}, roles={}), item_doc("beta", indexes={"k": "sku"}, roles={})
loose_alpha, loose_beta = item_doc("alpha", indexes={}, roles={}), item_doc("beta", indexes={}, roles={})
store = ListStore(
[
envelope(loose_alpha, "a:0", Item(sku="Q")),
envelope(loose_beta, "b:1", Item(sku="S")),
envelope(loose_beta, "b:2", Item(sku="S")),
envelope(loose_alpha, "a:1", Item(sku="P")),
envelope(loose_alpha, "a:2", Item(sku="P")),
]
)
cache = cache_of(alpha, beta, store=store)
with self.assertRaises(CultCacheError) as caught:
cache.pull_all_backing_stores()
self.assertEqual(str(caught.exception), refusal("a:1", "a:2", "alpha", "k"))
def test_register_index_refuses_an_alias_the_definition_declares_as_a_role(self) -> None:
document = item_doc()
cache = cache_of(document)
with self.assertRaises(CultCacheError):
cache.register_index(document, "group", "group")
cache.put_envelopes(
document, [envelope(document, "item:1", Item(group="guide")), envelope(document, "item:2", Item(group="guide"))]
)
class LoadFreshnessTests(unittest.TestCase):
def test_a_load_is_judged_on_the_file_alone_not_against_the_held_map(self) -> None:
document = item_doc()
store = ListStore([envelope(document, "item:b", Item(sku="S")), envelope(document, "item:c", Item(sku="T"))])
cache = cache_of(document, store=store)
cache.pull_all_backing_stores()
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b")
# another process moved S from b to a, and b to T's old place
store.envelopes = [envelope(document, "item:a", Item(sku="S")), envelope(document, "item:b", Item(sku="U")), store.envelopes[1]]
cache.pull_all_backing_stores()
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:a")
self.assertEqual(cache.get_key_by_index(document, "sku", "U"), "item:b")
class DefinitionTests(unittest.TestCase):
def test_every_constructor_refuses_an_alias_in_both_indexes_and_roles(self) -> None:
from dataclasses import replace
from cultcache_py import DocumentDefinition
with self.assertRaisesRegex(ValueError, "both an index and a role"):
DocumentDefinition(type="item", indexes={"sku": "sku"}, roles={"sku": "sku"})
with self.assertRaisesRegex(ValueError, "both an index and a role"):
replace(item_doc(roles={}), roles={"sku": "sku"})
if __name__ == "__main__":
unittest.main()

View file

@ -8,12 +8,33 @@ only in git history.
### Breaking ### Breaking
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name. Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0).
- A record whose payload carries unread slots is read-only. A payload with a slot past its type's declared slots, at any depth (record, nested record, list element, union arm, unit arm), still decodes, and every write or removal of its key is now refused before any store is touched: `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` (the whole batch) and `delete`, so `update` too. The refusal is `SchemaConflictError` with the new `unread_slots`; its message names the type and both slot counts and never a slot value. Holders of such a record used to overwrite it and drop the extra slots silently. The payload nesting refusal still runs first. A held payload this build cannot read at all (an arm it lacks from a newer writer, or a corrupt record) is read-only too: every write or removal is refused with an error naming the type and key (a newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, as no cache call removes it), and `is_writable` answers false. `load_soa` is a write of every held key it omits or changes, so it refuses to drop or replace a read-only record. `is_writable` also answers false for a key a store carries as a foreign record and for a type other than the one holding the key, as the writes do. Each write over a held key decodes the held payload once to ask (about 40% of a `put` of a 200,000-element record, measured in release); keys nothing holds pay nothing.
- cultmesh-rs `pull_rudp_catalog_snapshot` applies documents one by one with `put_raw_envelope`, so a snapshot document for a key the node holds read-only ends the pull at that document and the documents before it stay applied. C# applies a raw snapshot as one cache commit, so there a refusal applies nothing: this is a divergence, owed by follow-up `mesh-snapshot-atomic` (stage the pull through `put_prepared_batch`).
- `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.
- A cache with a backing store also refuses every write and removal, with `CultCacheLoadRefused`, until it has pulled: a cache that never pulled, or that gained a store since its last pull, holds no image of its stores, and a batch from it replaced a populated store with only the new records. `is_writable` answers false meanwhile. A cache with no store needs no pull. Callers that attach a store and write (cultmesh nodes created with `pull_on_start: false`) must call `pull_all_backing_stores` first.
- `decode_payload`, `put_raw_envelope` and prepared batches refuse a payload with bytes after its first MessagePack value (`payload has {n} bytes after its first MessagePack value`), by count; the tail is never walked or quoted. They used to read the first value and keep or drop the tail, so a record with a stray tail was served and rewritten without it. A store holding such a record (single-file, redb, `load_soa`) is refused at load, naming the type, the key and the count, and stays unreadable until the record is repaired; the same check now runs on every record at load, as the C# and Python runtimes do at open. The nesting bound is part of that check, so a stored payload nested past it is refused at load too, where it used to load and refuse on read.
- A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading.
- A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. This reverses 0.3.1, where `push_all` replaced a file with no readable stamp (healing it); the stamp rule now has one owner, the reader every write asks under the lock, and it accepts v1 and v3 (0.3.1 refused v3). - 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.
- `bool` index and lookup text renders `True` / `False`, as C# does, where it rendered `true` / `false`.
- `DatabaseEntry::UNIQUE_INDEXES` replaces `cult_indexes` as the one index declaration and changes type (its members are the unique aliases); a hand implementation overriding `cult_indexes` no longer compiles.
- `register_entry_type` over a type that already holds records re-judges them under the new declaration (unique indexes and the global rule) and refuses, changing nothing, when they violate it. A declaration with an index also refuses when a held record cannot be decoded as the new type; a declaration without one does not read the records, so it does not.
- A single-file store holds one record per key. The v1 and legacy readers refuse a store with two records under one key (`CultCacheStoreUnreadable`, the record `Undecodable`): the store does not open, and a write over it refuses and leaves the file. A consumer that holds such a file must repair it with the runtime that wrote it before opening it here.
- Every single-file writer (`push`, `push_all`, `insert_entry_if_absent`, the compare-exchange and snapshot-append family, and the cache's puts and batches) refuses to put a second type under a key the store holds, with `SchemaConflictError` naming the key and both types, writing nothing. Retyping a key is a delete, then an add (one batch may hold both). A write owns the held record when its schema id equals the id the held record is stored under, as C# `WrittenOwns` asks; a type renamed under a stable id therefore replaces its own record, and a write of another type name under another id is refused (`push` of the same type name under another id still replaces the record, as before; `compare_and_swap_entry`, and a `push_all` batch holding one type under two ids, refuse it). Registered types here declare no compatible ids, so equality is the whole test. Writers used to leave two records under the key.
- `compare_and_swap_entry` over a single file refuses an in-place retype (expected `A/k`, replacement `B/k` under another schema id) with the same `SchemaConflictError`, as `compare_exchange` does, where it used to retype the record and return `true`. A lost race still returns `false` first.
- A cache holds one record per key. `put`, `put_envelope`, `put_raw_envelope` and a batch of a second type under a key the cache holds, or a key a store carries foreign, are refused with `SchemaConflictError` even with no store (a memory-only cache used to hold both), whatever schema id the envelope claims: another registered type's record is never owned. `put_envelope`, `put_raw_envelope`, `put_prepared_batch` and `load_soa` also refuse, at any key, an envelope whose schema id names another type registered in this cache (the id a pull would resolve the record by). An id this cache does not register is trusted as the envelope claims it: at a key this cache does not hold, the single-file store decides ownership by that id, so such an envelope can replace another writer's record stored under it, and a reader registering the type the id names serves the record as that type. Store-level writers called directly (`push`, `insert_entry_if_absent`, compare-and-swap) still trust the claimed id. A refused write or pull changes nothing. Two stores holding one key refuse the pull, naming the key and both types, and so does one store holding two records at a key that resolve to one type. A store holding a record no registered type claims now loads (it used to refuse with "No schema is registered for persisted entry type") and the record is carried, through `put_prepared_batch` too, which lays foreign records back byte for byte; `delete` at a foreign key is refused. `load_soa` refuses a second type at a key or a record at a foreign key, and still refuses an unregistered type. The redb stores still key by `(type, key)`, and their own writers (`push`, `insert_entry_if_absent`, compare-and-swap) still put a second type at a held key: a cache now refuses to pull such a redb store with `SchemaConflictError`, where it used to load both records.
- A write of the same type under a held key replaces the record by key, and in one `push_all` the last given of several entries under a key and a schema id is the record, whatever their type names. The redb stores are unchanged and still key by type and key.
### Added ### Added
- 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 or value find the one with the smallest key. 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`). - `UnreadSlots { type_name, slot_count, declared_count, occurrences }`, `decode_payload_marked` (the decode that returns the mark) and `CultCache::is_writable(type, key)`. The derives' slot visitors note into a thread-local scope the decode entry opens (`__enter_slots`, `__note_unread_slots`, `__UnitArmSlots`, doc-hidden); a decode outside that entry notes nothing.
- `CultCache::foreign_records()` and `ForeignRecord { key, schema_id, schema_name, stored_at }`: the records the last pull found that no registered type claims, sorted by key.
- Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name find the one with the smallest key (an index value is unique, below). A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`).
- `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::<SchemaConflictError>()`. - `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>()`.
## [0.3.1] ## [0.3.1]

View file

@ -21,8 +21,14 @@ CultCache is a domain cache with persistence adapters.
- `CultCache` is the query and mutation surface. - `CultCache` is the query and mutation surface.
- Domain structs implement `DatabaseEntry`. - Domain structs implement `DatabaseEntry`.
- Entries are stored behind a `type::key` identity, so multiple entry types - A cache holds one record per key whatever its type. Records are held per
can share a logical key without colliding. `(type, key)`, but a write of a second type under a key the cache holds, or a
key a store carries foreign, is refused with `SchemaConflictError` naming the
key (no store needed); retyping a key is a delete, then an add. A store
record no registered type claims loads: the cache does not serve it, lists it
in `foreign_records()`, lays it back byte for byte through every write
including `put_prepared_batch`, and refuses a write or delete at its key.
Registering a type that claims its schema id picks it up at the next pull.
- Each payload is encoded directly from the known `DatabaseEntry` type into - Each payload is encoded directly from the known `DatabaseEntry` type into
MessagePack bytes. MessagePack bytes.
- Backing stores are adapters, not the public data model. - Backing stores are adapters, not the public data model.
@ -230,6 +236,17 @@ assert_eq!(rmp_serde::from_slice::<Node>(&bytes)?, tree);
# Ok::<(), Box<dyn std::error::Error>>(()) # 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:
@ -394,6 +411,10 @@ fn outcome(error: &anyhow::Error) {
} }
``` ```
## Store format and rollback
A store whose elements hold an id is marked `cultcache.store.v3` by the C# runtime. This runtime reads and writes a v3 single-file store, keeps the header it read and carries the ids through a rewrite; it never drops the mark, so it has no carry-back. A cultcache-rs from before element ids refuses a v3 store by name, so rolling back to it after a store has been written as v3 is one-way: restore from a copy taken before upgrading, and upgrade every reader before any writer. (The C# line is different: CultLib 1.x does not refuse; see the GameCult.Caching README.)
## Near-Term Ergonomic Improvements ## Near-Term Ergonomic Improvements
1. **Derive macro** 1. **Derive macro**
@ -412,7 +433,9 @@ fn outcome(error: &anyhow::Error) {
3. **Name and index lookups** 3. **Name and index lookups**
- `cache.get_by_name::<T>("Potion")` - `cache.get_by_name::<T>("Potion")`
- `cache.get_by_index::<T>("faction", "Lucent")` - `cache.get_by_index::<T>("callsign", "Lucent-7")`: `#[cultcache(index)]` is unique. Within one type no two
records hold one non-blank value of the alias; a put, batch or load that would make two is refused, naming both
keys and the alias, and writes nothing. A grouping (faction, kind, status) is not an index: filter `get_all`.
- generated extractors instead of reflection strings - generated extractors instead of reflection strings
4. **Schema/version metadata** 4. **Schema/version metadata**
@ -452,7 +475,7 @@ That is the Rust version of the original trick:
- C# refuses to store objects outside the `DatabaseEntry` root type. - C# refuses to store objects outside the `DatabaseEntry` root type.
- Runtime-visible `DatabaseEntry` subclasses get dedicated formatters. - Runtime-visible `DatabaseEntry` subclasses get dedicated formatters.
- Unknown or unregistered types are rejected. - A write of an unregistered type is rejected; a stored record of an unregistered type is carried as foreign, not decoded.
- MessagePack payload bytes deserialize through known concrete formatters, not - MessagePack payload bytes deserialize through known concrete formatters, not
an open-world type loader or generic structured-value bridge. an open-world type loader or generic structured-value bridge.

View file

@ -69,23 +69,16 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
} }
} }
}); });
let index_members = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident))); let index_members: Vec<_> = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident))).collect();
let index_pushes = index_members.map(|(alias, member)| { let index_rows = index_members.iter().map(|(alias, member)| {
quote! { quote! {
if let ::std::option::Option::Some(value) = ::cultcache_rs::CultLookupValue::lookup_value(&self.#member) { (#alias, (|record: &Self| ::cultcache_rs::CultLookupValue::lookup_value(&record.#member))
held.push((#alias, value)); as fn(&Self) -> ::std::option::Option<::std::string::String>)
}
} }
}); });
let index_pushes: Vec<_> = index_pushes.collect(); let index_rows: Vec<_> = index_rows.collect();
let indexes_fn = (!index_pushes.is_empty()).then(|| { let unique_indexes = (!index_rows.is_empty()).then(|| {
quote! { quote! { const UNIQUE_INDEXES: &'static [::cultcache_rs::CultIndexMember<Self>] = &[#(#index_rows),*]; }
fn cult_indexes(&self) -> ::std::vec::Vec<(&'static str, ::std::string::String)> {
let mut held = ::std::vec::Vec::new();
#(#index_pushes)*
held
}
}
}); });
Ok(quote! { Ok(quote! {
@ -93,8 +86,8 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
const TYPE: &'static str = #entry_type; const TYPE: &'static str = #entry_type;
const SCHEMA_NAME: &'static str = #schema_name; const SCHEMA_NAME: &'static str = #schema_name;
const GLOBAL: bool = #global; const GLOBAL: bool = #global;
#unique_indexes
#name_fn #name_fn
#indexes_fn
} }
#codec #codec
@ -388,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 {
@ -397,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),*
}) })
@ -443,7 +445,8 @@ struct CultCacheSlot {
bytes: bool, bytes: bool,
/// `#[cultcache(name)]`: the member `get_by_name` finds the record by. /// `#[cultcache(name)]`: the member `get_by_name` finds the record by.
name: bool, name: bool,
/// `#[cultcache(index)]` or `#[cultcache(index = "alias")]`: the alias `get_by_index` finds the record by. /// `#[cultcache(index)]` or `#[cultcache(index = "alias")]`: a unique lookup. Within one type no two records hold one
/// non-blank value of the alias; `get_by_index` finds the record by it.
index: Option<String>, index: Option<String>,
} }
@ -573,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),
@ -941,15 +947,15 @@ mod tests {
); );
assert!(tokens.contains("constGLOBAL:bool=true"), "{tokens}"); assert!(tokens.contains("constGLOBAL:bool=true"), "{tokens}");
assert!(tokens.contains("fncult_name"), "{tokens}"); assert!(tokens.contains("fncult_name"), "{tokens}");
assert!(tokens.contains("(\"code\",value)"), "{tokens}"); assert!(tokens.contains("(\"code\","), "{tokens}");
assert!(tokens.contains("(\"sku\",value)"), "{tokens}"); assert!(tokens.contains("(\"sku\","), "{tokens}");
} }
#[test] #[test]
fn a_record_without_lookups_generates_none_of_them() { fn a_record_without_lookups_generates_none_of_them() {
let tokens = entry_tokens(r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0)] a: String }"#); let tokens = entry_tokens(r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0)] a: String }"#);
assert!(tokens.contains("constGLOBAL:bool=false"), "{tokens}"); assert!(tokens.contains("constGLOBAL:bool=false"), "{tokens}");
assert!(!tokens.contains("fncult_name") && !tokens.contains("fncult_indexes"), "{tokens}"); assert!(!tokens.contains("fncult_name") && !tokens.contains("UNIQUE_INDEXES"), "{tokens}");
} }
// ResolveIndexAlias: a blank alias is the member's name. // ResolveIndexAlias: a blank alias is the member's name.
@ -958,7 +964,7 @@ mod tests {
let tokens = entry_tokens( let tokens = entry_tokens(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = " ")] code: String }"#, r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = " ")] code: String }"#,
); );
assert!(tokens.contains("(\"code\",value)"), "{tokens}"); assert!(tokens.contains("(\"code\","), "{tokens}");
} }
#[test] #[test]

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -11,7 +11,7 @@ use cultcache_rs::{
struct Item { struct Item {
#[cultcache(key = 0, name)] #[cultcache(key = 0, name)]
name: String, name: String,
#[cultcache(key = 1, index)] #[cultcache(key = 1)]
category: String, category: String,
#[cultcache(key = 2, index = "sku")] #[cultcache(key = 2, index = "sku")]
code: Option<String>, code: Option<String>,
@ -30,7 +30,7 @@ struct Gadget {
#[cultcache(key = 0, name)] #[cultcache(key = 0, name)]
name: String, name: String,
#[cultcache(key = 1, index)] #[cultcache(key = 1, index)]
category: String, serial: String,
} }
/// Sorts after `Item` and has its exact shape: a lookup that ran past the end of its own type would answer from it. /// Sorts after `Item` and has its exact shape: a lookup that ran past the end of its own type would answer from it.
@ -39,7 +39,7 @@ struct Gadget {
struct Zed { struct Zed {
#[cultcache(key = 0, name)] #[cultcache(key = 0, name)]
name: String, name: String,
#[cultcache(key = 1, index)] #[cultcache(key = 1)]
category: String, category: String,
#[cultcache(key = 2, index = "sku")] #[cultcache(key = 2, index = "sku")]
code: Option<String>, code: Option<String>,
@ -48,7 +48,7 @@ struct Zed {
} }
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "settings", global)] #[cultcache(type = "settings", schema = "tests.settings", global)]
struct Settings { struct Settings {
#[cultcache(key = 0)] #[cultcache(key = 0)]
value: String, value: String,
@ -71,7 +71,7 @@ struct Numbered {
#[cultcache(key = 0, name)] #[cultcache(key = 0, name)]
id: u32, id: u32,
#[cultcache(key = 1, index)] #[cultcache(key = 1, index)]
owner: u64, badge: u64,
} }
fn cache() -> CultCache { fn cache() -> CultCache {
@ -110,7 +110,6 @@ fn a_record_is_found_by_name_and_by_index_after_a_reopen() {
let reopened = reopened(&path); let reopened = reopened(&path);
let by_name = reopened.get_by_name::<Item>("Potion").unwrap().unwrap(); let by_name = reopened.get_by_name::<Item>("Potion").unwrap().unwrap();
assert_eq!(by_name.value, 50); assert_eq!(by_name.value, 50);
assert_eq!(reopened.get_by_index::<Item>("category", "Consumable").unwrap(), Some(by_name.clone()));
assert_eq!(reopened.get_by_index::<Item>("sku", "P-1").unwrap(), Some(by_name)); assert_eq!(reopened.get_by_index::<Item>("sku", "P-1").unwrap(), Some(by_name));
} }
@ -121,31 +120,32 @@ fn a_missing_name_or_value_is_none_and_an_unregistered_type_is_refused() {
assert_eq!(cache.get_by_name::<Item>("B").unwrap(), None); assert_eq!(cache.get_by_name::<Item>("B").unwrap(), None);
assert_eq!(cache.get_by_name::<Item>("a").unwrap(), None, "names compare ordinally"); assert_eq!(cache.get_by_name::<Item>("a").unwrap(), None, "names compare ordinally");
assert_eq!(cache.get_by_index::<Item>("category", "other").unwrap(), None); assert_eq!(cache.get_by_index::<Item>("sku", "other").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("nosuchalias", "cat").unwrap(), None); assert_eq!(cache.get_by_index::<Item>("nosuchalias", "a-1").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", "cat").unwrap(), None, "category is a grouping, not an index");
assert_eq!(cache.get_by_index::<Item>("code", "a-1").unwrap(), None, "the alias is sku, not the member name"); assert_eq!(cache.get_by_index::<Item>("code", "a-1").unwrap(), None, "the alias is sku, not the member name");
let bare = CultCache::new(); let bare = CultCache::new();
assert!(bare.get_by_name::<Item>("A").is_err()); assert!(bare.get_by_name::<Item>("A").is_err());
assert!(bare.get_by_index::<Item>("category", "cat").is_err()); assert!(bare.get_by_index::<Item>("sku", "a-1").is_err());
assert!(bare.get_global::<Settings>().is_err()); assert!(bare.get_global::<Settings>().is_err());
} }
#[test] #[test]
fn an_index_without_an_alias_is_named_by_its_member() { fn an_index_without_an_alias_is_named_by_its_member() {
let mut cache = cache(); let mut cache = cache();
cache.put("item:a", &item("A", "cat", None)).unwrap(); cache.put("numbered:1", &Numbered { id: 1, badge: 7 }).unwrap();
assert!(cache.get_by_index::<Item>("category", "cat").unwrap().is_some()); assert!(cache.get_by_index::<Numbered>("badge", "7").unwrap().is_some());
} }
// A blank name is no name; a blank index value is not indexed; a nil member holds no value. // A blank name is no name; a blank index value is not indexed; a nil member holds no value.
#[test] #[test]
fn blank_and_nil_values_are_not_found() { fn blank_and_nil_values_are_not_found() {
let mut cache = cache(); let mut cache = cache();
cache.put("item:blank", &item("", " ", None)).unwrap(); cache.put("item:blank", &item("", " ", Some(" "))).unwrap();
assert_eq!(cache.get_by_name::<Item>("").unwrap(), None); assert_eq!(cache.get_by_name::<Item>("").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", " ").unwrap(), None); assert_eq!(cache.get_by_index::<Item>("sku", " ").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", "").unwrap(), None); assert_eq!(cache.get_by_index::<Item>("sku", "").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("sku", "").unwrap(), None); assert_eq!(cache.get_by_index::<Item>("sku", "").unwrap(), None);
// Only emptiness voids a name: whitespace is a name, as in C# (Length > 0). // Only emptiness voids a name: whitespace is a name, as in C# (Length > 0).
@ -156,68 +156,41 @@ fn blank_and_nil_values_are_not_found() {
#[test] #[test]
fn lookups_never_cross_types() { fn lookups_never_cross_types() {
let mut cache = cache(); let mut cache = cache();
cache.put("item:a", &item("same", "same", None)).unwrap(); cache.put("item:a", &item("same", "same", Some("same"))).unwrap();
cache.put("gadget:a", &Gadget { name: "same".into(), category: "same".into() }).unwrap(); cache.put("gadget:a", &Gadget { name: "same".into(), serial: "same".into() }).unwrap();
cache.put("zed:a", &Zed { name: "same".into(), category: "same".into(), code: None, value: 0 }).unwrap(); cache.put("zed:a", &Zed { name: "same".into(), category: "same".into(), code: None, value: 0 }).unwrap();
assert_eq!(cache.get_by_name::<Gadget>("same").unwrap().unwrap().name, "same"); assert_eq!(cache.get_by_name::<Gadget>("same").unwrap().unwrap().name, "same");
cache.delete::<Item>("item:a").unwrap(); cache.delete::<Item>("item:a").unwrap();
assert_eq!(cache.get_by_name::<Item>("same").unwrap(), None); assert_eq!(cache.get_by_name::<Item>("same").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", "same").unwrap(), None); assert_eq!(cache.get_by_index::<Item>("sku", "same").unwrap(), None);
assert!(cache.get_by_name::<Gadget>("same").unwrap().is_some()); assert!(cache.get_by_name::<Gadget>("same").unwrap().is_some());
assert!(cache.get_by_index::<Gadget>("category", "same").unwrap().is_some()); assert!(cache.get_by_index::<Gadget>("serial", "same").unwrap().is_some());
} }
#[test] #[test]
fn a_name_and_index_follow_a_rewritten_record_and_leave_a_removed_one() { fn a_name_and_index_follow_a_rewritten_record_and_leave_a_removed_one() {
let mut cache = cache(); let mut cache = cache();
cache.put("item:a", &item("old", "c1", None)).unwrap(); cache.put("item:a", &item("old", "cat", Some("c1"))).unwrap();
cache.put("item:a", &item("new", "c2", None)).unwrap(); cache.put("item:a", &item("new", "cat", Some("c2"))).unwrap();
assert_eq!(cache.get_by_name::<Item>("old").unwrap(), None, "a stale name is not found"); assert_eq!(cache.get_by_name::<Item>("old").unwrap(), None, "a stale name is not found");
assert_eq!(cache.get_by_index::<Item>("category", "c1").unwrap(), None, "a stale value is not found"); assert_eq!(cache.get_by_index::<Item>("sku", "c1").unwrap(), None, "a stale value is not found");
assert!(cache.get_by_name::<Item>("new").unwrap().is_some()); assert!(cache.get_by_name::<Item>("new").unwrap().is_some());
assert!(cache.get_by_index::<Item>("category", "c2").unwrap().is_some()); assert!(cache.get_by_index::<Item>("sku", "c2").unwrap().is_some());
cache.delete::<Item>("item:a").unwrap(); cache.delete::<Item>("item:a").unwrap();
assert_eq!(cache.get_by_name::<Item>("new").unwrap(), None); assert_eq!(cache.get_by_name::<Item>("new").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", "c2").unwrap(), None); assert_eq!(cache.get_by_index::<Item>("sku", "c2").unwrap(), None);
} }
#[test] #[test]
fn non_string_members_are_found_by_their_text() { fn non_string_members_are_found_by_their_text() {
let mut cache = cache(); let mut cache = cache();
let owner = 161_u64; let badge = 161_u64;
cache.put("numbered:1", &Numbered { id: 42, owner }).unwrap(); cache.put("numbered:1", &Numbered { id: 42, badge }).unwrap();
assert!(cache.get_by_name::<Numbered>("42").unwrap().is_some()); assert!(cache.get_by_name::<Numbered>("42").unwrap().is_some());
assert!(cache.get_by_index::<Numbered>("owner", "161").unwrap().is_some()); assert!(cache.get_by_index::<Numbered>("badge", "161").unwrap().is_some());
}
// VariantTests.RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable: records sharing a value find the one with
// the smallest key, whatever the write order, live and after a reopen; removing the holder finds the other.
#[test]
fn records_sharing_a_value_find_the_smallest_key_and_removing_it_finds_the_next() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shared.cc");
{
let mut cache = stored_cache(&path);
cache.put("item:p2", &item("p2", "x", None)).unwrap();
cache.put("item:p1", &item("p1", "x", None)).unwrap();
let found = |cache: &CultCache| cache.get_by_index::<Item>("category", "x").unwrap().map(|found| found.name);
assert_eq!(found(&cache).as_deref(), Some("p1"), "the smallest key, though written second");
assert_eq!(reopened(&path).get_by_index::<Item>("category", "x").unwrap().unwrap().name, "p1");
cache.delete::<Item>("item:p1").unwrap();
assert_eq!(found(&cache).as_deref(), Some("p2"), "removing the holder finds the other one");
cache.put("item:p1", &item("p1", "x", None)).unwrap();
assert_eq!(found(&cache).as_deref(), Some("p1"));
cache.delete::<Item>("item:p2").unwrap();
assert_eq!(found(&cache).as_deref(), Some("p1"), "removing the other changes nothing");
cache.delete::<Item>("item:p1").unwrap();
assert_eq!(found(&cache), None);
}
assert_eq!(reopened(&path).get_by_index::<Item>("category", "x").unwrap(), None);
} }
#[test] #[test]
@ -244,6 +217,71 @@ fn a_global_is_none_until_written_and_survives_a_reopen() {
assert_eq!(reopened(&path).get_global::<Settings>().unwrap(), Some(settings("durable authority"))); assert_eq!(reopened(&path).get_global::<Settings>().unwrap(), Some(settings("durable authority")));
} }
/// `Settings` as an ordinary type: the same type id and schema, no global rule.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "settings", schema = "tests.settings")]
struct LooseSettings {
#[cultcache(key = 0)]
value: String,
}
// Declaring a held type global judges the records it holds: two are already a second, so the registration is
// refused and the type stays ordinary.
#[test]
fn registering_a_type_global_over_two_held_records_is_refused_and_changes_nothing() {
let mut cache = CultCache::new();
cache.register_entry_type::<LooseSettings>().unwrap();
cache.put("a", &LooseSettings { value: "one".into() }).unwrap();
cache.put("b", &LooseSettings { value: "two".into() }).unwrap();
let refusal = cache.register_entry_type::<Settings>().unwrap_err();
assert!(refusal.downcast_ref::<CultCacheSecondGlobal>().is_some(), "{refusal:#}");
cache.put("c", &LooseSettings { value: "three".into() }).unwrap();
assert_eq!(cache.get_all_with_keys::<LooseSettings>().unwrap().len(), 3, "still ordinary");
}
// One held record may become the global; a second is then refused as a second global.
#[test]
fn registering_a_type_global_over_one_held_record_is_admitted() {
let mut cache = CultCache::new();
cache.register_entry_type::<LooseSettings>().unwrap();
cache.put("a", &LooseSettings { value: "one".into() }).unwrap();
cache.register_entry_type::<Settings>().unwrap();
let refusal = cache.put("b", &Settings { value: "two".into() }).unwrap_err();
assert!(refusal.downcast_ref::<CultCacheSecondGlobal>().is_some(), "{refusal:#}");
}
/// A global with an indexed member, and the same type and schema declaring that member as an integer.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "indexed_global", schema = "tests.indexed_global", global)]
struct IndexedGlobal {
#[cultcache(key = 0, index = "V")]
v: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "indexed_global", schema = "tests.indexed_global", global)]
struct RetypedGlobal {
#[cultcache(key = 0, index = "V")]
v: i64,
}
// A refused re-registration over an already-global type leaves it global: the rule keeps refusing a second record.
#[test]
fn a_refused_reregistration_leaves_a_global_type_global() {
let mut cache = CultCache::new();
cache.register_entry_type::<IndexedGlobal>().unwrap();
cache.put("a", &IndexedGlobal { v: "x".into() }).unwrap();
assert!(cache.register_entry_type::<RetypedGlobal>().is_err(), "the held text cannot be decoded as an integer");
let refusal = cache.put("b", &IndexedGlobal { v: "y".into() }).unwrap_err();
assert!(refusal.downcast_ref::<CultCacheSecondGlobal>().is_some(), "{refusal:#}");
assert_eq!(cache.get_global::<IndexedGlobal>().unwrap(), Some(IndexedGlobal { v: "x".into() }));
assert_eq!(cache.get_by_index::<IndexedGlobal>("V", "x").unwrap(), cache.get::<IndexedGlobal>("a").unwrap());
}
#[test] #[test]
fn a_type_that_is_not_a_global_has_no_global() { fn a_type_that_is_not_a_global_has_no_global() {
let mut cache = cache(); let mut cache = cache();

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,157 @@ 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());
}
// 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 +718,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 +802,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

@ -0,0 +1,775 @@
//! `#[cultcache(index)]` is unique (`variants:ruling:unique-indexes`; C# `RefuseIndexSharing`): within one type and one
//! alias no two records hold one non-blank value, judged on the result of each admission, on every write path and every
//! load. The refusal text is informational; the contract is the verdict, so most pins assert the verdict and the
//! shared vectors in `tests/vectors/unique-index` pin the text and the canonical index text.
use std::path::{Path, PathBuf};
use cultcache_rs::{
CultCache, CultCacheEnvelope, CultCacheSoaTable, DatabaseEntry, SingleFileMessagePackBackingStore,
};
use serde::Deserialize;
/// The vectors' `tests.unique_item`: Sku is unique, Category a grouping.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "unique_item", schema = "tests.unique_item")]
struct Unique {
#[cultcache(key = 0, index = "Sku")]
sku: String,
#[cultcache(key = 1)]
category: String,
}
/// The same type with no index: a role, which writes the duplicates `Unique` refuses.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "unique_item", schema = "tests.unique_item")]
struct Twin {
#[cultcache(key = 0)]
sku: String,
#[cultcache(key = 1)]
category: String,
}
/// Two unique indexes declared out of alphabetical order, so no ordering is an accident of declaration.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "multi", schema = "Multi")]
struct Multi {
#[cultcache(key = 0, index = "B")]
b: Option<String>,
#[cultcache(key = 1, index = "A")]
a: Option<String>,
}
/// Another type sharing the alias `Sku`: types never conflict.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "other", schema = "Other")]
struct Other {
#[cultcache(key = 0, index = "Sku")]
sku: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "plain", schema = "Plain")]
struct Plain {
#[cultcache(key = 0)]
value: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "vs", schema = "VS")]
struct VString {
#[cultcache(key = 0, index = "V")]
v: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "vi", schema = "VI")]
struct VInt {
#[cultcache(key = 0, index = "V")]
v: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "vu", schema = "VU")]
struct VUint {
#[cultcache(key = 0, index = "V")]
v: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "vb", schema = "VB")]
struct VBool {
#[cultcache(key = 0, index = "V")]
v: bool,
}
/// The refusals vector's other schemas: `Sku` is unique in each; `Alt` is a second unique index in `uq_pair` (alias
/// `Alt`) and `uq_alias` (alias `alt`, sorting after `Sku` only under an ordinal compare); alpha and beta are plain.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "uq_pair", schema = "tests.uq_pair")]
struct UqPair {
#[cultcache(key = 0, index = "Sku")]
sku: String,
#[cultcache(key = 1, index = "Alt")]
alt: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "uq_alias", schema = "tests.uq_alias")]
struct UqAlias {
#[cultcache(key = 0, index = "Sku")]
sku: String,
#[cultcache(key = 1, index = "alt")]
alt: String,
}
/// Aliases U+FF21 and U+10400: UTF-16 puts the astral alias first (D801 DC00 < FF21), code-point order the other way.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "uq_wide", schema = "tests.uq_wide")]
struct UqWide {
#[cultcache(key = 0, index = "\u{FF21}")]
sku: String,
#[cultcache(key = 1, index = "\u{10400}")]
alt: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "uq_alpha", schema = "tests.uq_alpha")]
struct UqAlpha {
#[cultcache(key = 0, index = "Sku")]
sku: String,
#[cultcache(key = 1)]
second: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "uq_beta", schema = "tests.uq_beta")]
struct UqBeta {
#[cultcache(key = 0, index = "Sku")]
sku: String,
#[cultcache(key = 1)]
second: String,
}
fn unique(sku: &str) -> Unique {
Unique { sku: sku.into(), category: "c".into() }
}
fn multi(a: Option<&str>, b: Option<&str>) -> Multi {
Multi { a: a.map(Into::into), b: b.map(Into::into) }
}
fn refusal(k1: &str, k2: &str, schema: &str, alias: &str) -> String {
format!("Records {k1} and {k2} ({schema}) both hold unique index '{alias}'; an index value names one record.")
}
fn cache_at(path: &Path) -> CultCache {
let mut cache = CultCache::new();
cache.register_entry_type::<Unique>().unwrap();
cache.register_entry_type::<Multi>().unwrap();
cache.register_entry_type::<Other>().unwrap();
cache.register_entry_type::<Plain>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
cache.pull_all_backing_stores().unwrap();
cache
}
fn memory_cache() -> CultCache {
let mut cache = CultCache::new();
cache.register_entry_type::<Unique>().unwrap();
cache.register_entry_type::<Multi>().unwrap();
cache.register_entry_type::<Other>().unwrap();
cache
}
fn twin_cache_at(path: &Path) -> CultCache {
let mut cache = CultCache::new();
cache.register_entry_type::<Twin>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
cache.pull_all_backing_stores().unwrap();
cache
}
fn error_text(result: anyhow::Result<impl std::fmt::Debug>) -> String {
format!("{:#}", result.unwrap_err())
}
fn skus(cache: &CultCache) -> Vec<(String, String)> {
cache.get_all_with_keys::<Unique>().unwrap().into_iter().map(|(key, value)| (key, value.sku)).collect()
}
#[test]
fn the_derive_emits_the_unique_aliases() {
fn aliases<T: DatabaseEntry>() -> Vec<&'static str> {
T::UNIQUE_INDEXES.iter().map(|(alias, _)| *alias).collect()
}
assert_eq!(aliases::<Unique>(), ["Sku"]);
assert_eq!(aliases::<Multi>(), ["B", "A"]);
assert!(aliases::<Plain>().is_empty());
}
#[test]
fn a_put_of_a_held_value_at_another_key_is_refused_and_changes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("u.cc");
let mut cache = cache_at(&path);
cache.put("item:b", &unique("S-1")).unwrap();
let bytes = std::fs::read(&path).unwrap();
let text = error_text(cache.put("item:a", &unique("S-1")));
assert_eq!(text, refusal("item:a", "item:b", "tests.unique_item", "Sku"));
assert!(!text.contains("S-1"), "the refusal never names the value");
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the store is untouched");
assert_eq!(cache.get::<Unique>("item:a").unwrap(), None);
assert_eq!(cache.get_by_index::<Unique>("Sku", "S-1").unwrap().unwrap().sku, "S-1");
assert_eq!(cache.get_by_index::<Unique>("Sku", "S-1").unwrap(), cache.get::<Unique>("item:b").unwrap());
}
// The old bug: write b then a sharing a value used to answer whichever key sorted first.
#[test]
fn the_first_holder_keeps_the_value_whatever_the_key_order() {
let mut cache = memory_cache();
cache.put("item:z", &unique("S")).unwrap();
assert!(cache.put("item:a", &unique("S")).is_err());
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap().unwrap(), unique("S"));
assert_eq!(skus(&cache), vec![("item:z".to_string(), "S".to_string())]);
}
#[test]
fn a_record_rewriting_its_own_key_keeps_its_value_and_a_change_releases_the_old_one() {
let mut cache = memory_cache();
cache.put("k", &unique("S")).unwrap();
cache.put("k", &Unique { sku: "S".into(), category: "other".into() }).unwrap();
assert!(cache.get_by_index::<Unique>("Sku", "S").unwrap().is_some());
cache.put("k", &unique("T")).unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None, "a stale value is not found");
cache.put("j", &unique("S")).unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap().unwrap().sku, "S");
assert_eq!(cache.get_by_index::<Unique>("Sku", "T").unwrap().unwrap().sku, "T");
// Updating through `update` is the same rule.
assert!(cache.update::<Unique, _>("k", |_| unique("S")).is_err());
assert_eq!(cache.get::<Unique>("k").unwrap().unwrap().sku, "T");
}
#[test]
fn delete_releases_a_value() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.cc");
let mut cache = cache_at(&path);
cache.put("item:a", &unique("S")).unwrap();
assert!(cache.put("item:b", &unique("S")).is_err());
assert!(cache.delete::<Unique>("item:a").unwrap());
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None);
cache.put("item:b", &unique("S")).unwrap();
assert!(cache.get_by_index::<Unique>("Sku", "S").unwrap().is_some());
assert!(!cache.delete::<Unique>("item:a").unwrap(), "deleting an absent key releases nothing");
assert!(cache.get_by_index::<Unique>("Sku", "S").unwrap().is_some());
assert!(cache_at(&path).get_by_index::<Unique>("Sku", "S").unwrap().is_some());
}
#[test]
fn types_sharing_an_alias_and_a_value_do_not_conflict() {
let mut cache = memory_cache();
cache.put("a", &unique("S")).unwrap();
cache.put("oa", &Other { sku: "S".into() }).unwrap();
cache.put("ob", &Other { sku: "T".into() }).unwrap();
assert!(cache.put("ob", &Other { sku: "S".into() }).is_err());
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap().unwrap(), unique("S"));
assert_eq!(cache.get_by_index::<Other>("Sku", "S").unwrap().unwrap(), Other { sku: "S".into() });
}
#[test]
fn an_alias_the_type_does_not_declare_finds_nothing() {
let mut cache = memory_cache();
cache.put("a", &unique("S")).unwrap();
assert_eq!(cache.get_by_index::<Unique>("sku", "S").unwrap(), None);
assert_eq!(cache.get_by_index::<Unique>("Category", "c").unwrap(), None);
}
#[test]
fn blank_and_none_values_never_conflict() {
let mut cache = memory_cache();
for (key, sku) in [("a", " "), ("b", ""), ("c", "\u{85}"), ("d", "\t\n\u{a0}\u{3000}")] {
cache.put(key, &unique(sku)).unwrap();
}
cache.put("m1", &multi(None, None)).unwrap();
cache.put("m2", &multi(None, Some(" "))).unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", " ").unwrap(), None);
// U+FEFF, U+001F, U+200B, U+180E are values, never trimmed.
cache.put("e", &unique("\u{feff}")).unwrap();
assert!(cache.put("f", &unique("\u{feff}")).is_err());
cache.put("g", &unique(" S ")).unwrap();
cache.put("h", &unique("S")).unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", " S ").unwrap().unwrap().sku, " S ");
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap().unwrap().sku, "S");
}
// C# `string.IsNullOrWhiteSpace` over every code unit: exactly these 25 are blank (probed at 7760219b, dotnet 10).
#[test]
fn the_blank_set_is_exactly_the_csharp_white_space() {
let blank: Vec<u32> = (0x9..=0xD)
.chain([0x20, 0x85, 0xA0, 0x1680])
.chain(0x2000..=0x200A)
.chain([0x2028, 0x2029, 0x202F, 0x205F, 0x3000])
.collect();
assert_eq!(blank.len(), 25);
let mut cache = memory_cache();
let mut found = Vec::new();
for unit in (0..=0xFFFF_u32).filter(|unit| char::from_u32(*unit).is_some()) {
let text = char::from_u32(unit).unwrap().to_string();
cache.put("a", &unique(&text)).unwrap();
let second = cache.put("b", &unique(&text));
if second.is_ok() {
found.push(unit);
cache.delete::<Unique>("b").unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", &text).unwrap(), None, "U+{unit:04X} blank is never found");
} else {
assert_eq!(cache.get_by_index::<Unique>("Sku", &text).unwrap().unwrap().sku, text);
}
}
assert_eq!(found, blank);
}
fn staged<T: DatabaseEntry>(cache: &CultCache, rows: &[(&str, T)]) -> Vec<CultCacheEnvelope> {
rows.iter().map(|(key, value)| cache.prepare_entry(*key, value).unwrap().0).collect()
}
// Two unique indexes, collision on the second-declared, through every write path.
#[test]
fn every_write_path_refuses_and_changes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("paths.cc");
let mut cache = cache_at(&path);
cache.put("m:held", &multi(Some("a1"), Some("b1"))).unwrap();
let bytes = std::fs::read(&path).unwrap();
let before = cache.snapshot();
let clash_b = multi(Some("a2"), Some("b1"));
let clash_a = multi(Some("a1"), Some("b2"));
for (clash, alias) in [(&clash_b, "B"), (&clash_a, "A")] {
let pick = |text: &str| {
let (first, second) = if "m:new" < "m:held" { ("m:new", "m:held") } else { ("m:held", "m:new") };
assert_eq!(text, refusal(first, second, "Multi", alias));
};
pick(&error_text(cache.put("m:new", clash)));
pick(&error_text(cache.update::<Multi, _>("m:new", |_| clash.clone())));
let envelope = staged(&cache, &[("m:new", clash.clone())]).remove(0);
pick(&error_text(cache.put_envelope::<Multi>(envelope.clone())));
pick(&error_text(cache.put_raw_envelope(envelope.clone())));
pick(&error_text(cache.put_prepared_batch(vec![envelope])));
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert_eq!(cache.snapshot(), before);
assert_eq!(cache.get_by_index::<Multi>("B", "b2").unwrap(), None);
}
// A clean row through every path lands and is found by both aliases.
let fine = multi(Some("a9"), Some("b9"));
cache.put("m:1", &fine).unwrap();
let envelope = staged(&cache, &[("m:2", multi(Some("a8"), Some("b8")))]).remove(0);
cache.put_envelope::<Multi>(envelope).unwrap();
let envelope = staged(&cache, &[("m:3", multi(Some("a7"), Some("b7")))]).remove(0);
cache.put_raw_envelope(envelope).unwrap();
cache.put_prepared_batch(staged(&cache, &[("m:4", multi(Some("a6"), Some("b6")))])).unwrap();
for (alias, value, key) in [("A", "a9", 1), ("B", "b8", 2), ("A", "a7", 3), ("B", "b6", 4)] {
let found = cache.get_by_index::<Multi>(alias, value).unwrap().unwrap();
assert_eq!(Some(found), cache.get::<Multi>(&format!("m:{key}")).unwrap());
}
// Held values survive a reopen of the store: the load installs the same holders.
let mut reopened = cache_at(&path);
assert!(reopened.put("m:x", &multi(None, Some("b9"))).is_err());
assert!(reopened.put("m:x", &multi(Some("a6"), None)).is_err());
assert!(reopened.put("m:x", &multi(Some("zz"), Some("yy"))).is_ok());
}
#[test]
fn a_batch_is_judged_whole() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("batch.cc");
let mut cache = cache_at(&path);
cache.put("a", &unique("X")).unwrap();
cache.put("b", &unique("Y")).unwrap();
// A swap inside one batch lands.
cache.put_prepared_batch(staged(&cache, &[("a", unique("Y")), ("b", unique("X"))])).unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "Y").unwrap(), cache.get::<Unique>("a").unwrap());
assert_eq!(cache.get_by_index::<Unique>("Sku", "X").unwrap(), cache.get::<Unique>("b").unwrap());
assert_eq!(cache_at(&path).get_by_index::<Unique>("Sku", "X").unwrap().unwrap().sku, "X");
// A batch taking a value a record being replaced just released lands; one holding a value twice is refused whole.
cache.put_prepared_batch(staged(&cache, &[("a", unique("Z")), ("c", unique("Y"))])).unwrap();
let bytes = std::fs::read(&path).unwrap();
let before = cache.snapshot();
let text = error_text(cache.put_prepared_batch(staged(&cache, &[("p", unique("N")), ("q", unique("N")), ("r", unique("W"))])));
assert_eq!(text, refusal("p", "q", "tests.unique_item", "Sku"));
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert_eq!(cache.snapshot(), before);
// A key staged twice lands as its last envelope.
cache.put_prepared_batch(staged(&cache, &[("d", unique("D1")), ("d", unique("D2")), ("e", unique("D1"))])).unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "D2").unwrap(), cache.get::<Unique>("d").unwrap());
assert_eq!(cache.get_by_index::<Unique>("Sku", "D1").unwrap(), cache.get::<Unique>("e").unwrap());
}
#[test]
fn a_load_of_a_duplicate_image_is_refused_and_keeps_the_prior_view() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("load.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();
let bytes = std::fs::read(&path).unwrap();
let before = cache.snapshot();
assert_eq!(error_text(cache.pull_all_backing_stores()), refusal("item:a", "item:b", "tests.unique_item", "Sku"));
assert_eq!(cache.snapshot(), before, "a refused pull admits nothing");
assert_eq!(cache.get_by_index::<Unique>("Sku", "K").unwrap().unwrap().sku, "K");
assert_eq!(std::fs::read(&path).unwrap(), bytes);
// load_soa of the same image through a cache with no store.
let table: CultCacheSoaTable = twin.snapshot_soa();
let mut soa = memory_cache();
soa.put("item:keep", &unique("K")).unwrap();
let before = soa.snapshot();
assert_eq!(error_text(soa.load_soa(table)), refusal("item:a", "item:b", "tests.unique_item", "Sku"));
assert_eq!(soa.snapshot(), before);
assert!(soa.get_by_index::<Unique>("Sku", "K").unwrap().is_some());
// A clean image loads and replaces the holders with the image's.
let mut clean = twin_cache_at(&dir.path().join("clean.cc"));
clean.put("item:x", &Twin { sku: "X".into(), category: "c".into() }).unwrap();
soa.load_soa(clean.snapshot_soa()).unwrap();
assert_eq!(soa.get_by_index::<Unique>("Sku", "K").unwrap(), None);
assert!(soa.get_by_index::<Unique>("Sku", "X").unwrap().is_some());
soa.put("item:y", &unique("K")).unwrap();
}
#[test]
fn a_multi_index_store_with_a_duplicate_on_the_second_alias_refuses_to_open() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("multi.cc");
{
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "multi", schema = "Multi")]
struct Role {
#[cultcache(key = 0)]
b: Option<String>,
#[cultcache(key = 1)]
a: Option<String>,
}
let mut cache = CultCache::new();
cache.register_entry_type::<Role>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
cache.pull_all_backing_stores().unwrap();
cache.put("m:1", &Role { b: Some("b".into()), a: Some("a1".into()) }).unwrap();
cache.put("m:2", &Role { b: Some("b".into()), a: Some("a2".into()) }).unwrap();
}
let mut cache = CultCache::new();
cache.register_entry_type::<Multi>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
assert_eq!(error_text(cache.pull_all_backing_stores()), refusal("m:1", "m:2", "Multi", "B"));
}
#[test]
fn a_bool_index_is_found_by_the_csharp_text() {
let mut cache = CultCache::new();
cache.register_entry_type::<VBool>().unwrap();
cache.put("t", &VBool { v: true }).unwrap();
assert!(cache.put("u", &VBool { v: true }).is_err());
cache.put("f", &VBool { v: false }).unwrap();
assert_eq!(cache.get_by_index::<VBool>("V", "True").unwrap().unwrap().v, true);
assert_eq!(cache.get_by_index::<VBool>("V", "False").unwrap().unwrap().v, false);
assert_eq!(cache.get_by_index::<VBool>("V", "true").unwrap(), None);
assert_eq!(cache.get_by_index::<VBool>("V", "false").unwrap(), None);
}
// ---- shared vectors, written by the C# reference ----
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Cell {
Nil,
Bool(bool),
Int(i64),
Uint(u64),
Double(f64),
Text(String),
}
fn vectors() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/unique-index")
}
type ValueRow = (String, String, Cell, Option<String>);
#[test]
fn vectors_values_give_the_csharp_index_text() {
let bytes = std::fs::read(vectors().join("values.msgpack")).unwrap();
let rows: Vec<ValueRow> = rmp_serde::from_slice(&bytes).unwrap();
assert!(!rows.is_empty());
let mut checked = 0;
for (label, kind, value, text) in rows {
let mut cache = CultCache::new();
cache.register_entry_type::<VString>().unwrap();
cache.register_entry_type::<VInt>().unwrap();
cache.register_entry_type::<VUint>().unwrap();
cache.register_entry_type::<VBool>().unwrap();
// `double` rows are skipped: CultLookupValue has no float impl, so Rust cannot index a double member.
match (kind.as_str(), value) {
("string", Cell::Text(held)) => {
cache.put("one", &VString { v: held.clone() }).unwrap();
match text {
Some(text) => {
assert_eq!(cache.get_by_index::<VString>("V", &text).unwrap().unwrap().v, held, "{label}");
assert!(cache.put("two", &VString { v: held }).is_err(), "{label}");
}
None => {
cache.put("two", &VString { v: held.clone() }).unwrap();
assert_eq!(cache.get_by_index::<VString>("V", &held).unwrap(), None, "{label}");
}
}
}
("int64", Cell::Int(held)) => {
cache.put("one", &VInt { v: held }).unwrap();
assert_eq!(cache.get_by_index::<VInt>("V", &text.unwrap()).unwrap().unwrap().v, held, "{label}");
assert!(cache.put("two", &VInt { v: held }).is_err(), "{label}");
}
("uint64", Cell::Uint(held)) => {
cache.put("one", &VUint { v: held }).unwrap();
assert_eq!(cache.get_by_index::<VUint>("V", &text.unwrap()).unwrap().unwrap().v, held, "{label}");
assert!(cache.put("two", &VUint { v: held }).is_err(), "{label}");
}
("uint64", Cell::Int(held)) => {
cache.put("one", &VUint { v: held as u64 }).unwrap();
assert_eq!(cache.get_by_index::<VUint>("V", &text.unwrap()).unwrap().unwrap().v, held as u64, "{label}");
}
("int64", Cell::Uint(held)) => {
cache.put("one", &VInt { v: held as i64 }).unwrap();
assert_eq!(cache.get_by_index::<VInt>("V", &text.unwrap()).unwrap().unwrap().v, held as i64, "{label}");
}
("bool", Cell::Bool(held)) => {
cache.put("one", &VBool { v: held }).unwrap();
assert_eq!(cache.get_by_index::<VBool>("V", &text.unwrap()).unwrap().unwrap().v, held, "{label}");
}
("double", Cell::Double(_) | Cell::Int(_) | Cell::Uint(_) | Cell::Nil) => continue,
(kind, value) => panic!("{label}: unexpected vector row {kind} {value:?}"),
}
checked += 1;
}
assert!(checked > 10, "the vectors were read, not skipped: {checked}");
}
type OutcomeRow = (String, Option<String>, String, Option<String>, Option<String>);
#[test]
fn vectors_stores_hold_their_verdicts() {
let bytes = std::fs::read(vectors().join("outcomes.msgpack")).unwrap();
let rows: Vec<OutcomeRow> = rmp_serde::from_slice(&bytes).unwrap();
assert!(rows.len() >= 3);
let dir = tempfile::tempdir().unwrap();
for (file, alias, verdict, k1, k2) in rows {
let path = dir.path().join(&file);
std::fs::copy(vectors().join(&file), &path).unwrap();
let before = std::fs::read(&path).unwrap();
let attach = |cache: &mut CultCache| {
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
cache.pull_all_backing_stores()
};
let mut cache = CultCache::new();
let opened = match alias.as_deref() {
Some("Sku") => {
cache.register_entry_type::<Unique>().unwrap();
attach(&mut cache)
}
None => {
cache.register_entry_type::<Twin>().unwrap();
attach(&mut cache)
}
Some(other) => panic!("unexpected vector alias {other}"),
};
match verdict.as_str() {
"open" => opened.unwrap_or_else(|error| panic!("{file} / {alias:?} should open: {error:#}")),
"refused" => assert_eq!(
error_text(opened),
refusal(&k1.unwrap(), &k2.unwrap(), "tests.unique_item", "Sku"),
"{file}"
),
other => panic!("unexpected verdict {other}"),
}
assert_eq!(std::fs::read(&path).unwrap(), before, "{file} is byte-identical");
}
}
/// One op of a refusals row: `["open", file-or-nil]`, `["pull", file]` or `["commit", [[key, schema, sku, second], ...]]`.
#[derive(Deserialize)]
#[serde(untagged)]
enum Arg {
Records(Vec<(String, String, String, String)>),
File(Option<String>),
}
type RefusalRow = (String, Vec<(String, Arg)>, Option<(String, String, String, String)>);
/// Rows `[label, ops, refusal]`: the pair a refusal names is a function of the state the admission would leave
/// (`tests/vectors/unique-index/README.md`, RULE), whatever the staging, file or pull order, key case or alias case.
#[test]
fn vectors_refusals_hold_the_rule() {
let bytes = std::fs::read(vectors().join("refusals.msgpack")).unwrap();
let rows: Vec<RefusalRow> = rmp_serde::from_slice(&bytes).unwrap();
let dir = tempfile::tempdir().unwrap();
let mut walked = 0;
for (index, (label, ops, refused)) in rows.into_iter().enumerate() {
let path = dir.path().join(format!("row-{index}.cc"));
let mut cache: Option<CultCache> = None;
let mut failure = None;
for (at, (kind, arg)) in ops.iter().enumerate() {
let last = at + 1 == ops.len();
let mut before = path.exists().then(|| std::fs::read(&path).unwrap());
let result = match (kind.as_str(), arg) {
("open", Arg::File(file)) => {
if let Some(file) = file {
std::fs::copy(vectors().join(file), &path).unwrap();
}
let mut opened = CultCache::new();
opened.register_entry_type::<Unique>().unwrap();
opened.register_entry_type::<UqPair>().unwrap();
opened.register_entry_type::<UqAlias>().unwrap();
opened.register_entry_type::<UqWide>().unwrap();
opened.register_entry_type::<UqAlpha>().unwrap();
opened.register_entry_type::<UqBeta>().unwrap();
opened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
let pulled = opened.pull_all_backing_stores();
cache = Some(opened);
pulled
}
("pull", Arg::File(Some(file))) => {
std::fs::copy(vectors().join(file), &path).unwrap();
before = Some(std::fs::read(&path).unwrap());
cache.as_mut().unwrap().pull_all_backing_stores()
}
("commit", Arg::Records(records)) => {
let cache = cache.as_mut().unwrap();
let staged: Vec<_> = records
.iter()
.map(|(key, schema, sku, second)| {
let (sku, second) = (sku.clone(), second.clone());
match schema.as_str() {
"tests.unique_item" => cache.prepare_entry(key, &Unique { sku, category: second }).map(|(envelope, _)| envelope),
"tests.uq_pair" => cache.prepare_entry(key, &UqPair { sku, alt: second }).map(|(envelope, _)| envelope),
"tests.uq_alias" => cache.prepare_entry(key, &UqAlias { sku, alt: second }).map(|(envelope, _)| envelope),
"tests.uq_wide" => cache.prepare_entry(key, &UqWide { sku, alt: second }).map(|(envelope, _)| envelope),
"tests.uq_alpha" => cache.prepare_entry(key, &UqAlpha { sku, second }).map(|(envelope, _)| envelope),
"tests.uq_beta" => cache.prepare_entry(key, &UqBeta { sku, second }).map(|(envelope, _)| envelope),
other => panic!("{label}: unexpected schema {other}"),
}
.unwrap()
})
.collect();
cache.put_prepared_batch(staged)
}
other => panic!("{label}: unexpected op {:?}", other.0),
};
if last {
failure = Some((result, before));
} else {
result.unwrap_or_else(|error| panic!("{label}: op {at} should succeed: {error:#}"));
}
}
let (result, before) = failure.unwrap();
match refused {
None => result.unwrap_or_else(|error| panic!("{label}: should succeed: {error:#}")),
Some((k1, k2, schema, alias)) => {
let error = result.err().unwrap_or_else(|| panic!("{label}: should refuse"));
assert_eq!(format!("{error:#}"), refusal(&k1, &k2, &schema, &alias), "{label}");
if let Some(before) = before {
assert_eq!(std::fs::read(&path).unwrap(), before, "{label}: the file is byte-identical");
}
}
}
walked += 1;
}
assert_eq!(walked, 37);
}
// A pull replaces the holder table with the stored image's: a value another writer changed is released, not kept.
#[test]
fn a_second_pull_replaces_the_holders_with_the_stored_image() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pull.cc");
let mut cache = cache_at(&path);
cache.put("a", &unique("S")).unwrap();
cache_at(&path).put("a", &unique("T")).unwrap();
cache.pull_all_backing_stores().unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None, "the replaced value is no longer held");
assert_eq!(cache.get_by_index::<Unique>("Sku", "T").unwrap(), cache.get::<Unique>("a").unwrap());
cache.put("b", &unique("S")).unwrap();
assert!(cache.put("c", &unique("T")).is_err());
}
// The projector decides what held records hold: declaring an index for a type that already has records derives
// their holders, or refuses the registration when they collide.
#[test]
fn registering_an_index_over_held_records_derives_their_holders() {
let mut cache = CultCache::new();
cache.register_entry_type::<Twin>().unwrap();
cache.put("a", &Twin { sku: "S".into(), category: "x".into() }).unwrap();
cache.put("b", &Twin { sku: "T".into(), category: "x".into() }).unwrap();
cache.register_entry_type::<Unique>().unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), cache.get::<Unique>("a").unwrap());
assert!(cache.get_by_index::<Unique>("Sku", "S").unwrap().is_some());
assert!(cache.put("c", &unique("S")).is_err(), "a held value is refused");
cache.put("c", &unique("U")).unwrap();
}
#[test]
fn registering_an_index_over_colliding_held_records_is_refused_and_changes_nothing() {
let mut cache = CultCache::new();
cache.register_entry_type::<Twin>().unwrap();
cache.put("a", &Twin { sku: "S".into(), category: "x".into() }).unwrap();
cache.put("b", &Twin { sku: "S".into(), category: "y".into() }).unwrap();
let text = error_text(cache.register_entry_type::<Unique>());
assert_eq!(text, refusal("a", "b", "tests.unique_item", "Sku"));
// Still the unindexed declaration: duplicates keep landing, nothing is looked up.
cache.put("c", &Twin { sku: "S".into(), category: "z".into() }).unwrap();
assert_eq!(cache.get_all_with_keys::<Twin>().unwrap().len(), 3);
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None);
}
/// The same type and schema declaring Sku as an integer: the held text cannot be decoded as one.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "unique_item", schema = "tests.unique_item")]
struct Retyped {
#[cultcache(key = 0, index = "Sku")]
sku: i64,
}
/// A refused re-registration leaves the previous declaration in force: it keeps judging, so a later duplicate is refused.
#[test]
fn a_refused_reregistration_keeps_the_previous_index_judging() {
let mut cache = CultCache::new();
cache.register_entry_type::<Unique>().unwrap();
cache.put("a", &unique("S")).unwrap();
cache.put("b", &unique("T")).unwrap();
// A different declaration over the same type (Category is the unique alias) collides on held records.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "unique_item", schema = "tests.unique_item")]
struct ByCat {
#[cultcache(key = 0)]
sku: String,
#[cultcache(key = 1, index = "Cat")]
category: String,
}
assert!(cache.register_entry_type::<ByCat>().is_err(), "a and b both hold category c");
assert!(cache.put("z", &unique("S")).is_err(), "the previous index still judges");
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), cache.get::<Unique>("a").unwrap());
cache.put("z", &unique("U")).unwrap();
}
// A held record the new declaration cannot decode refuses the registration; the unindexed declaration stays.
#[test]
fn registering_an_index_over_an_undecodable_held_record_is_refused_and_changes_nothing() {
let mut cache = CultCache::new();
cache.register_entry_type::<Twin>().unwrap();
cache.put("a", &Twin { sku: "S".into(), category: "x".into() }).unwrap();
assert!(cache.register_entry_type::<Retyped>().is_err());
cache.put("b", &Twin { sku: "S".into(), category: "y".into() }).unwrap();
assert_eq!(cache.get_all_with_keys::<Twin>().unwrap().len(), 2, "no index judges the duplicate");
}
// Dropping the declaration releases the holders.
#[test]
fn registering_without_the_index_releases_the_holders() {
let mut cache = CultCache::new();
cache.register_entry_type::<Unique>().unwrap();
cache.put("a", &unique("S")).unwrap();
cache.register_entry_type::<Twin>().unwrap();
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None, "no declaration, no holders");
cache.put("b", &Twin { sku: "S".into(), category: "x".into() }).unwrap();
assert!(cache.register_entry_type::<Unique>().is_err(), "the duplicate now written is a held violation");
}

View file

@ -3,13 +3,45 @@
All notable changes to `@gamecult/cultcache-ts` are recorded here, following All notable changes to `@gamecult/cultcache-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]
### Breaking
- A single-file store marked `cultcache.store.v3` (it holds an element id, written by the C# runtime) is read, and a rewrite keeps the mark and the ids; this runtime does not decide when a document carries ids. A runtime from before this change refuses a v3 store by name, so rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading.
- `indexes` are unique, as C# `[CultIndex]`: a write or a load that leaves two records of a type holding one non-blank
value under one alias is refused naming both keys and the alias, and writes nothing. Index text is the C# canonical
text (booleans `True`/`False`, integers as digits, doubles as the exact expansion, blank values never indexed), so
an empty string is no longer a value. `registerIndex` over held duplicates installs nothing. A new `roles` map names
the many-row selection aliases (never indexed for lookup, same catalog `indexAlias`). The per-key migration rule is
in the README; classify every key before the CultLib your tools load is updated. The refusal verdict is the contract, its text is informational.
- The single-file reader (v1 and the legacy envelope array) refuses a store that holds two records under one key as `StoreUnreadableError`; a store that opened before may now be refused. Repair it by removing the extra record with the runtime that wrote it.
- A `put`, `push` or `pushAll` that would land a second type under a held key (against the file, or within one batch) is refused with `SchemaConflictError`, and nothing is written. The written type may replace the record when its catalog entry carries the stored schema id as its own or lists it compatible (a type renamed under a stable id restamps the record, as C# does); otherwise retyping a key is a delete, then a put. The C# allowance for an undeclared older id of the same schema name that reads whole is not ported: a same-named type keeps passing, including one that drops persisted slots, which C# refuses.
- Same-type writes replace by key; in one `pushAll` batch the last entry for a key wins.
- A store whose canonical-schema catalog slot is not a string is refused as `StoreUnreadableError`.
- The cache holds one record per key (variants:ruling:cache-refuses-retype). A `put` or `putEnvelope` that lands a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store (a memory-only cache used to hold both types at the key). A type that owns the held record's schema id (as for the store) replaces it, unless the held record is in a store other than the written type's home: that rename is refused with `SchemaConflictError` (remove the record first). Two stores holding one key, registered or foreign, refuse the load and admit nothing. A store's `delete` removes the record whose stored schema id the removal's type owns, whatever the arrived record's `type` string.
- A store holding a record no registered type claims now loads (it used to throw "No schema is registered..."): the cache does not serve the record, lists it in `foreignRecords()`, keeps its bytes through every write, and refuses a `put` or a `delete` at its key with `SchemaConflictError` (variants:ruling:unheld-removal-refused-at-commit). Registering a type that claims its schema id serves it at the next `pullAllBackingStores`.
- A record whose payload carries a slot past its reader's declared slots, at any depth (the record, a nested record, a list element, a union arm, a unit arm), is now held read-only: it loads and reads, and every `put`, `putEnvelope`, `update` or `delete` of its key is refused with `SchemaConflictError` before a store is touched (cultlib-gaps:ruling:extra-trailing-slots, as the C# reference). A load used to skip the extra slots silently and a later write shed them. The refusal names the schema, the key, the type that was read with extra slots and both slot counts, never a slot value, and carries them as `SchemaConflictError.unreadSlots`. An untouched record keeps its bytes through writes of other keys. Missing trailing slots keep their declared defaults and mark nothing. A `put` never marks (the value is encoded by this runtime); a `putEnvelope` of a payload carrying extra slots lands its bytes and holds the record read-only. A mark at depth 0 is consented to only when the record arrived under a schema id other than the reader's own that the reader lists compatible, and that id's catalog entry lists those slots (a writer that adds a slot and keeps the reader's id is never consented to) (`unreadSlots.explained`); every other mark refuses whatever is declared. New: `CultCache.isWritable(definition, key)` (live, advisory), `CultCache.unreadSlots(definition, key)` and the exported `CultCacheUnreadSlots`. Documents with no slot codec (a plain map, a user formatter) never mark. A `delete` is by key: a type that owns the schema id of the record held at the key as another type removes that one record (one store call, the cache dropped once the store has removed it), and a type that does not own it is refused with `SchemaConflictError`, as a `put` is; `isWritable` answers both. Implied semver: a minor bump (0.16.0) while the package is 0.x; a major one at 1.0 and after.
### Added
- `checkPayloadDepth` returns the offset where the first value ends, or `undefined` when the payload ends early (a header, a container or a str/bin/scalar body cut short; it was `void`); it still refuses only depth. The bytes-after refusal is internal (`checkPayloadIsOneValue`, not exported); no new public name.
- `CultCache.foreignRecords()`: the `{key, schemaId, schemaName, storedAt}` of every record the last pull carried and no registered type claims, sorted by key ordinal.
### Changed
- A document payload is exactly one MessagePack value (cultlib-gaps:ruling:trailing-bytes-refuse). `decodeDocumentPayload`, the one reader (store loads, `put`/`putEnvelope`, CultNet replication, the provider command), and the inspector refuse bytes after the first value with `document type "T" payload has N bytes after its first MessagePack value.`, counted from the walk's end and never walked or echoed; a payload that ends early stays the decoder's to refuse. The refused set is unchanged; only the text changed (it was `@msgpack`'s `RangeError` "Extra N of M byte(s)" for a plain document and the codec reader's "bytes after its value" for a codec document, and the inspector's preview no longer shows a tail), so code matching either text must match this one. `readWire` reads one value and no longer checks what follows. Stated limit: a definition with its own `formatter` is not walked, so whether its payload is one value is the formatter's. Implied bump: 0.16.0 (pre-1.0 minor, with the Breaking entries above).
- Opening a legacy (pre-v1) store is a pure read: payloads that were not bytes are normalized in memory and the
file is left as it was, so a codec that refuses the content cannot lose the old bytes. The next push, delete or
flush rewrites the file as v1, merging what the open read.
- `SingleFileStoreRead` no longer carries `repairedLegacyPayload`; no consumer used it.
## [0.15.0] ## [0.15.0]
0.14.1 and 0.14.2 were never tagged or published; this entry carries them with the Unreleased work, and `cultcache-ts-v0.14.0` is the previous release. 0.14.1 and 0.14.2 were never tagged or published; this entry carries them with the Unreleased work, and `cultcache-ts-v0.14.0` is the previous release.
### Breaking ### Breaking
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name. - 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.
- A single-file store refuses a zero-byte file as `StoreUnreadableError`; only a path with nothing at it is an empty store. A header this runtime does not read is refused, and the refusal echoes the header only as `cultcache.store.v<digits>`. - A single-file store refuses a zero-byte file as `StoreUnreadableError`; only a path with nothing at it is an empty store. A header this runtime does not read is refused, and the refusal echoes the header only as `cultcache.store.v<digits>`.
- A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `pushAll` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. A full `pushAll` over a file with no readable stamp does not replace it: the reader refuses it. The stamp rule has one owner, `refuseUnreadFormat`, asked only by the one reader that every write consults under the lock. The reader judges the stamp from the header before it decodes the body, so a newer store is refused by name whether or not its body is whole. - A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `pushAll` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. A full `pushAll` over a file with no readable stamp does not replace it: the reader refuses it. The stamp rule has one owner, `refuseUnreadFormat`, asked only by the one reader that every write consults under the lock. The reader judges the stamp from the header before it decodes the body, so a newer store is refused by name whether or not its body is whole.
- A dangling symbolic link at the store path is an I/O error with code `ENOENT`, not an empty store. - A dangling symbolic link at the store path is an I/O error with code `ENOENT`, not an empty store.
@ -41,8 +73,6 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
- The Windows replace is a POSIX-semantics rename first (it lands while C# and Rust readers - The Windows replace is a POSIX-semantics rename first (it lands while C# and Rust readers
hold the store open), falling back to a write-through `MoveFileExW`, with 25 attempts 20 ms hold the store open), falling back to a write-through `MoveFileExW`, with 25 attempts 20 ms
apart on errors 5, 32 and 33. Writers wait up to 30 s for the lock. apart on errors 5, 32 and 33. Writers wait up to 30 s for the lock.
- Opening a legacy store that needs healing rewrites it under the lock, from a read taken
under the lock.
### Dependencies ### Dependencies

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
@ -63,6 +65,10 @@ const payload = encodeDocumentPayload(noteDocument, note); // DocumentPayload
Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins. The whole cache without the single-file store is `@gamecult/cultcache-ts/browser`, which is also what bundlers resolve for the package root (`browser` condition); bring your own `CacheBackingStore`. Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins. The whole cache without the single-file store is `@gamecult/cultcache-ts/browser`, which is also what bundlers resolve for the package root (`browser` condition); bring your own `CacheBackingStore`.
## Store format and rollback
A store whose elements hold an id is marked `cultcache.store.v3` by the C# runtime. This runtime reads a v3 store, keeps the header it read and carries the ids through a rewrite; it never drops the mark, so it has no carry-back. A cultcache-ts from before element ids refuses a v3 store by name, so rolling back to it after a store has been written as v3 is one-way: restore from a copy taken before upgrading, and upgrade every reader before any writer. (The C# line is different: CultLib 1.x does not refuse; see the GameCult.Caching README.)
## `.cc` Inspection ## `.cc` Inspection
CultCache files may use the `.cc` extension. The bytes are still the canonical CultCache files may use the `.cc` extension. The bytes are still the canonical
@ -93,12 +99,16 @@ const itemDocument = defineDocumentType({
schemaVersion: "item.v1", schemaVersion: "item.v1",
schema: z.object({ schema: z.object({
name: z.string(), name: z.string(),
sku: z.string(),
category: z.string(), category: z.string(),
value: z.number().int(), value: z.number().int(),
}), }),
name: "name", name: "name",
indexes: { indexes: {
category: "category", sku: "sku", // unique: a sku names one item
},
roles: {
category: "category", // a selection alias: many items share a category
}, },
}); });
@ -122,6 +132,7 @@ await cache.pullAllBackingStores();
await cache.put(itemDocument, "item:potion", { await cache.put(itemDocument, "item:potion", {
name: "Potion", name: "Potion",
sku: "POT-001",
category: "Consumable", category: "Consumable",
value: 50, value: 50,
}); });
@ -132,7 +143,7 @@ await cache.putGlobal(settingsDocument, {
}); });
const potion = cache.getByName(itemDocument, "Potion"); const potion = cache.getByName(itemDocument, "Potion");
const consumable = cache.getByIndex(itemDocument, "category", "Consumable"); const bySku = cache.getByIndex(itemDocument, "sku", "POT-001");
const settings = cache.getRequiredGlobal(settingsDocument); const settings = cache.getRequiredGlobal(settingsDocument);
``` ```
@ -194,7 +205,7 @@ A document type has exactly one home store
## Name, Index, and Global Semantics ## Name, Index, and Global Semantics
`name` and `indexes` can be declared on the document definition: `name`, `indexes` and `roles` can be declared on the document definition:
```ts ```ts
const playerDocument = defineDocumentType({ const playerDocument = defineDocumentType({
@ -202,17 +213,80 @@ const playerDocument = defineDocumentType({
schema: playerSchema, schema: playerSchema,
name: "displayName", name: "displayName",
indexes: { indexes: {
faction: "faction", email: "email", // unique lookup
email: "email", },
roles: {
faction: "faction", // selection alias, many players share one
}, },
}); });
``` ```
`indexes` are unique (variants:ruling:unique-indexes; C# `[CultIndex]`, Rust `#[cultcache(index)]`). Within one type
and one alias no two records hold one non-blank value, judged on the result of every write and of every load; a
refusal reads `Records <k1> and <k2> (<schema name>) both hold unique index '<alias>'; an index value names one record.`
(keys in ordinal order, never the value), and writes nothing. The contract is the verdict, which stores and writes are
refused; the text is informational. On a load TS judges record by record in file order and names the first collision
it meets, where C# judges the whole store, so the two runtimes can name different pairs of one store. A blank value (absent, empty, or only whitespace) is
never indexed and never conflicts. Two types may share an alias and a value. Index values are compared as the C#
reference renders them: strings as is (never trimmed), `True`/`False`, integers as digits, a non-integral number as
its exact decimal expansion; NaN and infinities have no value. `roles` name selection and reference aliases (the
member's catalog `indexAlias`, so the catalog bytes are the same either way); they imply nothing about uniqueness and
are never indexed for lookup, so `getByIndex` on a role answers `undefined`. An alias is declared in one of the two.
### One record per key, and foreign records
A cache holds one record per key whatever its type (variants:ruling:cache-refuses-retype). A write of a type that does not
own the held record's schema id (it carries that id as its own or lists it compatible) is refused with
`SchemaConflictError` naming the key, with or without a store; retyping a key is `delete` then `put`. A store that holds a
record no registered type claims still loads: the cache does not serve it, `foreignRecords()` lists it (`key`,
`schemaId`, `schemaName`, `storedAt`, by key ordinal), every write keeps its bytes, and a `put` or `delete` at its key is
refused. Registering a type that claims its schema id serves it at the next `pullAllBackingStores`. Two stores that hold
one key refuse the load and admit nothing.
### Migrating an existing `indexes` key
Apply per key, and do it BEFORE the CultLib checkout your tools load is rebuilt or updated. A consumer that loads
CultLib at run time (Bifrost reads `$VOIDBOT_CULTLIB_ROOT`, else the sibling `../CultLib` dist) has no pin to hold: its
next start after the checkout moves is the bump. A consumer that pins a version applies this before it bumps the pin.
Moving an `indexes` key to `roles` is safe on the old CultLib too when the definition lists `members` explicitly (an old
codec definition without `members` derives `indexAlias` from `indexes` only, so a moved key would lose its catalog alias),
so the consumer edit lands first:
1. Nothing looks records up by the key (`getByIndex`, `getKeyByIndex`, `getIdByIndex`; Python `get_by_index`,
`get_key_by_index`, `node.get_by_index`, `watch_by_index`; Rust `get_by_index`): it is a role. Move it to `roles`;
where a definition lists `members` explicitly, delete it from `indexes` and keep the member's `indexAlias` (the
catalog is the same).
2. Something looks it up: it stays unique only when two live records can never hold one non-blank value, because the
record key is derived from that value alone, or the value is an outside identity (email, sku, external id).
Enums, statuses, kinds, categories, parent or foreign ids, shared hashes and timestamps are roles; a lookup on
one already answered an arbitrary record, so move it to a selection (`any_of`) or `getAll` plus a filter.
3. Before the checkout moves, open each real `.cc` once with the new definitions. A refusal names both keys and the alias and
writes nothing.
Misclassifying costs differently each way. A role declared unique: the second record holding a shared value is
refused at commit, and a store already holding one refuses to open, so that store is down until the key is
reclassified (loud, names both keys and the alias). A unique key declared a role: lookups by it answer nothing and
duplicates stop being refused.
The Bifrost TS stores (at 781e6ea) call no lookup anywhere, so by rule 1 every key is a role and no key becomes unique.
All six definitions list `members` explicitly, each with its `indexAlias`, so the edit is deleting each `indexes` block:
the catalog is unchanged and the edit is safe on the CultLib it loads today. It must land before that checkout is
updated:
| Definition | Keys | Verdict |
|---|---|---|
| `update-request` (`tools/agent-transport.mjs`) | `targetRepoName`, `targetAgentIdentity`, `status` | role: many requests per repo and agent; status is an enum |
| `crossing_receipt` (`tools/bifrost-crossing-documents.mjs`) | `commandId`, `crossingKind`, `status` | role: receipts are `crossing_<commandId>_<activityKind>`, several per command; the rest are enums |
| release authority (`tools/bifrost-repository-release-authority.mjs`) | `repositoryFullName`, `upstreamRef`, `commitSha`, `status` | role: the authority id is derived from repo, ref and sha, so one sha under two refs is two records |
| `topic` (`tools/governance-threads.mjs`) | `jurisdictionRepoName`, `jurisdictionAgentIdentity`, `status` | role: many topics per jurisdiction |
| `comment` (`tools/governance-threads.mjs`) | `topicId`, `authorId`, `stance` | role: many comments per topic; stance is an enum |
| `advertisement` (`tools/provider-advertisement.mjs`) | `serviceName` | role: one record holds it today, but nothing looks it up |
You can also register them later if you feel like changing the machine after boot: You can also register them later if you feel like changing the machine after boot:
```ts ```ts
await cache.registerNameLookup(playerDocument, "displayName"); await cache.registerNameLookup(playerDocument, "displayName");
await cache.registerIndex(playerDocument, "faction", "faction"); await cache.registerIndex(playerDocument, "email", "email");
``` ```
Registration, `addBackingStore`, `pullAllBackingStores` and every write and Registration, `addBackingStore`, `pullAllBackingStores` and every write and
@ -255,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
@ -299,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.
@ -357,7 +434,7 @@ That is the TypeScript version of the original CultCache trick:
- the top-level MessagePack value is `[formatVersion, schemaCatalog, records]` - the top-level MessagePack value is `[formatVersion, schemaCatalog, records]`
- each record is `[key, schemaId, storedAt, payload]` - each record is `[key, schemaId, storedAt, payload]`
- each catalog entry is `[schemaId, schemaName, schemaVersion, contentHash, canonicalSchemaJson, compatibleSchemaIds, members]` - each catalog entry is `[schemaId, schemaName, schemaVersion, contentHash, canonicalSchemaJson, compatibleSchemaIds, members]`
- it still reads the older TS/Rust-style envelope array and rewrites repaired legacy payloads as v1 snapshots - it still reads the older TS/Rust-style envelope array (object payloads are normalized in memory; opening never rewrites the file) and replaces it with a v1 snapshot on the next push, delete or flush
- it queues local writes in order, then takes the shared `<file>.lock` (`flock` on Unix, `LockFileEx` on Windows, via koffi, loaded on the first write) for each one, so C#, Rust and TypeScript writers exclude each other; the read a write merges from is taken under that lock - it queues local writes in order, then takes the shared `<file>.lock` (`flock` on Unix, `LockFileEx` on Windows, via koffi, loaded on the first write) for each one, so C#, Rust and TypeScript writers exclude each other; the read a write merges from is taken under that lock
- it fsyncs the staged file and, on Unix, the directory after the replace; on Windows it replaces with a POSIX-semantics rename and falls back to a write-through `MoveFileExW`, retrying briefly on sharing violations - it fsyncs the staged file and, on Unix, the directory after the replace; on Windows it replaces with a POSIX-semantics rename and falls back to a write-through `MoveFileExW`, retrying briefly on sharing violations
- it persists raw MessagePack payload bytes inside schema-addressed records - it persists raw MessagePack payload bytes inside schema-addressed records

View file

@ -2,7 +2,7 @@
// (node:fs, koffi). Bring your own CacheBackingStore, or none. // (node:fs, koffi). Bring your own CacheBackingStore, or none.
export * as codecs from "./codec"; export * as codecs from "./codec";
export type { Codec, SlotsCodec } from "./codec"; export type { Codec, SlotsCodec } from "./codec";
export { CultCache, CultCacheBuilder } from "./cult-cache"; export { CultCache, CultCacheBuilder, type CultCacheForeignRecord } from "./cult-cache";
export { inspectCultCacheBytes } from "./cult-cache-inspector"; export { inspectCultCacheBytes } from "./cult-cache-inspector";
export { export {
decodeDocumentPayload, decodeDocumentPayload,
@ -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,5 +1,6 @@
import { import {
decodeDocumentPayload, declaresRole,
decodeDocumentPayloadMarked,
documentPayloadBytes, documentPayloadBytes,
documentPayloadFromBytes, documentPayloadFromBytes,
encodeDocumentPayload, encodeDocumentPayload,
@ -7,8 +8,9 @@ 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 } from "./store-format"; import { SchemaConflictError, schemaIdForEnvelope, writtenOwns } from "./store-format";
import type { import type {
AnyCultCacheDocumentDefinition, AnyCultCacheDocumentDefinition,
CacheBackingStore, CacheBackingStore,
@ -19,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;
@ -26,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 = {
@ -46,8 +49,13 @@ type EntryLookups = {
}; };
type HydratedEntry = CultCacheEnvelope & { type HydratedEntry = CultCacheEnvelope & {
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.
@ -62,6 +70,17 @@ type HydratedState = {
// delete of that global removes it from the home store. Remove once no store holds a global under // delete of that global removes it from the home store. Remove once no store holds a global under
// a key other than GLOBAL_KEY. // a key other than GLOBAL_KEY.
readonly legacyGlobals: Map<string, CultCacheEnvelope>; readonly legacyGlobals: Map<string, CultCacheEnvelope>;
// The records the last pull found that no registered type claims. Derived from the pull and replaced with the state; it
// decides nothing but the refusal of a write or a removal at its keys.
readonly foreign: CultCacheForeignRecord[];
};
/** A record a store carries that no registered type claims: the cache does not serve it, keeps its bytes, and refuses a write or a removal at its key. */
export type CultCacheForeignRecord = {
readonly key: string;
readonly schemaId: string;
readonly schemaName: string;
readonly storedAt: string;
}; };
// Lookups for every held record of one type under candidate accessors, derived before anything is installed. // Lookups for every held record of one type under candidate accessors, derived before anything is installed.
@ -71,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(),
@ -79,6 +112,7 @@ function emptyHydratedState(): HydratedState {
indexToKeyMaps: new Map(), indexToKeyMaps: new Map(),
globalKeys: new Map(), globalKeys: new Map(),
legacyGlobals: new Map(), legacyGlobals: new Map(),
foreign: [],
}; };
} }
@ -224,7 +258,7 @@ export class CultCache {
return this.#serial(() => { return this.#serial(() => {
const registered = this.#requireDefinition(definition); const registered = this.#requireDefinition(definition);
const nameAccessor = this.#compileAccessor(definition.type, "name", accessor); const nameAccessor = this.#compileAccessor(definition.type, "name", accessor);
const derived = this.#deriveTypeLookups(definition.type, nameAccessor, registered.indexAccessors); const derived = this.#deriveTypeLookups(definition.type, registered.catalogEntry.schemaName, nameAccessor, registered.indexAccessors);
this.#installAccessors(registered, nameAccessor, registered.indexAccessors, derived); this.#installAccessors(registered, nameAccessor, registered.indexAccessors, derived);
return definition; return definition;
}); });
@ -240,10 +274,14 @@ export class CultCache {
throw new Error(`CultCache index names for type "${definition.type}" must be non-empty.`); throw new Error(`CultCache index names for type "${definition.type}" must be non-empty.`);
} }
if (declaresRole(definition, indexName)) {
throw new Error(`Index "${indexName}" of ${definition.type} is declared as a role; a role is never a unique index.`);
}
const registered = this.#requireDefinition(definition); const registered = this.#requireDefinition(definition);
const indexAccessors = new Map(registered.indexAccessors); const indexAccessors = new Map(registered.indexAccessors);
indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor)); indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor));
const derived = this.#deriveTypeLookups(definition.type, registered.nameAccessor, indexAccessors); const derived = this.#deriveTypeLookups(definition.type, registered.catalogEntry.schemaName, registered.nameAccessor, indexAccessors);
this.#installAccessors(registered, registered.nameAccessor, indexAccessors, derived); this.#installAccessors(registered, registered.nameAccessor, indexAccessors, derived);
return definition; return definition;
}); });
@ -274,11 +312,14 @@ export class CultCache {
for (const entry of entries) { for (const entry of entries) {
const registered = this.#resolveDefinitionForEnvelope(entry); const registered = this.#resolveDefinitionForEnvelope(entry);
if (!registered) { if (!registered) {
throw new Error( // A store may hold what this cache has no type for; it is carried, not served, and not a reason to refuse the load.
entry.schemaId next.foreign.push({
? `No schema is registered for persisted schema id "${entry.schemaId}".` key: entry.key,
: `No schema is registered for persisted document type "${entry.type}".`, schemaId: schemaIdForEnvelope(entry),
); schemaName: entry.catalogEntry?.schemaName ?? entry.type,
storedAt: entry.storedAt,
});
continue;
} }
const type = registered.definition.type; const type = registered.definition.type;
@ -291,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);
@ -306,11 +347,38 @@ 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);
if (other !== undefined) {
throw new Error(`Record ${hydrated.key} is held by two stores, as type "${other.type}" and as type "${type}".`);
}
this.#installEntry(next, registered, hydrated); this.#installEntry(next, registered, hydrated);
} }
} }
const foreignByKey = new Map<string, string>();
for (const record of next.foreign) {
const first = foreignByKey.get(record.key);
if (first !== undefined) {
throw new Error(`Record ${record.key} is held by two stores, as schema "${first}" and as schema "${record.schemaName}".`);
}
foreignByKey.set(record.key, record.schemaName);
const other = [...this.#definitions.keys()].map((type) => next.entries.get(this.#entryId(type, record.key))).find((held) => held !== undefined);
if (other !== undefined) {
throw new Error(`Record ${record.key} is held by two stores, as type "${other.type}" and as schema "${record.schemaName}".`);
}
}
// Judged whole, once: the refusal names the least violation of the state this load would leave, whatever the
// order of the stores and of the entries in them.
refuseLeastIndexSharing(function* () {
for (const entry of next.entries.values()) {
for (const [alias, value] of entry.lookups.indexes) {
yield { type: entry.type, schemaName: entry.catalogEntry.schemaName, alias, value, key: entry.key };
}
}
}());
this.#hydrated = next; this.#hydrated = next;
}); });
} }
@ -418,6 +486,7 @@ export class CultCache {
return key ? this.get(definition, key) : undefined; return key ? this.get(definition, key) : undefined;
} }
// A unique index: one key per non-blank value. A roles alias is never indexed and answers undefined.
getKeyByIndex<TDefinition extends AnyCultCacheDocumentDefinition>( getKeyByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
definition: TDefinition, definition: TDefinition,
indexName: string, indexName: string,
@ -469,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,
@ -483,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;
}); });
} }
@ -536,6 +604,34 @@ export class CultCache {
}); });
} }
/** What the record's payload carried past this build's declared slots, or undefined: the held record is read-only unless `explained`. */
unreadSlots(definition: AnyCultCacheDocumentDefinition, key: string): CultCacheUnreadSlots | undefined {
return this.#hydrated.entries.get(this.#entryId(this.#requireDefinition(definition).definition.type, key))?.unreadSlots;
}
/**
* Whether a write of the record's own type at the key would be let through: it asks the judge #writeNow asks, of the record the
* cache holds (a key a store carries foreign, a record of another type the write does not own or cannot replace, a global at
* another key, a record carrying unread slots nothing consents to). It answers the WRITE form. A removal asks the judge's
* removal form and differs in two states: an owner's `delete` of a record held in another store than its own home succeeds
* where this is false (a write would push to the wrong store), and `delete` of a global type at a key other than __global__ is
* the plain false of an unheld key where this is false. Live and advisory: the write or removal still decides.
*/
isWritable(definition: AnyCultCacheDocumentDefinition, key: string): boolean {
const registered = this.#requireDefinition(definition);
try {
this.#judge(registered, key, this.#probe(registered, key));
return true;
} catch {
return false;
}
}
// What the last pull found that no registered type claims, by key ordinal. A later registration claims a record at the next pull.
foreignRecords(): readonly CultCacheForeignRecord[] {
return [...this.#hydrated.foreign].sort((left, right) => compareOrdinal(left.key, right.key));
}
snapshot(): CultCacheEnvelope[] { snapshot(): CultCacheEnvelope[] {
return [...this.#hydrated.entries.values()].map((entry) => this.#toEnvelope(entry)); return [...this.#hydrated.entries.values()].map((entry) => this.#toEnvelope(entry));
} }
@ -579,7 +675,7 @@ export class CultCache {
if (existingBySchemaName && existingBySchemaName !== registered) { if (existingBySchemaName && existingBySchemaName !== registered) {
throw refuse(existingBySchemaName, registered.catalogEntry, `schema name "${registered.catalogEntry.schemaName}"`); throw refuse(existingBySchemaName, registered.catalogEntry, `schema name "${registered.catalogEntry.schemaName}"`);
} }
const derived = this.#deriveTypeLookups(definition.type, nameAccessor, indexAccessors); const derived = this.#deriveTypeLookups(definition.type, registered.catalogEntry.schemaName, nameAccessor, indexAccessors);
// Every check and accessor has run; install. // Every check and accessor has run; install.
this.#definitions.set(definition.type, registered); this.#definitions.set(definition.type, registered);
@ -626,9 +722,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,
@ -637,24 +732,150 @@ 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 hydrated = this.#admitEntry(this.#hydrated, registered, entry, value); const retyped = this.#judge(registered, entry.key, entry);
const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value, unreadSlots);
this.#refuseIndexSharing(this.#hydrated, entry.type, registered.catalogEntry.schemaName, entry.key, hydrated.lookups);
const legacy = this.#hydrated.legacyGlobals.get(entry.type); const legacy = this.#hydrated.legacyGlobals.get(entry.type);
await home?.push(entry); await home?.push(entry);
if (home && legacy) { if (home && legacy) {
await this.#retireLegacyGlobal(home, legacy, entry); await this.#retireLegacyGlobal(home, legacy, entry);
} }
this.#hydrated.legacyGlobals.delete(entry.type); this.#hydrated.legacyGlobals.delete(entry.type);
if (retyped !== undefined) {
// The record the write restamped (it owns the held id) is the same record: one key, one held type.
this.#removeEntry(this.#hydrated, this.#definitions.get(retyped.type)!, retyped);
}
this.#installEntry(this.#hydrated, registered, hydrated); this.#installEntry(this.#hydrated, registered, hydrated);
} }
// An envelope of the definition's type at the key, carrying nothing: what the judge asks about a write or a removal by that type.
#probe(registered: RegisteredDefinition, key: string): CultCacheEnvelope {
return {
key, type: registered.definition.type, payload: documentPayloadFromBytes(new Uint8Array(0)), storedAt: "",
schemaId: registered.catalogEntry.schemaId, catalogEntry: registered.catalogEntry,
};
}
// The one judge of whether a key may be written (a written envelope) or removed (none), before any store is touched: it throws the
// refusal, and #writeNow, #deleteNow and isWritable all ask it, so the query cannot disagree with the write. For a write it
// returns the held record of another type that the write owns and so restamps, if any. A refusal names keys and schemas,
// never a payload or a slot value.
#judge(registered: RegisteredDefinition, key: string, written?: CultCacheEnvelope): HydratedEntry | undefined {
const type = registered.definition.type;
const held = this.#hydrated.entries.get(this.#entryId(type, key));
if (written === undefined) {
// A removal is by key: the record it removes is the one held at the key, of this type or of another type whose schema id
// this type owns (the record a write of this type would replace). A held record of a type it does not own is refused.
this.#refuseForeignKey(key, type, "removed");
this.#refuseUnread(held);
if (held !== undefined) {
return undefined;
}
const other = this.#heldUnderOtherType(this.#hydrated, type, key);
if (other === undefined) {
return undefined;
}
if (!writtenOwns(this.#probe(registered, key), schemaIdForEnvelope(other))) {
throw new SchemaConflictError(
registered.catalogEntry.schemaId,
[other.type, type],
key,
`CultCache holds record "${key}" as type "${other.type}", so type "${type}" cannot remove it.`,
);
}
this.#refuseUnread(other);
return other;
}
this.#requireGlobalKey(registered, key);
const retyped = this.#refuseRetype(written);
this.#refuseUnread(held);
this.#refuseUnread(retyped);
if (retyped !== undefined && this.#homeStore(retyped.type) !== this.#homeStore(type)) {
// The write owns the held record's id and would replace it, but the held record is in another store; the push
// reaches only this type's home, so the old record would stay in its own file and load as a second holder.
throw new SchemaConflictError(
schemaIdForEnvelope(written),
[retyped.type, type],
key,
`CultCache holds record "${key}" as type "${retyped.type}" in another store than the home of type "${type}", so the rename cannot replace it; remove the record first.`,
);
}
return retyped;
}
// The one refusal of a record held read-only for the slots its payload carries past this build's: a write or a removal of
// its key. It names the type and the counts, never a slot value. A mark that is explained (see #admitEntry) is consented to.
#unreadRefusal(held: HydratedEntry | undefined): SchemaConflictError | undefined {
const unread = held?.unreadSlots;
if (held === undefined || unread === undefined || unread.explained) {
return undefined;
}
return new SchemaConflictError(
held.storedSchemaId ?? held.schemaId ?? held.type,
[held.catalogEntry.schemaName],
held.key,
`${held.catalogEntry.schemaName} record ${held.key} is read-only: ${unread.typeName} was read with ${unread.slotCount} slots where this build declares ${unread.declaredCount} ` +
`(${unread.occurrences} object(s) in the record). Writing it would drop data a newer writer stored; read it with types that declare those slots, or write it from that build.`,
unread,
);
}
#refuseUnread(held: HydratedEntry | undefined): void {
const refusal = this.#unreadRefusal(held);
if (refusal !== undefined) {
throw refusal;
}
}
// The one key-identity judge of the cache: may this write land at its key, given what the cache holds there under
// every registered type and the keys its stores carry foreign. The store judges the file with the same predicate
// (writtenOwns); this judges the cache, which a store-less cache has no other judge for. Returns the held record of
// another type the write owns and so replaces, if any. A refusal names the key and schemas, never a payload.
#refuseRetype(entry: CultCacheEnvelope): HydratedEntry | undefined {
this.#refuseForeignKey(entry.key, entry.type, "written");
const held = this.#heldUnderOtherType(this.#hydrated, entry.type, entry.key);
if (held === undefined || writtenOwns(entry, schemaIdForEnvelope(held))) {
return held;
}
throw new SchemaConflictError(
schemaIdForEnvelope(entry),
[held.type, entry.type],
entry.key,
`CultCache holds record "${entry.key}" as type "${held.type}", so type "${entry.type}" cannot be written under the same key; remove the record first.`,
);
}
#refuseForeignKey(key: string, type: string, what: "written" | "removed"): void {
const foreign = this.#hydrated.foreign.find((record) => record.key === key);
if (foreign !== undefined) {
throw new SchemaConflictError(
foreign.schemaId,
[foreign.schemaName],
key,
`CultCache store carries record "${key}" as schema "${foreign.schemaName}" (${foreign.schemaId}), which no registered type claims, so type "${type}" cannot be ${what} at that key.`,
);
}
}
// The record another registered type holds at the key, if any.
#heldUnderOtherType(state: HydratedState, type: string, key: string): HydratedEntry | undefined {
for (const other of this.#definitions.keys()) {
const held = other === type ? undefined : state.entries.get(this.#entryId(other, key));
if (held !== undefined) {
return held;
}
}
return undefined;
}
// The first write of a global loaded from a legacy key removes that record in the same write. If the // The first write of a global loaded from a legacy key removes that record in the same write. If the
// removal fails, the new record is taken back out, so the store keeps only the legacy record. // removal fails, the new record is taken back out, so the store keeps only the legacy record.
async #retireLegacyGlobal(home: CacheBackingStore, legacy: CultCacheEnvelope, written: CultCacheEnvelope): Promise<void> { async #retireLegacyGlobal(home: CacheBackingStore, legacy: CultCacheEnvelope, written: CultCacheEnvelope): Promise<void> {
@ -671,15 +892,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) {
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;
} }
@ -706,7 +932,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)),
}; };
} }
@ -795,11 +1021,13 @@ export class CultCache {
switch (typeof value) { switch (typeof value) {
case "string": case "string":
return value; return isBlankIndexText(value) ? undefined : value;
case "number":
case "boolean": case "boolean":
return value ? "True" : "False";
case "bigint": case "bigint":
return String(value); return value.toString();
case "number":
return canonicalNumberText(value);
default: default:
throw new Error(`CultCache ${label} accessor for type "${type}" produced a non-scalar value.`); throw new Error(`CultCache ${label} accessor for type "${type}" produced a non-scalar value.`);
} }
@ -884,6 +1112,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);
@ -894,6 +1123,7 @@ export class CultCache {
} }
} }
const lookups = this.#lookupsFor(registered.nameAccessor, registered.indexAccessors, value);
return { return {
...entry, ...entry,
payload: this.#cloneBytes(entry.payload), payload: this.#cloneBytes(entry.payload),
@ -902,10 +1132,24 @@ export class CultCache {
schemaId: registered.catalogEntry.schemaId, schemaId: registered.catalogEntry.schemaId,
catalogEntry: registered.catalogEntry, catalogEntry: registered.catalogEntry,
value, value,
lookups: this.#lookupsFor(registered.nameAccessor, registered.indexAccessors, value), lookups,
storedSchemaId: entry.schemaId ?? entry.type,
...(noted ? { unreadSlots: { ...noted, explained: explainedBy(noted, registered.catalogEntry, entry.schemaId ?? entry.type, entry.catalogEntry) } } : {}),
}; };
} }
// The one index rule (variants:ruling:unique-indexes): within one type and one unique alias, no two records hold one
// non-blank value. The held key at the landing record's own key is its previous self and keeps its value. The message
// names both keys, ordinal, and the alias, never the value.
#refuseIndexSharing(state: HydratedState, type: string, schemaName: string, key: string, lookups: EntryLookups): void {
for (const alias of [...lookups.indexes.keys()].sort(compareOrdinal)) {
const held = state.indexToKeyMaps.get(this.#indexId(type, alias))?.get(lookups.indexes.get(alias)!);
if (held !== undefined && held !== key) {
throw indexSharingRefusal(held, key, schemaName, alias);
}
}
}
// Infallible half of admission: replaces any held record at the same key and installs the admitted one. // Infallible half of admission: replaces any held record at the same key and installs the admitted one.
#installEntry(state: HydratedState, registered: RegisteredDefinition, hydrated: HydratedEntry): void { #installEntry(state: HydratedState, registered: RegisteredDefinition, hydrated: HydratedEntry): void {
const entryId = this.#entryId(hydrated.type, hydrated.key); const entryId = this.#entryId(hydrated.type, hydrated.key);
@ -966,6 +1210,7 @@ export class CultCache {
// Runs the candidate accessors over every held record of the type into local maps. // Runs the candidate accessors over every held record of the type into local maps.
#deriveTypeLookups( #deriveTypeLookups(
type: string, type: string,
schemaName: string,
nameAccessor: Accessor | undefined, nameAccessor: Accessor | undefined,
indexAccessors: ReadonlyMap<string, Accessor>, indexAccessors: ReadonlyMap<string, Accessor>,
): DerivedTypeLookups { ): DerivedTypeLookups {
@ -983,10 +1228,18 @@ export class CultCache {
nameMap?.set(lookups.name, entry.key); nameMap?.set(lookups.name, entry.key);
} }
for (const [indexName, indexValue] of lookups.indexes) { for (const [indexName, indexValue] of lookups.indexes) {
indexMaps.get(indexName)?.set(indexValue, entry.key); const map = indexMaps.get(indexName)!;
if (!map.has(indexValue)) map.set(indexValue, entry.key);
} }
} }
// Judged whole, by the same judge as a load: the least violation of the state the registration would leave.
refuseLeastIndexSharing(function* () {
for (const [entry, lookups] of entryLookups) {
for (const [alias, value] of lookups.indexes) yield { type, schemaName, alias, value, key: entry.key };
}
}());
return { entryLookups, nameMap, indexMaps }; return { entryLookups, nameMap, indexMaps };
} }
@ -1057,3 +1310,70 @@ export class CultCache {
return documentPayloadFromBytes(new Uint8Array(documentPayloadBytes(payload))); return documentPayloadFromBytes(new Uint8Array(documentPayloadBytes(payload)));
} }
} }
function compareOrdinal(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
// The whole-state judge (variants:question:unique-index-refusal-pair-rule): over every (type, alias, value) held at two
// or more distinct keys, throws for the one least by (alias, least key, second-least key, schema name), each compared by
// UTF-16 code unit. A function of the state, never of the order the rows arrive in.
function refuseLeastIndexSharing(rows: Iterable<{ type: string; schemaName: string; alias: string; value: string; key: string }>): void {
const groups = new Map<string, { schemaName: string; alias: string; keys: string[] }>();
for (const { type, schemaName, alias, value, key } of rows) {
const id = JSON.stringify([type, alias, value]);
const group = groups.get(id);
if (group) group.keys.push(key);
else groups.set(id, { schemaName, alias, keys: [key] });
}
let least: { schemaName: string; alias: string; first: string; second: string } | undefined;
for (const { schemaName, alias, keys } of groups.values()) {
if (keys.length < 2) continue;
keys.sort(compareOrdinal);
const candidate = { schemaName, alias, first: keys[0], second: keys[1] };
if (!least || compareLeast(candidate, least) < 0) least = candidate;
}
if (least) throw indexSharingRefusal(least.first, least.second, least.schemaName, least.alias);
}
function compareLeast(
a: { schemaName: string; alias: string; first: string; second: string },
b: { schemaName: string; alias: string; first: string; second: string },
): number {
return compareOrdinal(a.alias, b.alias) || compareOrdinal(a.first, b.first) || compareOrdinal(a.second, b.second) || compareOrdinal(a.schemaName, b.schemaName);
}
function indexSharingRefusal(keyA: string, keyB: string, schemaName: string, alias: string): Error {
const [first, second] = compareOrdinal(keyA, keyB) <= 0 ? [keyA, keyB] : [keyB, keyA];
return new Error(`Records ${first} and ${second} (${schemaName}) both hold unique index '${alias}'; an index value names one record.`);
}
// Blank is no value: empty or only C# White_Space characters. Not JavaScript's \s, which counts U+FEFF and omits U+0085.
const BLANK_INDEX_TEXT = /^[\t-\r \u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]*$/;
function isBlankIndexText(value: string): boolean {
return BLANK_INDEX_TEXT.test(value);
}
// The canonical text of a number, as C# RenderCanonicalNumber: integers as their digits (-0 is 0), a non-integral finite
// double as its exact decimal expansion, NaN and infinities as no value.
function canonicalNumberText(value: number): string | undefined {
if (!Number.isFinite(value)) {
return undefined;
}
if (Number.isInteger(value)) {
return BigInt(value).toString();
}
// value = mantissa * 2^exponent with exponent < 0, so value = mantissa * 5^k / 10^k for k = -exponent.
const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, value);
const bits = view.getBigUint64(0);
const biasedExponent = Number((bits >> 52n) & 0x7ffn);
const fraction = bits & 0xfffffffffffffn;
const mantissa = biasedExponent === 0 ? fraction : fraction | (1n << 52n);
const k = -((biasedExponent === 0 ? 1 : biasedExponent) - 1075);
const digits = (mantissa * 5n ** BigInt(k)).toString().padStart(k + 1, "0");
const text = `${digits.slice(0, digits.length - k)}.${digits.slice(digits.length - k)}`.replace(/0+$/, "").replace(/\.$/, "");
return value < 0 ? `-${text}` : text;
}

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,
@ -32,23 +33,22 @@ export function defineDocumentType<TSchema extends CultCacheSchema>(
throw new Error(`CultCache document type "${definition.type}" declares an invalid name accessor.`); throw new Error(`CultCache document type "${definition.type}" declares an invalid name accessor.`);
} }
if (Array.isArray(definition.indexes)) { const declared = new Set<string>();
const seen = new Set<string>(); for (const [kind, set] of [["index", definition.indexes], ["role", definition.roles]] as const) {
for (const index of definition.indexes) { const names = new Set<string>();
if (!index.name || index.name.trim().length === 0) { for (const name of Array.isArray(set) ? set.map((entry) => entry.name) : set ? Object.keys(set) : []) {
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`);
}
if (seen.has(index.name)) {
throw new Error(`CultCache document type "${definition.type}" declares duplicate index "${index.name}".`);
}
seen.add(index.name);
}
} else if (definition.indexes) {
for (const name of Object.keys(definition.indexes)) {
if (!name || name.trim().length === 0) { if (!name || name.trim().length === 0) {
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`); throw new Error(`CultCache document type "${definition.type}" declares ${kind === "index" ? "an index" : "a role"} with an empty name.`);
} }
if (names.has(name)) {
throw new Error(`CultCache document type "${definition.type}" declares duplicate ${kind} "${name}".`);
}
if (kind === "role" && declared.has(name)) {
throw new Error(`CultCache document type "${definition.type}" declares "${name}" as both an index and a role; an index already names its role.`);
}
names.add(name);
} }
for (const name of names) declared.add(name);
} }
if (definition.members) { if (definition.members) {
@ -119,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.
@ -132,16 +146,25 @@ export function encodeDocumentPayload(definition: AnyCultCacheDocumentDefinition
return documentPayloadFromBytes(bytes); return documentPayloadFromBytes(bytes);
} }
// Whether the definition declares `name` as a role alias (either `roles` form).
export function declaresRole(definition: AnyCultCacheDocumentDefinition, name: string): boolean {
const roles = definition.roles;
return Array.isArray(roles) ? roles.some((entry) => entry.name === name) : roles ? Object.hasOwn(roles, name) : false;
}
// The schema members of a document: the ones it declares, else those its codec derives, else none. // The schema members of a document: the ones it declares, else those its codec derives, else none.
export function resolveDocumentMembers(definition: AnyCultCacheDocumentDefinition): readonly CultCacheSchemaCatalogMember[] { export function resolveDocumentMembers(definition: AnyCultCacheDocumentDefinition): readonly CultCacheSchemaCatalogMember[] {
if (definition.members || !definition.codec) { if (definition.members || !definition.codec) {
return definition.members ?? []; return definition.members ?? [];
} }
const codec = definition.codec; const codec = definition.codec;
const indexAlias = (slotName: string) => const indexAlias = (slotName: string) => {
!Array.isArray(definition.indexes) && definition.indexes for (const set of [definition.indexes, definition.roles]) {
? Object.entries(definition.indexes).find(([, accessor]) => accessor === slotName)?.[0] ?? null const alias = !Array.isArray(set) && set ? Object.entries(set).find(([, accessor]) => accessor === slotName)?.[0] : undefined;
: null; if (alias !== undefined) return alias;
}
return null;
};
return codec.members.map((member, index) => ({ return codec.members.map((member, index) => ({
...member, ...member,
isName: (definition.name as unknown) === codec.names[index], isName: (definition.name as unknown) === codec.names[index],

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

@ -7,7 +7,7 @@ import { z } from "zod";
import { documentPayloadBytes, documentPayloadFromBytes } from "./document"; import { documentPayloadBytes, documentPayloadFromBytes } from "./document";
import { commitStoreFile, withStoreWriteLock } from "./store-file"; import { commitStoreFile, withStoreWriteLock } from "./store-file";
import { STORE_FORMAT_VERSION, SchemaConflictError, type StoreFormat, StoreUnreadableError, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots } from "./store-format"; import { STORE_FORMAT_VERSION, SchemaConflictError, type StoreFormat, StoreUnreadableError, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots, schemaIdForEnvelope, writtenOwns } from "./store-format";
import type { import type {
CacheBackingStore, CacheBackingStore,
CultCacheEnvelope, CultCacheEnvelope,
@ -47,21 +47,11 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
} }
async pullAll(): Promise<CultCacheEnvelope[]> { async pullAll(): Promise<CultCacheEnvelope[]> {
// The disk decides the header: a file that is gone or legacy is not marked. // A pure read: the disk decides the header, and a legacy file is normalized in memory only. The file is rewritten
this.#format = STORE_FORMAT_VERSION; // as v1 by the next push, delete or flush, which merges what this read returns; opening never replaces bytes the
// consumer's codec has not yet accepted.
const disk = this.#readDisk(); const disk = this.#readDisk();
this.#format = disk.format; this.#format = disk.format;
if (disk.repairedLegacyPayload) {
// The heal decides from its own read, taken under the store lock.
await this.#enqueue(async () => {
const again = this.#readDisk();
this.#format = again.format;
if (again.repairedLegacyPayload) {
await this.#writeAll([], again.envelopes);
}
});
}
return disk.envelopes; return disk.envelopes;
} }
@ -70,9 +60,8 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
const disk = this.#readDisk(); const disk = this.#readDisk();
this.#format = disk.format; this.#format = disk.format;
const existing = disk.envelopes; const existing = disk.envelopes;
const filtered = existing.filter( requireOneTypePerKey(this.filePath, [entry], existing);
(candidate) => !(candidate.type === entry.type && candidate.key === entry.key), const filtered = existing.filter((candidate) => candidate.key !== entry.key);
);
await this.#writeAll([entry], filtered); await this.#writeAll([entry], filtered);
}); });
} }
@ -82,8 +71,10 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
const disk = this.#readDisk(); const disk = this.#readDisk();
this.#format = disk.format; this.#format = disk.format;
const existing = disk.envelopes; const existing = disk.envelopes;
// The record the file stores at the key is the one removed when the removal's schema owns its stored id: the
// arrived record's type is its schema's name, which a type of another name does not carry.
const filtered = existing.filter( const filtered = existing.filter(
(candidate) => !(candidate.type === entry.type && candidate.key === entry.key), (candidate) => !(candidate.key === entry.key && (schemaIdForEnvelope(candidate) === schemaIdForEnvelope(entry) || writtenOwns(entry, schemaIdForEnvelope(candidate)))),
); );
await this.#writeAll([], filtered); await this.#writeAll([], filtered);
}); });
@ -113,7 +104,9 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
this.#format = STORE_FORMAT_VERSION; this.#format = STORE_FORMAT_VERSION;
} }
await this.#writeAll(entries, []); requireOneTypePerKey(this.filePath, entries, []);
// A key is held once: of same-type entries sharing a key in one batch, the last is kept.
await this.#writeAll([...new Map(entries.map((entry) => [entry.key, entry])).values()], []);
}); });
} }
@ -147,6 +140,28 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
} }
} }
// A store holds a key once, and the reader refuses a file that holds it twice, so a write that would put a second type under a
// key (against a record on disk, or within its own batch) is refused before anything is written. Retyping a record is a delete
// of the old type followed by a put of the new one.
function requireOneTypePerKey(path: string, supplied: CultCacheEnvelope[], held: CultCacheEnvelope[]): void {
const typeByKey = new Map<string, CultCacheEnvelope>();
for (const entry of held) {
typeByKey.set(entry.key, entry);
}
for (const entry of supplied) {
const prior = typeByKey.get(entry.key);
if (prior !== undefined && prior.type !== entry.type && !writtenOwns(entry, schemaIdForEnvelope(prior))) {
throw new SchemaConflictError(
schemaIdForEnvelope(entry),
[prior.type, entry.type],
entry.key,
`CultCache store ${path} holds record "${entry.key}" as type "${prior.type}", so type "${entry.type}" cannot be written under the same key; remove the record first.`,
);
}
typeByKey.set(entry.key, entry);
}
}
/** /**
* The one reader of a CultCache single-file store, for this package's store and for any consumer that must read a * The one reader of a CultCache single-file store, for this package's store and for any consumer that must read a
* store file (an authority bundle, say) rather than decode it itself. A path with nothing at it is an absent, empty, * store file (an authority bundle, say) rather than decode it itself. A path with nothing at it is an absent, empty,
@ -158,7 +173,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
export function readSingleFileStore(path: string): SingleFileStoreRead { export function readSingleFileStore(path: string): SingleFileStoreRead {
const data = readStoreBytes(path); const data = readStoreBytes(path);
if (data === undefined) { if (data === undefined) {
return { absent: true, format: STORE_FORMAT_VERSION, envelopes: [], repairedLegacyPayload: false }; return { absent: true, format: STORE_FORMAT_VERSION, envelopes: [] };
} }
try { try {
@ -214,8 +229,9 @@ type DecodedSnapshot = {
}; };
function encodeSnapshot(supplied: CultCacheEnvelope[], arrived: CultCacheEnvelope[], format: StoreFormat): unknown[] { function encodeSnapshot(supplied: CultCacheEnvelope[], arrived: CultCacheEnvelope[], format: StoreFormat): unknown[] {
const replaced = new Set(supplied.map((entry) => `${entry.type}::${entry.key}`)); // A key is held once: a supplied record replaces the arrived record under its key whatever the type.
const kept = arrived.filter((entry) => !replaced.has(`${entry.type}::${entry.key}`)); const replaced = new Set(supplied.map((entry) => entry.key));
const kept = arrived.filter((entry) => !replaced.has(entry.key));
const entries = [...kept, ...supplied]; const entries = [...kept, ...supplied];
const catalog = catalogEntriesFor([ const catalog = catalogEntriesFor([
...kept.map((envelope) => ({ envelope, registered: false })), ...kept.map((envelope) => ({ envelope, registered: false })),
@ -361,10 +377,6 @@ function defaultEntryFor(entry: CultCacheEnvelope): CultCacheSchemaCatalogEntry
}; };
} }
function schemaIdForEnvelope(entry: CultCacheEnvelope): string {
return entry.schemaId ?? entry.type;
}
function encodeCatalogEntry(entry: CultCacheSchemaCatalogEntry): unknown[] { function encodeCatalogEntry(entry: CultCacheSchemaCatalogEntry): unknown[] {
return [ return [
entry.schemaId, entry.schemaId,
@ -395,8 +407,6 @@ export type SingleFileStoreRead = {
absent: boolean; absent: boolean;
format: StoreFormat; format: StoreFormat;
envelopes: CultCacheEnvelope[]; envelopes: CultCacheEnvelope[];
// A legacy file whose payloads were not bytes: reading it repairs them, and the store rewrites the file.
repairedLegacyPayload: boolean;
}; };
function decodeStoreFile(data: Uint8Array): Omit<SingleFileStoreRead, "absent"> { function decodeStoreFile(data: Uint8Array): Omit<SingleFileStoreRead, "absent"> {
@ -420,7 +430,7 @@ function decodeStoreFile(data: Uint8Array): Omit<SingleFileStoreRead, "absent">
storedAt: record.storedAt, storedAt: record.storedAt,
}; };
}); });
return { format: snapshot.format, envelopes, repairedLegacyPayload: false }; return { format: snapshot.format, envelopes };
} }
const legacy = decodeLegacyEnvelopeArray(decoded); const legacy = decodeLegacyEnvelopeArray(decoded);
@ -428,19 +438,9 @@ function decodeStoreFile(data: Uint8Array): Omit<SingleFileStoreRead, "absent">
throw new Error("CultCache file is not a recognized CultCache MessagePack store."); throw new Error("CultCache file is not a recognized CultCache MessagePack store.");
} }
let repairedLegacyPayload = false; requireUniqueKeys(legacy);
const normalized = legacy.map((entry) => { const normalized = legacy.map((entry) => ({ ...entry, payload: normalizePayload(entry.payload) }));
const payload = normalizePayload(entry.payload); return { format: STORE_FORMAT_VERSION, envelopes: envelopeArraySchema.parse(normalized).map((entry) => ({ ...entry, payload: documentPayloadFromBytes(entry.payload) })) as CultCacheEnvelope[] };
if (payload !== entry.payload) {
repairedLegacyPayload = true;
}
return {
...entry,
payload,
};
});
return { format: STORE_FORMAT_VERSION, envelopes: envelopeArraySchema.parse(normalized).map((entry) => ({ ...entry, payload: documentPayloadFromBytes(entry.payload) })) as CultCacheEnvelope[], repairedLegacyPayload };
} }
function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined { function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined {
@ -472,9 +472,21 @@ function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined {
} }
const records = recordsRaw.map(decodeRecord); const records = recordsRaw.map(decodeRecord);
requireUniqueKeys(records);
return { format: decoded[0], catalogBySchemaId, records }; return { format: decoded[0], catalogBySchemaId, records };
} }
// A store holds a key once, whatever the type: a repeat is refused in a v1 snapshot and in a legacy envelope array alike.
function requireUniqueKeys(records: Array<{ key: string }>): void {
const seen = new Set<string>();
for (const { key } of records) {
if (seen.has(key)) {
throw new Error(`CultCache record "${key}" appears more than once.`);
}
seen.add(key);
}
}
function decodeCatalogEntry(value: unknown): CultCacheSchemaCatalogEntry { function decodeCatalogEntry(value: unknown): CultCacheSchemaCatalogEntry {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
throw new Error("CultCache schema catalog entries must be MessagePack arrays."); throw new Error("CultCache schema catalog entries must be MessagePack arrays.");
@ -502,12 +514,16 @@ function decodeCatalogEntry(value: unknown): CultCacheSchemaCatalogEntry {
throw new Error(`CultCache schema catalog entry "${schemaId}" has invalid members.`); throw new Error(`CultCache schema catalog entry "${schemaId}" has invalid members.`);
} }
if (typeof canonicalSchemaJson !== "string") {
throw new Error(`CultCache schema catalog entry "${schemaId}" has an invalid canonical schema.`);
}
return { return {
schemaId, schemaId,
schemaName, schemaName,
schemaVersion: isNonEmptyString(schemaVersion) ? schemaVersion : `${schemaName}.v1`, schemaVersion: isNonEmptyString(schemaVersion) ? schemaVersion : `${schemaName}.v1`,
contentHash: isNonEmptyString(contentHash) ? contentHash : schemaId, contentHash: isNonEmptyString(contentHash) ? contentHash : schemaId,
canonicalSchemaJson: typeof canonicalSchemaJson === "string" ? canonicalSchemaJson : "", canonicalSchemaJson,
compatibleSchemaIds, compatibleSchemaIds,
members: members.map(decodeCatalogMember), members: members.map(decodeCatalogMember),
}; };

View file

@ -1,5 +1,7 @@
import { decode } from "@msgpack/msgpack"; import { decode } from "@msgpack/msgpack";
import type { CultCacheEnvelope, CultCacheUnreadSlots } from "./types";
export const STORE_FORMAT_VERSION = "cultcache.store.v1"; export const STORE_FORMAT_VERSION = "cultcache.store.v1";
/** /**
@ -81,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;
} }
} }
@ -151,3 +156,19 @@ export function requireStoreSlots(decoded: unknown[]): void {
throw new Error(`CultCache store has ${decoded.length} top-level slots; this runtime reads ${STORE_SLOTS} (header, schema catalog, records).`); throw new Error(`CultCache store has ${decoded.length} top-level slots; this runtime reads ${STORE_SLOTS} (header, schema catalog, records).`);
} }
} }
/** The schema id a record is stored under: the one it carries, else its type. */
export function schemaIdForEnvelope(entry: CultCacheEnvelope): string {
return entry.schemaId ?? entry.type;
}
/**
* As C# WrittenOwns' declared half: the written type owns the stored schema id when its catalog entry carries that id as its own
* or lists it compatible, so a type renamed under a stable id restamps the record it already holds. (The C# half that lets an
* undeclared older id of the same schema name through when it reads whole is not ported; a same-named type keeps passing.)
* The one predicate of the key-identity rule: the store judges the file with it and the cache judges what it holds.
*/
export function writtenOwns(written: CultCacheEnvelope, storedId: string): boolean {
const entry = written.catalogEntry;
return entry !== undefined && (entry.schemaId === storedId || (entry.compatibleSchemaIds ?? []).includes(storedId));
}

View file

@ -59,9 +59,21 @@ export interface CultCacheDocumentDefinition<TSchema extends CultCacheSchema = C
members?: readonly CultCacheSchemaCatalogMember[]; members?: readonly CultCacheSchemaCatalogMember[];
global?: boolean; global?: boolean;
name?: CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>; name?: CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>;
/**
* Unique lookup aliases. A non-blank value names one record of the type: a write that would leave two records holding
* one value, and a store that already holds two, is refused naming both keys and the alias. Blank values (absent,
* empty, or only whitespace) are never indexed. `getByIndex` and `getKeyByIndex` answer from these.
*/
indexes?: indexes?:
| Readonly<Record<string, CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>>> | Readonly<Record<string, CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>>>
| readonly CultCacheDocumentIndexDefinition<InferCultCacheSchemaValue<TSchema>>[]; | readonly CultCacheDocumentIndexDefinition<InferCultCacheSchemaValue<TSchema>>[];
/**
* Selection and reference aliases: the member's catalog `indexAlias`. A role implies nothing about uniqueness, many
* records may share a value, and it is never indexed for lookup. An alias is declared in `indexes` or `roles`, not both.
*/
roles?:
| Readonly<Record<string, CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>>>
| readonly CultCacheDocumentIndexDefinition<InferCultCacheSchemaValue<TSchema>>[];
formatter?: CultCacheDocumentFormatter<InferCultCacheSchemaValue<TSchema>>; formatter?: CultCacheDocumentFormatter<InferCultCacheSchemaValue<TSchema>>;
codec?: SlotsCodec<InferCultCacheSchemaValue<TSchema> & object>; codec?: SlotsCodec<InferCultCacheSchemaValue<TSchema> & object>;
} }
@ -87,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

@ -14,7 +14,7 @@ import { z } from "zod";
import { CultCache } from "../src/cult-cache"; 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, readStoreBytes } from "../src/single-file-messagepack-backing-store"; import { SingleFileMessagePackBackingStore, readSingleFileStore, readStoreBytes } from "../src/single-file-messagepack-backing-store";
import { SchemaConflictError, StoreUnreadableError, refuseUnreadStoreBytes } from "../src/store-format"; import { 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";
@ -143,6 +143,63 @@ test("CultCache supports registry bootstrap, global documents, and lookup by nam
}); });
}); });
test("a put that would land a second type under a persisted key is refused, the cache and the file are unchanged, and the store reloads", async () => {
const alpha = defineDocumentType({ type: "alpha", schema: z.object({ v: z.number() }) });
const beta = defineDocumentType({ type: "beta", schema: z.object({ v: z.number() }) });
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
const registry = defineDocumentRegistry(alpha, beta);
const open = () => CultCache.builder().withRegistry(registry).withGenericStore(new SingleFileMessagePackBackingStore(storePath)).build();
const cache = open();
await cache.put(alpha, "shared", { v: 1 });
const before = [...(await readFile(storePath))];
await assert.rejects(() => cache.put(beta, "shared", { v: 2 }), SchemaConflictError);
assert.deepEqual([...(await readFile(storePath))], before);
assert.deepEqual(cache.get(alpha, "shared"), { v: 1 });
assert.equal(cache.get(beta, "shared"), undefined);
const reloaded = open();
await reloaded.pullAllBackingStores();
assert.deepEqual(reloaded.get(alpha, "shared"), { v: 1 });
assert.equal(await cache.delete(alpha, "shared"), true);
await cache.put(beta, "shared", { v: 2 });
const retyped = open();
await retyped.pullAllBackingStores();
assert.equal(retyped.get(alpha, "shared"), undefined);
assert.deepEqual(retyped.get(beta, "shared"), { v: 2 });
});
test("a put by a type renamed under the stored id, which lists that id compatible, restamps the record; an unrelated type is refused", async () => {
const old = defineDocumentType({ type: "ren.old", schema: z.object({ v: z.number() }), schemaId: "ren.id.v1", schemaName: "ren.old" });
const renamed = defineDocumentType({
type: "ren.new",
schema: z.object({ v: z.number() }),
schemaId: "ren.id.v2",
schemaName: "ren.new",
compatibleSchemaIds: ["ren.id.v2", "ren.id.v1"],
});
const other = defineDocumentType({ type: "ren.other", schema: z.object({ v: z.number() }), schemaId: "ren.id.other", schemaName: "ren.other" });
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
const open = (...types: (typeof old)[]) =>
types
.reduce((builder, type) => builder.withDocumentType(type), CultCache.builder())
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await open(old).put(old, "k", { v: 1 });
const cache = open(renamed, other as unknown as typeof old);
await cache.pullAllBackingStores();
assert.deepEqual(cache.get(renamed, "k"), { v: 1 });
await assert.rejects(() => cache.put(other, "k", { v: 9 }), SchemaConflictError);
await cache.put(renamed, "k", { v: 2 });
const reloaded = open(renamed);
await reloaded.pullAllBackingStores();
assert.deepEqual(reloaded.get(renamed, "k"), { v: 2 });
assert.equal(readSingleFileStore(storePath).envelopes.filter((envelope) => envelope.key === "k").length, 1);
});
test("CultCache can register name and index lookups after entries already exist", async () => { test("CultCache can register name and index lookups after entries already exist", async () => {
const noteDocument = defineDocumentType({ const noteDocument = defineDocumentType({
type: "note", type: "note",
@ -209,10 +266,10 @@ test("CultCache fails closed on unknown or illegal persisted polymorphic state",
])) ]))
.build(); .build();
await assert.rejects( // A record no registered type claims is carried, not a reason to refuse the load.
async () => cacheWithUnknownType.pullAllBackingStores(), await cacheWithUnknownType.pullAllBackingStores();
/No schema is registered for persisted document type "forbidden"\./, assert.deepEqual(cacheWithUnknownType.snapshot(), []);
); assert.deepEqual(cacheWithUnknownType.foreignRecords().map((record) => [record.key, record.schemaName]), [["mystery", "forbidden"]]);
const cacheWithDuplicateGlobal = CultCache.builder() const cacheWithDuplicateGlobal = CultCache.builder()
.withDocumentType(settingsDocument) .withDocumentType(settingsDocument)
@ -504,46 +561,56 @@ test("SingleFileMessagePackBackingStore refuses a record whose schema the catalo
await assert.rejects(() => cache.pullAllBackingStores(), StoreUnreadableError); await assert.rejects(() => cache.pullAllBackingStores(), StoreUnreadableError);
}); });
test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was persisted as an object", async () => { const legacyNoteType = defineDocumentType({
const noteDocument = defineDocumentType({ type: "note",
type: "note", schema: z.object({ title: z.string(), body: z.string().optional() }),
schema: z.object({ });
title: z.string(),
body: z.string(),
}),
});
// SYNTHETIC pre-v1 file (no real one is known): an envelope array with object payloads.
async function legacyNoteStore(second: unknown): Promise<{ storePath: string; bytes: Uint8Array }> {
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "legacy.msgpack"); const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "legacy.msgpack");
const envelopePayload = { const bytes = encode([
title: "Hello", { key: "note:hello", type: "note", payload: { title: "Hello", body: "world" }, storedAt: "2026-10-20T00:00:00Z" },
body: "world", { key: "note:other", type: "note", payload: second, storedAt: "2026-10-20T00:00:00Z" },
}; ]);
await writeFile(storePath, bytes);
await writeFile( return { storePath, bytes };
storePath, }
encode([
{
key: "note:hello",
type: "note",
payload: envelopePayload,
storedAt: new Date().toISOString(),
},
]),
);
test("a codec that refuses a synthetic pre-v1 store's content leaves the file byte-identical", async () => {
const { storePath, bytes } = await legacyNoteStore({ title: 42 });
const cache = CultCache.builder() const cache = CultCache.builder()
.withDocumentType(noteDocument) .withDocumentType(legacyNoteType)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await assert.rejects(() => cache.pullAllBackingStores());
assert.deepEqual([...(await readFile(storePath))], [...bytes]);
assert.deepEqual((await readdir(dirname(storePath))).sort(), ["legacy.msgpack"]);
});
test("a synthetic pre-v1 store is read untouched and becomes v1 only when the consumer writes, keeping its other entries", async () => {
const { storePath, bytes } = await legacyNoteStore({ title: "Other" });
const cache = CultCache.builder()
.withDocumentType(legacyNoteType)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath)) .withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build(); .build();
await cache.pullAllBackingStores(); await cache.pullAllBackingStores();
assert.deepEqual(cache.getRequired(noteDocument, "note:hello"), envelopePayload); assert.deepEqual(cache.getRequired(legacyNoteType, "note:hello"), { title: "Hello", body: "world" });
assert.deepEqual([...(await readFile(storePath))], [...bytes], "reading does not rewrite the file");
const rewritten = decode(await readFile(storePath)) as unknown[]; await cache.put(legacyNoteType, "note:new", { title: "New" });
assert.equal(rewritten[0], "cultcache.store.v1"); assert.equal((decode(await readFile(storePath)) as unknown[])[0], "cultcache.store.v1");
const records = rewritten[2] as unknown[][];
assert.equal(records.length, 1); const reopened = CultCache.builder()
assert.ok(records[0]?.[3] instanceof Uint8Array); .withDocumentType(legacyNoteType)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await reopened.pullAllBackingStores();
assert.deepEqual(reopened.getRequired(legacyNoteType, "note:hello"), { title: "Hello", body: "world" });
assert.deepEqual(reopened.getRequired(legacyNoteType, "note:other"), { title: "Other" });
assert.deepEqual(reopened.getRequired(legacyNoteType, "note:new"), { title: "New" });
}); });
test("CultCache rejects a second generic backing store", async () => { test("CultCache rejects a second generic backing store", async () => {
@ -2515,17 +2582,24 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads",
.filter((line) => line.length > 0 && !line.startsWith("#")) .filter((line) => line.length > 0 && !line.startsWith("#"))
.map((line) => line.split(/\s+/u)); .map((line) => line.split(/\s+/u));
// Every vector in the folder has a manifest row. // Every vector in the folder has a manifest row.
const listed = rows.map(([vector]) => vector!).filter((vector) => !vector.startsWith("..")).sort(); const listed = rows.map(([vector]) => vector!).filter((vector) => !vector.startsWith("..") && !vector.startsWith("@")).sort();
assert.deepEqual((await readdir(root)).filter((name) => name.endsWith(".bin")).sort(), listed); assert.deepEqual((await readdir(root)).filter((name) => name.endsWith(".bin")).sort(), listed);
const dir = await mkdtemp(join(tmpdir(), "cultcache-readability-")); const dir = await mkdtemp(join(tmpdir(), "cultcache-readability-"));
const seed = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "seed.msgpack"); const seed = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "seed.msgpack");
const envelopes = await new SingleFileMessagePackBackingStore(seed).pullAll(); const envelopes = await new SingleFileMessagePackBackingStore(seed).pullAll();
for (const [vector, , , typescript] of rows) { for (const [vector, , , typescript] of rows) {
const reads = typescript === "reads"; const reads = typescript === "reads";
const bytes = await readFile(join(root, vector)); // "@directory": the walker makes an empty directory at the store path; the runtime refuses to open or replace it and leaves it be.
const isDirectory = vector === "@directory";
const bytes = isDirectory ? Buffer.alloc(0) : await readFile(join(root, vector));
for (const operation of ["pullAll", "softPushAll", "pushAll", "push", "delete"] as const) { for (const operation of ["pullAll", "softPushAll", "pushAll", "push", "delete"] as const) {
const file = join(dir, `${vector.replace(/[^a-z0-9.-]/giu, "_")}.${operation}.msgpack`); const file = join(dir, `${vector.replace(/[^a-z0-9.-]/giu, "_")}.${operation}.msgpack`);
await writeFile(file, bytes); if (isDirectory) {
assert.equal(typescript, "refuses", "a directory is never a store");
await mkdir(file);
} else {
await writeFile(file, bytes);
}
const store = new SingleFileMessagePackBackingStore(file); const store = new SingleFileMessagePackBackingStore(file);
const run = () => const run = () =>
operation === "pullAll" operation === "pullAll"
@ -2560,6 +2634,10 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads",
} else { } else {
assert.ok(!keys.includes(envelopes[0]!.key), `${vector} ${operation}`); assert.ok(!keys.includes(envelopes[0]!.key), `${vector} ${operation}`);
} }
} else if (isDirectory) {
await assert.rejects(run, (error) => !(error instanceof StoreUnreadableError), `${vector} ${operation}: an I/O failure, not a typed refusal`);
assert.ok((await lstat(file)).isDirectory(), `${vector} ${operation} replaced the directory`);
assert.deepEqual(await readdir(file), [], `${vector} ${operation} wrote into the directory`);
} else { } else {
await assert.rejects( await assert.rejects(
run, run,
@ -2572,6 +2650,27 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads",
} }
}); });
// One key is held once: a push whose type does not own the stored schema id is refused, so the writer cannot emit a store its own
// reader refuses; one whose type owns it (its own id, or one it lists compatible, as C# WrittenOwns) restamps the record in place.
test("SingleFileMessagePackBackingStore push asks whether the written type owns the stored id, not what the types are called", async () => {
// One cache holds one record per key, so each envelope is made in a cache of its own.
const owner = CultCache.builder().withDocumentType(ownsId).build();
const lister = CultCache.builder().withDocumentType(listsId).build();
await owner.put(ownsId, "k", { name: "owner" });
await lister.put(listsId, "k", { name: "lister" });
const file = join(await mkdtemp(join(tmpdir(), "cultcache-push-key-")), "store.msgpack");
const store = new SingleFileMessagePackBackingStore(file);
const idsOf = async () => ((decode(await readFile(file)) as unknown[][])[2] as unknown[][]).map((record) => [record[0], record[1]]);
await store.push(lister.getRequiredEnvelope(listsId, "k"));
await assert.rejects(() => store.push(owner.getRequiredEnvelope(ownsId, "k")), SchemaConflictError);
assert.deepEqual(await idsOf(), [["k", "id.lister"]]);
await store.pushAll([lister.getRequiredEnvelope(listsId, "k")]);
await store.push(lister.getRequiredEnvelope(listsId, "k"));
assert.equal((await store.pullAll()).length, 1);
});
// Owner beats lister: a definition that owns an id and one that lists it as compatible register together in either order. A record // Owner beats lister: a definition that owns an id and one that lists it as compatible register together in either order. A record
// under the id is the owner's, and a lister's only when nothing owns it; two listers and no owner name no single definition. // under the id is the owner's, and a lister's only when nothing owns it; two listers and no owner name no single definition.
const ownsId = defineDocumentType({ type: "tests.owns", schema: z.object({ name: z.string() }), schemaId: "id.owned", schemaName: "tests.owns" }); const ownsId = defineDocumentType({ type: "tests.owns", schema: z.object({ name: z.string() }), schemaId: "id.owned", schemaName: "tests.owns" });

View file

@ -24,7 +24,6 @@ const itemDocument = defineDocumentType({
schemaVersion: "gamecult.first_call_item.v1", schemaVersion: "gamecult.first_call_item.v1",
schema: z.object({ name: z.string(), category: z.string(), value: z.number().int() }), schema: z.object({ name: z.string(), category: z.string(), value: z.number().int() }),
name: "name", name: "name",
indexes: { category: "category" },
}); });
const registry = defineDocumentRegistry(itemDocument); const registry = defineDocumentRegistry(itemDocument);

View file

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

View file

@ -119,6 +119,19 @@ test("a payload that ends early, is empty or carries trailing bytes is the reade
assert.doesNotThrow(() => checkPayloadDepth(bytes(0xc0))); 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

@ -12,6 +12,7 @@ import { decode, encode } from "@msgpack/msgpack";
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 type { CultCacheEnvelope } from "../src/types"; import type { CultCacheEnvelope } from "../src/types";
import { import {
@ -471,44 +472,70 @@ test("the store lock is released when the operation throws", async () => {
} }
}); });
// SYNTHETIC pre-v1 vector: no real file of this shape is known to exist. A top-level array of {key,type,payload,storedAt}
// whose payload is not binary (an object, a Node Buffer JSON, an int array) is what the removed heal-on-open rewrote.
const legacyObjectPayload = (): Uint8Array => const legacyObjectPayload = (): Uint8Array =>
encode([{ key: "note:hello", type: "note", payload: { title: "Hello" }, storedAt: "2026-10-20T00:00:00Z" }]); encode([{ key: "note:hello", type: "note", payload: { title: "Hello" }, storedAt: "2026-10-20T00:00:00Z" }]);
test("an open that heals a legacy store waits for the store lock and heals from its own read", async () => { test("opening a synthetic pre-v1 store with object payloads returns them normalized and leaves the file untouched, even while another holder has the lock", async () => {
const directory = await scratch(); const directory = await scratch();
try { try {
const path = join(directory, "legacy.cc"); const path = join(directory, "legacy.cc");
const legacy = legacyObjectPayload(); const legacy = legacyObjectPayload();
await writeFile(path, legacy); await writeFile(path, legacy);
const store = new SingleFileMessagePackBackingStore(path); const store = new SingleFileMessagePackBackingStore(path);
let healed: Promise<unknown> | undefined;
let settled = false;
await withStoreWriteLock(path, async () => { await withStoreWriteLock(path, async () => {
healed = store.pullAll().then(() => { const [lockHeld] = await Promise.all([readdir(directory)]);
settled = true; const envelopes = await within(store.pullAll(), 5000, "an open does not wait for the write lock");
}); assert.deepEqual(envelopes.map((entry) => entry.key), ["note:hello"]);
await sleepFor(300); assert.deepEqual(decode(documentPayloadBytes(envelopes[0].payload)), { title: "Hello" });
assert.equal(settled, false, "the heal must wait for the lock"); assert.deepEqual([...(await readFile(path))], [...legacy]);
assert.deepEqual([...(await readFile(path))], [...legacy], "the file keeps its legacy bytes while the lock is held elsewhere"); assert.deepEqual((await readdir(directory)).sort(), lockHeld.sort(), "an open adds no lock or temp file");
}); });
await within(healed!, 5000, "the heal"); assert.deepEqual([...(await readFile(path))], [...legacy]);
assert.equal((decode(await readFile(path)) as unknown[])[0], "cultcache.store.v1"); assert.deepEqual((await readdir(directory)).filter((name) => name !== "legacy.cc" && name !== "legacy.cc.lock"), []);
} finally { } finally {
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
} }
}); });
test("push and delete over a legacy store that needs healing finish instead of waiting on their own lock", async () => { test("opening a synthetic pre-v1 store with Buffer-JSON and int-array payloads normalizes them in memory and leaves the file untouched", async () => {
const directory = await scratch();
try {
const path = join(directory, "legacy.cc");
const inner = encode({ title: "Hello" });
const legacy = encode([
{ key: "a", type: "note", payload: { type: "Buffer", data: [...inner] }, storedAt: "2026-10-20T00:00:00Z" },
{ key: "b", type: "note", payload: [...inner], storedAt: "2026-10-20T00:00:00Z" },
]);
await writeFile(path, legacy);
const envelopes = await new SingleFileMessagePackBackingStore(path).pullAll();
assert.deepEqual(envelopes.map((entry) => entry.key), ["a", "b"]);
for (const envelope of envelopes) {
assert.deepEqual(decode(documentPayloadBytes(envelope.payload)), { title: "Hello" });
}
assert.deepEqual([...(await readFile(path))], [...legacy]);
assert.deepEqual(await readdir(directory), ["legacy.cc"]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("push and delete over a synthetic pre-v1 store write v1 and keep the other legacy entries, normalized payloads included", async () => {
const directory = await scratch(); const directory = await scratch();
try { try {
const path = join(directory, "legacy.cc"); const path = join(directory, "legacy.cc");
await writeFile(path, legacyObjectPayload()); await writeFile(path, legacyObjectPayload());
const store = new SingleFileMessagePackBackingStore(path); const store = new SingleFileMessagePackBackingStore(path);
await within(store.push(entryOf("a")), 5000, "push"); await within(store.push(entryOf("a")), 5000, "push");
assert.equal((decode(await readFile(path)) as unknown[])[0], "cultcache.store.v1");
assert.deepEqual((await store.pullAll()).map((entry) => entry.key).sort(), ["a", "note:hello"]);
await writeFile(path, legacyObjectPayload()); await writeFile(path, legacyObjectPayload());
await within(store.delete(entryOf("a")), 5000, "delete"); await within(store.delete(entryOf("a")), 5000, "delete");
const keys = (await store.pullAll()).map((entry) => entry.key); assert.equal((decode(await readFile(path)) as unknown[])[0], "cultcache.store.v1");
assert.deepEqual(keys, ["note:hello"]); const kept = await store.pullAll();
assert.deepEqual(kept.map((entry) => entry.key), ["note:hello"]);
assert.deepEqual(decode(documentPayloadBytes(kept[0].payload)), { title: "Hello" });
} finally { } finally {
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
} }
@ -545,15 +572,84 @@ const snapshotOf = async (store: SingleFileMessagePackBackingStore): Promise<str
.map((entry) => `${entry.type}/${entry.key}:${[...documentPayloadBytes(entry.payload)].at(-1)}`) .map((entry) => `${entry.type}/${entry.key}:${[...documentPayloadBytes(entry.payload)].at(-1)}`)
.sort(); .sort();
test("push replaces the record at the same type and key and keeps every other", async () => { test("push replaces the record under the same key and type and keeps every other; retyping is delete then push", async () => {
const directory = await scratch(); const directory = await scratch();
try { try {
const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc")); const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc"));
await store.pushAll([entryOf("a", "t", 1), entryOf("b", "t", 2), entryOf("a", "u", 3)]); await store.pushAll([entryOf("a", "t", 1), entryOf("b", "t", 2), entryOf("c", "u", 3)]);
await store.push(entryOf("a", "t", 9)); await store.push(entryOf("a", "t", 9));
assert.deepEqual(await snapshotOf(store), ["t/a:9", "t/b:2", "u/a:3"]); assert.deepEqual(await snapshotOf(store), ["t/a:9", "t/b:2", "u/c:3"]);
await store.push(entryOf("c", "t", 4)); await store.push(entryOf("d", "t", 4));
assert.deepEqual(await snapshotOf(store), ["t/a:9", "t/b:2", "t/c:4", "u/a:3"]); assert.deepEqual(await snapshotOf(store), ["t/a:9", "t/b:2", "t/d:4", "u/c:3"]);
await store.delete(entryOf("a", "t"));
await store.push(entryOf("a", "u", 5));
assert.deepEqual(await snapshotOf(store), ["t/b:2", "t/d:4", "u/a:5", "u/c:3"]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("a push that would land a second type under an existing key is refused and the file is byte for byte unchanged", async () => {
const directory = await scratch();
try {
const path = join(directory, "store.cc");
const store = new SingleFileMessagePackBackingStore(path);
await store.pushAll([entryOf("k", "t", 1), entryOf("m", "t", 2)]);
const before = [...(await readFile(path))];
await assert.rejects(
() => store.push(entryOf("k", "u", 9)),
(error: unknown) => error instanceof SchemaConflictError && error.recordKey === "k" && /holds record "k" as type "t"/u.test(error.message),
);
assert.deepEqual([...(await readFile(path))], before);
assert.deepEqual(await snapshotOf(new SingleFileMessagePackBackingStore(path)), ["t/k:1", "t/m:2"]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("a pushAll batch that carries one key under two types is refused and writes nothing", async () => {
const directory = await scratch();
try {
const path = join(directory, "store.cc");
const store = new SingleFileMessagePackBackingStore(path);
await assert.rejects(() => store.pushAll([entryOf("k", "t", 1), entryOf("k", "u", 2)]), SchemaConflictError);
assert.equal((await readdir(directory)).includes("store.cc"), false);
await store.pushAll([entryOf("a", "t", 1)]);
const before = [...(await readFile(path))];
await assert.rejects(() => store.pushAll([entryOf("k", "t", 1), entryOf("k", "u", 2)]), SchemaConflictError);
assert.deepEqual([...(await readFile(path))], before);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("a pushAll batch that carries one key twice under one type keeps the last and the file reads back with one record", async () => {
const directory = await scratch();
try {
const path = join(directory, "store.cc");
await new SingleFileMessagePackBackingStore(path).pushAll([entryOf("k", "t", 1), entryOf("a", "t", 3), entryOf("k", "t", 2)]);
assert.deepEqual(await snapshotOf(new SingleFileMessagePackBackingStore(path)), ["t/a:3", "t/k:2"]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("a legacy envelope array that holds a key under two types is refused on open, as a v1 store is", async () => {
const directory = await scratch();
try {
const path = join(directory, "legacy.cc");
const bytes = encode([
{ key: "a", type: "t", payload: { n: 1 }, storedAt: "2026-10-20T00:00:00Z" },
{ key: "a", type: "u", payload: { n: 2 }, storedAt: "2026-10-20T00:00:00Z" },
]);
await writeFile(path, bytes);
const store = new SingleFileMessagePackBackingStore(path);
await assert.rejects(
() => store.pullAll(),
(error: unknown) => error instanceof StoreUnreadableError && /"a" appears more than once/u.test(error.message),
);
await assert.rejects(() => store.push(entryOf("b")), StoreUnreadableError);
assert.deepEqual([...(await readFile(path))], [...bytes]);
} finally { } finally {
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
} }
@ -563,11 +659,12 @@ test("delete removes only the record at that type and key", async () => {
const directory = await scratch(); const directory = await scratch();
try { try {
const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc")); const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc"));
await store.pushAll([entryOf("a", "t", 1), entryOf("b", "t", 2), entryOf("a", "u", 3)]); await store.pushAll([entryOf("a", "t", 1), entryOf("b", "t", 2), entryOf("c", "u", 3)]);
await store.delete(entryOf("a", "t")); await store.delete(entryOf("a", "t"));
assert.deepEqual(await snapshotOf(store), ["t/b:2", "u/a:3"]); assert.deepEqual(await snapshotOf(store), ["t/b:2", "u/c:3"]);
await store.delete(entryOf("zzz", "t")); await store.delete(entryOf("zzz", "t"));
assert.deepEqual(await snapshotOf(store), ["t/b:2", "u/a:3"]); await store.delete(entryOf("c", "t"));
assert.deepEqual(await snapshotOf(store), ["t/b:2", "u/c:3"]);
} finally { } finally {
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
} }
@ -697,7 +794,7 @@ test("a store file of an unrecognized shape is refused, and a store naming a mis
} }
}); });
test("opening a store that needs no healing writes nothing: a missing store leaves no lock file, a legacy store with byte payloads keeps its bytes", async () => { test("opening writes nothing: a missing store leaves no lock file, a legacy store with byte payloads keeps its bytes", async () => {
const directory = await scratch(); const directory = await scratch();
try { try {
const absent = join(directory, "nested", "absent.cc"); const absent = join(directory, "nested", "absent.cc");

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

@ -0,0 +1,583 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { test } from "node:test";
import { decode } from "@msgpack/msgpack";
import { z } from "zod";
import { codecs } from "../src";
import { CultCache } from "../src/cult-cache";
import { defineDocumentType, resolveDocumentMembers } from "../src/document";
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
import type { AnyCultCacheDocumentDefinition } from "../src/types";
// variants:ruling:unique-indexes. `indexes` are unique within one type, `roles` are selection aliases. The vectors in
// tests/vectors/unique-index are written by the C# reference; the rest of the behaviour is pinned here.
function findVectors(from: string): string {
for (let dir = from; ; dir = resolve(dir, "..")) {
if (existsSync(join(dir, "tests", "vectors", "unique-index"))) return join(dir, "tests", "vectors", "unique-index");
if (resolve(dir, "..") === dir) throw new Error("tests/vectors/unique-index not found above the test.");
}
}
const vectors = findVectors(__dirname);
const CANARY = "CANARY-uidx-5e1b";
const skuSchema = z.object({ sku: z.any().optional(), group: z.string().optional() });
function item(type: string, where: "indexes" | "roles" | "none" = "indexes", schemaName = `tests.${type}`) {
return defineDocumentType({
type,
schemaId: `${schemaName}.v1`,
schemaName,
schemaVersion: `${schemaName}.v1`,
schema: skuSchema,
...(where === "none" ? {} : { [where]: { sku: "sku" } }),
});
}
// A definition whose payload is never read back, so a value of any scalar kind (a bigint included) can be held and indexed.
function scalar(type: string) {
const held: unknown[] = [];
return defineDocumentType({
type,
schemaId: `tests.${type}.v1`,
schemaName: `tests.${type}`,
schemaVersion: `tests.${type}.v1`,
schema: skuSchema,
formatter: {
encode: (value: unknown) => new Uint8Array([held.push(value) - 1]),
decode: (payload: Uint8Array) => held[payload[0]] as never,
},
indexes: { sku: "sku" },
});
}
function memory(...definitions: AnyCultCacheDocumentDefinition[]) {
let builder = CultCache.builder();
for (const definition of definitions) builder = builder.withDocumentType(definition);
return builder.build();
}
function refusal(keyA: string, keyB: string, schemaName: string, alias: string) {
return `Records ${keyA} and ${keyB} (${schemaName}) both hold unique index '${alias}'; an index value names one record.`;
}
test("a put of a held value at another key is refused naming both keys and the alias, and nothing moves", async () => {
const dir = await mkdtemp(join(tmpdir(), "uidx-"));
try {
const file = join(dir, "items.cc");
const document = item("item");
const cache = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
await cache.pullAllBackingStores();
await cache.put(document, "zeta", { sku: CANARY });
const before = await readFile(file);
const snapshot = cache.snapshot();
await assert.rejects(cache.put(document, "alpha", { sku: CANARY }), (error: Error) => {
assert.equal(error.message, refusal("alpha", "zeta", "tests.item", "sku"));
assert.ok(!error.message.includes(CANARY), "the value is never echoed");
return true;
});
assert.deepEqual(await readFile(file), before, "the store is byte-identical");
assert.deepEqual(cache.snapshot(), snapshot);
assert.equal(cache.get(document, "alpha"), undefined);
assert.equal(cache.getKeyByIndex(document, "sku", CANARY), "zeta");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("rewriting a record's own key with its own value lands", async () => {
const document = item("item");
const cache = memory(document);
await cache.put(document, "a", { sku: "S", group: "one" });
await cache.put(document, "a", { sku: "S", group: "two" });
assert.equal(cache.getKeyByIndex(document, "sku", "S"), "a");
assert.equal(cache.get(document, "a")?.group, "two");
});
test("a removed or changed holder releases its value", async () => {
const document = item("item");
const cache = memory(document);
await cache.put(document, "b", { sku: "S" });
await assert.rejects(cache.put(document, "a", { sku: "S" }), /Records a and b/u);
assert.equal(cache.getKeyByIndex(document, "sku", "S"), "b", "the refused write did not take the value");
await cache.delete(document, "b");
assert.equal(cache.getKeyByIndex(document, "sku", "S"), undefined);
await cache.put(document, "a", { sku: "S" });
assert.equal(cache.getKeyByIndex(document, "sku", "S"), "a");
await cache.put(document, "a", { sku: "T" });
await cache.put(document, "b", { sku: "S" });
assert.equal(cache.getKeyByIndex(document, "sku", "S"), "b");
assert.equal(cache.getKeyByIndex(document, "sku", "T"), "a");
});
// The C# White_Space set, and nothing else, is blank. Every UTF-16 code unit is judged, so a rule written for a few
// characters (or for JavaScript's \s, which counts U+FEFF and omits U+0085) cannot pass.
const CSHARP_WHITE_SPACE = (code: number) =>
(code >= 0x09 && code <= 0x0d) || code === 0x20 || code === 0x85 || code === 0xa0 || code === 0x1680 ||
(code >= 0x2000 && code <= 0x200a) || code === 0x2028 || code === 0x2029 || code === 0x202f || code === 0x205f || code === 0x3000;
test("blank is exactly the C# White_Space set: every code unit, alone, repeated and padded", async () => {
const document = item("item");
const cache = memory(document);
const disagreements: string[] = [];
for (let code = 0; code <= 0xffff; code++) {
if (code >= 0xd800 && code <= 0xdfff) continue; // lone surrogates do not survive MessagePack text
const character = String.fromCharCode(code);
await cache.put(document, "x", { sku: character });
let blank = true;
try {
await cache.put(document, "y", { sku: character });
} catch {
blank = false;
}
if (blank !== CSHARP_WHITE_SPACE(code)) disagreements.push(code.toString(16));
await cache.delete(document, "x");
await cache.delete(document, "y");
}
assert.deepEqual(disagreements, []);
// Not trimmed: a padded value is the value, distinct from its trimmed form.
await cache.put(document, "x", { sku: " a " });
await cache.put(document, "y", { sku: "a" });
assert.equal(cache.getKeyByIndex(document, "sku", " a "), "x");
assert.equal(cache.getKeyByIndex(document, "sku", "a"), "y");
await assert.rejects(cache.put(document, "z", { sku: " a " }), /Records x and z/u);
});
test("blank values are never indexed and never conflict: empty, spaces, absent", async () => {
const document = item("item");
const cache = memory(document);
for (const [key, sku] of [["a", ""], ["b", " "], ["c", "\u0085"], ["d", undefined], ["e", " "]] as const) {
await cache.put(document, key, { sku });
}
assert.equal(cache.getKeyByIndex(document, "sku", ""), undefined);
assert.equal(cache.getKeyByIndex(document, "sku", " "), undefined);
// U+FEFF and U+001F are values.
await cache.put(document, "f", { sku: "" });
await assert.rejects(cache.put(document, "g", { sku: "" }), /Records f and g/u);
await cache.put(document, "h", { sku: "\u001F" });
await assert.rejects(cache.put(document, "i", { sku: "\u001F" }), /Records h and i/u);
});
test("two types may share an alias and a value", async () => {
const first = item("first");
const second = item("second");
const cache = memory(first, second);
await cache.put(first, "a", { sku: "S" });
await cache.put(second, "b", { sku: "S" });
assert.equal(cache.getKeyByIndex(first, "sku", "S"), "a");
assert.equal(cache.getKeyByIndex(second, "sku", "S"), "b");
});
test("roles may share values and are not looked up", async () => {
const document = item("item", "roles");
const cache = memory(document);
await cache.put(document, "a", { sku: "S" });
await cache.put(document, "b", { sku: "S" });
await cache.put(document, "c", { sku: "S" });
assert.equal(cache.getKeyByIndex(document, "sku", "S"), undefined);
assert.equal(cache.getByIndex(document, "sku", "S"), undefined);
});
test("an alias declared in indexes and roles is refused at definition; each map is checked like indexes", () => {
const base = { type: "bad", schema: skuSchema, schemaId: "tests.bad.v1", schemaName: "tests.bad", schemaVersion: "tests.bad.v1" };
assert.throws(
() => defineDocumentType({ ...base, indexes: { sku: "sku" }, roles: { sku: "sku" } }),
{ message: 'CultCache document type "bad" declares "sku" as both an index and a role; an index already names its role.' },
);
assert.throws(() => defineDocumentType({ ...base, roles: { " ": "sku" } }), /declares a role with an empty name/u);
assert.throws(
() => defineDocumentType({ ...base, roles: [{ name: "r", accessor: "sku" }, { name: "r", accessor: "group" }] }),
/declares duplicate role "r"/u,
);
assert.doesNotThrow(() => defineDocumentType({ ...base, indexes: { sku: "sku" }, roles: { group: "group" } }));
});
test("a codec definition's catalog entry is identical with an alias under indexes or roles", async () => {
const codec = codecs.slots<{ sku: string; group: string }>({ 0: ["sku", codecs.string], 1: ["group", codecs.string] }, "T.Item");
const make = (where: "indexes" | "roles") =>
defineDocumentType({
type: "codec_item",
schemaId: "tests.codec_item.v1",
schemaName: "tests.codec_item",
schemaVersion: "tests.codec_item.v1",
schema: z.object({ sku: z.string(), group: z.string() }),
codec,
[where]: { sku: "sku" },
});
const catalog = async (definition: AnyCultCacheDocumentDefinition) => {
const cache = memory(definition);
await cache.put(definition, "a", { sku: "S", group: "g" } as never);
return cache.snapshot()[0].catalogEntry!;
};
const members = (definition: AnyCultCacheDocumentDefinition) => resolveDocumentMembers(definition);
assert.deepEqual(members(make("indexes")), members(make("roles")));
assert.equal(members(make("roles")).find((member) => member.memberName === "Sku")?.indexAlias, "sku");
const [unique, role] = [await catalog(make("indexes")), await catalog(make("roles"))];
assert.equal(unique.canonicalSchemaJson, role.canonicalSchemaJson);
assert.equal(unique.contentHash, role.contentHash);
assert.equal(unique.schemaId, role.schemaId);
});
test("vectors values.msgpack: every row's canonical text, blank rows no value", async () => {
const rows = decode(await readFile(join(vectors, "values.msgpack")), { useBigInt64: true }) as [string, string, unknown, string | null][];
assert.ok(rows.length >= 20);
for (const [label, kind, value, text] of rows) {
const document = scalar(`value_${label.replaceAll(/[^a-z0-9]/gu, "_")}`);
const cache = memory(document);
void kind;
const normalized = value;
await cache.put(document, "one", { sku: normalized });
if (text === null) {
await cache.put(document, "two", { sku: normalized });
assert.equal(cache.get(document, "two") !== undefined, true, `${label}: a blank value commits twice`);
for (const probe of ["", " ", "NaN", "0", "Infinity", "-Infinity", "null", "undefined"]) {
assert.equal(cache.getKeyByIndex(document, "sku", probe), undefined, `${label}: found by '${probe}'`);
}
} else {
assert.equal(cache.getKeyByIndex(document, "sku", text), "one", `${label}: found by its text`);
await assert.rejects(cache.put(document, "two", { sku: normalized }), /Records one and two/u, label);
}
}
});
// An independent oracle for the double rule: the text must denote exactly the double, so N / 10^d == mantissa * 2^exponent
// in exact integer arithmetic, with no trailing zero and no bare point. A rule written to a few spellings cannot pass.
function exactlyDenotes(text: string, value: number): boolean {
const match = /^(-?)(\d+)(?:\.(\d*[1-9]))?$/u.exec(text);
if (!match) return false;
const decimals = match[3]?.length ?? 0;
const numerator = BigInt(match[2] + (match[3] ?? ""));
const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, Math.abs(value));
const bits = view.getBigUint64(0);
const biased = Number((bits >> 52n) & 0x7ffn);
const mantissa = biased === 0 ? bits & 0xfffffffffffffn : (bits & 0xfffffffffffffn) | (1n << 52n);
const exponent = (biased === 0 ? 1 : biased) - 1075;
const lhs = numerator * (exponent >= 0 ? 1n : 2n ** BigInt(-exponent));
const rhs = mantissa * (exponent >= 0 ? 2n ** BigInt(exponent) : 1n) * 10n ** BigInt(decimals);
return (match[1] === "-") === (value < 0) && lhs === rhs;
}
test("a number's index text is its exact value: integers as digits, doubles as the exact expansion, NaN and infinities none", async () => {
const document = scalar("number");
const cache = memory(document);
const probes = [0.1, -0.1, 2.5e-7, 1.5, -123.456, 5e-324, 2.2250738585072014e-308, 1e-10, 123456789.123456789, Number.MIN_VALUE * 3,
...Array.from({ length: 200 }, (_, i) => Math.sin(i + 1) * 10 ** ((i % 40) - 20)), 1e21 + 2 ** 20 + 0.5 * 0];
// Round trip through lookups: the exact expansion of each probe is found, and it denotes the probe exactly.
for (const value of probes) {
if (Number.isInteger(value)) continue;
await cache.put(document, "k", { sku: value });
const candidates = candidateTexts(value);
const found = candidates.filter((candidate) => cache.getKeyByIndex(document, "sku", candidate) === "k");
assert.equal(found.length, 1, `${value}: exactly one spelling is its index text`);
assert.ok(exactlyDenotes(found[0], value), `${value}: ${found[0]} denotes it exactly`);
}
await cache.put(document, "k", { sku: 1e21 });
assert.equal(cache.getKeyByIndex(document, "sku", "1000000000000000000000"), "k");
await cache.put(document, "k", { sku: -0 });
assert.equal(cache.getKeyByIndex(document, "sku", "0"), "k");
await cache.put(document, "k", { sku: 12n });
assert.equal(cache.getKeyByIndex(document, "sku", "12"), "k");
await cache.put(document, "k", { sku: true });
assert.equal(cache.getKeyByIndex(document, "sku", "True"), "k");
await cache.put(document, "k", { sku: false });
assert.equal(cache.getKeyByIndex(document, "sku", "False"), "k");
for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
await cache.put(document, "p", { sku: value });
await cache.put(document, "q", { sku: value });
}
});
// Spellings a lookup could be probed with: the exact decimal expansion computed by an arbitrary-precision route.
function candidateTexts(value: number): string[] {
const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, Math.abs(value));
const bits = view.getBigUint64(0);
const biased = Number((bits >> 52n) & 0x7ffn);
const mantissa = biased === 0 ? bits & 0xfffffffffffffn : (bits & 0xfffffffffffffn) | (1n << 52n);
const k = 1075 - (biased === 0 ? 1 : biased);
const scaled = (mantissa * 5n ** BigInt(k)).toString().padStart(k + 1, "0");
const point = scaled.length - k;
const text = `${scaled.slice(0, point)}.${scaled.slice(point)}`.replace(/0+$/u, "").replace(/\.$/u, "");
return [(value < 0 ? "-" : "") + text, String(value), value.toString()].filter((spelling, i, all) => all.indexOf(spelling) === i);
}
function twoDefinitions(schemaId: string, where: "indexes" | "roles") {
return defineDocumentType({
type: "unique_item",
schemaId,
schemaName: "tests.unique_item",
schemaVersion: "tests.unique_item.v1",
schema: z.object({ sku: z.string(), category: z.string() }),
codec: codecs.slots<{ sku: string; category: string }>({ 0: ["sku", codecs.string], 1: ["category", codecs.string] }, "UqVecItem"),
[where]: { Sku: "sku" },
});
}
test("vectors duplicate-store.cc and blank-store.cc hold their verdicts", async () => {
const outcomes = decode(await readFile(join(vectors, "outcomes.msgpack"))) as [string, string | null, string, string | null, string | null][];
assert.equal(outcomes.length, 3);
const dir = await mkdtemp(join(tmpdir(), "uidx-vec-"));
try {
for (const [name, alias, outcome, key1, key2] of outcomes) {
const file = join(dir, `${outcomes.indexOf(outcomes.find((row) => row[0] === name && row[1] === alias)!)}-${name}`);
await copyFile(join(vectors, name), file);
const before = await readFile(file);
const [envelope] = await new SingleFileMessagePackBackingStore(file).pullAll();
const document = twoDefinitions(envelope.schemaId!, alias === null ? "roles" : "indexes");
const cache = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
const stateBefore = cache.snapshot();
if (outcome === "open") {
await cache.pullAllBackingStores();
assert.equal(cache.snapshot().length, name === "blank-store.cc" ? 2 : 4);
if (alias === null) assert.equal(cache.getKeyByIndex(document, "Sku", "S-1"), undefined);
} else {
await assert.rejects(cache.pullAllBackingStores(), (error: Error) => {
assert.equal(error.message, refusal(key1!, key2!, "tests.unique_item", alias!));
assert.ok(!error.message.includes("S-1"));
return true;
});
assert.deepEqual(cache.snapshot(), stateBefore, "a refused load admits nothing");
}
assert.deepEqual(await readFile(file), before, `${name} is untouched`);
}
} finally {
await rm(dir, { recursive: true, force: true });
}
});
// refusals.msgpack: rows [label, ops, refusal] written by the C# reference. TypeScript has no batch commit
// (variants:idea:unique-index-runtimes.ts-batch-commit), so it performs every row whose commits each hold one record:
// 5, 8-13, 19-22, 24, 25, 27, 29, 31, 32, 33, 35 and 37 of the README (20).
const REFUSAL_SCHEMAS: [string, string, string, Record<string, "sku" | "second">][] = [
["unique_item", "tests.unique_item", "sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882", { Sku: "sku" }],
["uq_pair", "tests.uq_pair", "sha256:2538cceafb6dcbce22ef11b932ecb804af5f96bc3aaedff692a52d3640433e60", { Sku: "sku", Alt: "second" }],
["uq_alias", "tests.uq_alias", "sha256:77a77a394ebd8bf71e14b9b6f2cea4978c25d4e705fd12364f660edbf6c63b1f", { Sku: "sku", alt: "second" }],
["uq_wide", "tests.uq_wide", "sha256:b79eecd11eed5e456902f095d2748da7f59125c4c8972b0da7dddc81d07fc03f", { "\uFF21": "sku", "\u{10400}": "second" }],
["uq_alpha", "tests.uq_alpha", "sha256:53aeaf2ea9b25e85ade6b15fc04c7653e7b206c87e258f1aa74dd32a840a1389", { Sku: "sku" }],
["uq_beta", "tests.uq_beta", "sha256:45ed62a64c91f6d815d3ec975c4a4a1b2ce07b6e7262a32d828673cf10899586", { Sku: "sku" }],
];
type RefusalOp = ["open", string | null] | ["pull", string] | ["commit", [string, string, string, string][]];
test("vectors refusals.msgpack: every row TS can perform", async () => {
const rows = decode(await readFile(join(vectors, "refusals.msgpack"))) as [string, RefusalOp[], [string, string, string, string] | null][];
const definitions = new Map(
REFUSAL_SCHEMAS.map(([type, schemaName, schemaId, indexes]) => [
schemaName,
defineDocumentType({
type,
schemaId,
schemaName,
schemaVersion: `${schemaName}.v1`,
schema: z.object({ sku: z.string(), second: z.string() }),
codec: codecs.slots<{ sku: string; second: string }>({ 0: ["sku", codecs.string], 1: ["second", codecs.string] }, `Uq${type}`),
indexes,
}),
]),
);
const dir = await mkdtemp(join(tmpdir(), "uidx-refusals-"));
let walked = 0;
try {
for (const [label, ops, refused] of rows) {
if (ops.some((op) => op[0] === "commit" && op[1].length > 1)) continue;
const file = join(dir, `${walked}.cc`);
let cache: CultCache | undefined;
const perform = async (op: RefusalOp) => {
if (op[0] === "open") {
if (op[1] !== null) await copyFile(join(vectors, op[1]), file);
let builder = CultCache.builder().withGenericStore(new SingleFileMessagePackBackingStore(file));
for (const definition of definitions.values()) builder = builder.withDocumentType(definition);
cache = builder.build();
await cache.pullAllBackingStores();
} else if (op[0] === "pull") {
await copyFile(join(vectors, op[1]), file);
await cache!.pullAllBackingStores();
} else {
for (const [key, schema, sku, second] of op[1]) await cache!.put(definitions.get(schema)!, key, { sku, second });
}
};
for (const op of ops.slice(0, -1)) await perform(op);
const last = ops[ops.length - 1];
if (refused === null) {
await perform(last);
} else {
const [k1, k2, schema, alias] = refused;
const before = last[0] === "commit" || (last[0] === "open" && last[1] === null)
? (existsSync(file) ? await readFile(file) : Buffer.alloc(0))
: await readFile(join(vectors, last[1]!));
await assert.rejects(perform(last), { message: refusal(k1, k2, schema, alias) }, label);
assert.deepEqual(existsSync(file) ? await readFile(file) : Buffer.alloc(0), before, `${label}: the file is untouched`);
}
walked++;
}
} finally {
await rm(dir, { recursive: true, force: true });
}
assert.equal(walked, 20);
});
test("a refused load leaves the cache's prior state as it was, and a later good load still works", async () => {
const dir = await mkdtemp(join(tmpdir(), "uidx-load-"));
try {
const bad = join(dir, "bad.cc");
await copyFile(join(vectors, "duplicate-store.cc"), bad);
const [envelope] = await new SingleFileMessagePackBackingStore(bad).pullAll();
const document = twoDefinitions(envelope.schemaId!, "indexes");
const cache = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(bad)).build();
await assert.rejects(cache.pullAllBackingStores(), /Records item:a and item:b/u);
assert.equal(cache.get(document, "item:a"), undefined);
assert.equal(cache.getKeyByIndex(document, "Sku", "S-1"), undefined);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("registerIndex refuses a name the definition declares as a role", async () => {
const document = item("item", "roles");
const cache = memory(document);
await cache.put(document, "a", { sku: "S", group: "g" });
await cache.put(document, "b", { sku: "S", group: "h" });
await assert.rejects(cache.registerIndex(document, "sku", "sku"), { message: /declared as a role; a role is never a unique index/u });
assert.equal(cache.getKeyByIndex(document, "sku", "S"), undefined);
await cache.put(document, "c", { sku: "S", group: "i" });
});
test("registerIndex over held duplicates installs nothing", async () => {
const document = item("item", "none");
const cache = memory(document);
await cache.put(document, "a", { sku: "S", group: "g" });
await cache.put(document, "b", { sku: "S", group: "h" });
await assert.rejects(cache.registerIndex(document, "sku", "sku"), { message: refusal("a", "b", "tests.item", "sku") });
assert.equal(cache.getKeyByIndex(document, "sku", "S"), undefined, "no map was installed");
await cache.put(document, "c", { sku: "S", group: "i" });
await cache.registerIndex(document, "group", "group");
assert.equal(cache.getKeyByIndex(document, "group", "h"), "b");
await assert.rejects(cache.put(document, "d", { sku: "x", group: "h" }), /Records b and d/u);
});
test("registerIndex over distinct held values installs the lookup", async () => {
const document = item("item", "none");
const cache = memory(document);
await cache.put(document, "a", { sku: "S" });
await cache.registerIndex(document, "sku", "sku");
assert.equal(cache.getKeyByIndex(document, "sku", "S"), "a");
});
// ---- a type with several unique indexes: the second alias is judged, in alias order, in both judges ----
const pairSchema = z.object({ a: z.string().optional(), b: z.string().optional() });
function pair(where: { indexes?: Record<string, "a" | "b">; roles?: Record<string, "a" | "b"> }) {
return defineDocumentType({
type: "pair",
schemaId: "tests.pair.v1",
schemaName: "tests.pair",
schemaVersion: "tests.pair.v1",
schema: pairSchema,
...where,
});
}
// Declared b first, so insertion order is the opposite of alias order.
const BOTH_UNIQUE = { indexes: { b: "b", a: "a" } as Record<string, "a" | "b"> };
test("a collision on the second unique alias is refused, with a value on the first alias that does not collide", async () => {
const document = pair(BOTH_UNIQUE);
const cache = memory(document);
await cache.put(document, "k1", { a: "x1", b: "shared" });
await assert.rejects(cache.put(document, "k2", { a: "x2", b: "shared" }), { message: refusal("k1", "k2", "tests.pair", "b") });
await assert.rejects(cache.put(document, "k2", { a: "x1", b: "y" }), { message: refusal("k1", "k2", "tests.pair", "a") });
assert.equal(cache.get(document, "k2"), undefined);
});
test("when two aliases collide the first alias in ordinal order is named, and its keys are in ordinal order", async () => {
const document = pair(BOTH_UNIQUE);
const cache = memory(document);
await cache.put(document, "m", { a: "same-a", b: "same-b" });
await assert.rejects(cache.put(document, "c", { a: "same-a", b: "same-b" }), { message: refusal("c", "m", "tests.pair", "a") });
// The colliding holders differ per alias: a is held by m, b by n; alias a is still the one named.
await cache.put(document, "n", { a: "other-a", b: "other-b" });
await assert.rejects(cache.put(document, "z", { a: "same-a", b: "other-b" }), { message: refusal("m", "z", "tests.pair", "a") });
});
test("a load judges every unique alias: a store collision on the second alias refuses the open", async () => {
const dir = await mkdtemp(join(tmpdir(), "uidx-pair-"));
try {
for (const [name, records, expected] of [
["second-only", [["k1", { a: "1", b: "s" }], ["k2", { a: "2", b: "s" }]], refusal("k1", "k2", "tests.pair", "b")],
["both-aliases", [["m", { a: "s", b: "s" }], ["c", { a: "s", b: "s" }]], refusal("c", "m", "tests.pair", "a")],
] as const) {
const file = join(dir, `${name}.cc`);
const roles = pair({ roles: { b: "b", a: "a" } });
const writer = CultCache.builder().withDocumentType(roles).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
for (const [key, value] of records) await writer.put(roles, key, value);
const before = await readFile(file);
const unique = pair(BOTH_UNIQUE);
const reader = CultCache.builder().withDocumentType(unique).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
await assert.rejects(reader.pullAllBackingStores(), { message: expected }, name);
assert.deepEqual(reader.snapshot(), [], "a refused load admits nothing");
assert.deepEqual(await readFile(file), before);
const rolesReader = CultCache.builder().withDocumentType(roles).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
await rolesReader.pullAllBackingStores();
assert.equal(rolesReader.snapshot().length, 2, `${name}: the same store opens under roles`);
}
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("registerIndex of a second unique alias judges it, naming the two smallest keys whatever order they were written in", async () => {
const document = pair({ indexes: { a: "a" } });
const cache = memory(document);
await cache.put(document, "z", { a: "1", b: "s" });
await cache.put(document, "m", { a: "2", b: "s" });
await cache.put(document, "q", { a: "3", b: "s" });
await assert.rejects(cache.registerIndex(document, "b", "b"), { message: refusal("m", "q", "tests.pair", "b") });
assert.equal(cache.getKeyByIndex(document, "a", "1"), "z", "the alias already installed is intact");
assert.equal(cache.getKeyByIndex(document, "b", "s"), undefined);
});
// ---- the single-put judge's two orders, which no pair-order vector row reaches from TS (a vector row's second record
// arrives in a batch that TS cannot perform): aliases by UTF-16 code unit, then the key pair by UTF-16 code unit ----
test("a put colliding on two aliases names the one least by UTF-16 code unit, not by code point", async () => {
// U+10400 is the surrogate pair D801 DC00, so it sorts before U+FF21 by code unit and after it by code point.
const document = pair({ indexes: { "A": "a", "\u{10400}": "b" } });
const cache = memory(document);
await cache.put(document, "h1", { a: "s1", b: "t1" });
await cache.put(document, "h2", { a: "s2", b: "t2" });
await assert.rejects(cache.put(document, "p", { a: "s1", b: "t2" }), { message: refusal("h2", "p", "tests.pair", "\u{10400}") });
assert.equal(cache.get(document, "p"), undefined);
});
test("a put names its key pair by UTF-16 code unit, not by normalised form", async () => {
// "e" U+0302 is the NFD form of U+00EA: equal when normalised, and less by code unit.
const document = item("item");
const cache = memory(document);
await cache.put(document, "ê", { sku: "v" });
await assert.rejects(cache.put(document, "ê", { sku: "v" }), { message: refusal("ê", "ê", "tests.item", "sku") });
assert.equal(cache.get(document, "ê"), undefined);
});
test("a put colliding on two aliases orders them by UTF-16 code unit, not by normalised form", async () => {
// U+00EA normalises to e U+0302, which is less than e U+0303 when normalised and greater by code unit.
const document = pair({ indexes: { "ê": "a", "ẽ": "b" } });
const cache = memory(document);
await cache.put(document, "h1", { a: "s1", b: "t1" });
await cache.put(document, "h2", { a: "s2", b: "t2" });
await assert.rejects(cache.put(document, "p", { a: "s1", b: "t2" }), { message: refusal("h2", "p", "tests.pair", "ẽ") });
});

View file

@ -10,7 +10,7 @@
<PackageId>GameCult.Math</PackageId> <PackageId>GameCult.Math</PackageId>
<!-- MIT overall; math.Phacelle.cs and CultPhasor.cs (and the two Phacelle HLSL files) are MPL-2.0; math.Cycles.cs and CultMath.Cycles.hlsl are Apache-2.0. --> <!-- MIT overall; math.Phacelle.cs and CultPhasor.cs (and the two Phacelle HLSL files) are MPL-2.0; math.Cycles.cs and CultMath.Cycles.hlsl are Apache-2.0. -->
<PackageLicenseExpression>MIT AND MPL-2.0 AND Apache-2.0</PackageLicenseExpression> <PackageLicenseExpression>MIT AND MPL-2.0 AND Apache-2.0</PackageLicenseExpression>
<Version>0.5.0</Version> <Version>0.5.1</Version>
<Description>Clean-room C# math shaped for HLSL-style renderer code.</Description> <Description>Clean-room C# math shaped for HLSL-style renderer code.</Description>
<Authors>GameCult</Authors> <Authors>GameCult</Authors>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>

View file

@ -5,7 +5,7 @@ All notable changes to this package are documented in this file.
Earlier releases (0.2.0-0.2.3) predate this package's changelog and are not Earlier releases (0.2.0-0.2.3) predate this package's changelog and are not
backfilled here; see `docs/semver-policy.md` for why. backfilled here; see `docs/semver-policy.md` for why.
## [Unreleased] ## [0.5.1] - 2026-11-11
### Added ### Added

View file

@ -1,7 +1,7 @@
{ {
"name": "org.gamecult.cultmath", "name": "org.gamecult.cultmath",
"displayName": "CultMath", "displayName": "CultMath",
"version": "0.5.0", "version": "0.5.1",
"unity": "2021.3", "unity": "2021.3",
"description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.", "description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.",
"license": "MIT AND MPL-2.0 AND Apache-2.0", "license": "MIT AND MPL-2.0 AND Apache-2.0",

View file

@ -567,8 +567,14 @@ test("a size the runtime cannot allocate is too-large in both exports, not a Ran
const count = 2 ** 15; const count = 2 ** 15;
const huge = { ...manifest, sizeBytes: count * int32Max, chunks: Array.from({ length: count }, (_, index) => ({ ...manifest.chunks[0]!, offset: index * int32Max, sizeBytes: int32Max })) }; const huge = { ...manifest, sizeBytes: count * int32Max, chunks: Array.from({ length: count }, (_, index) => ({ ...manifest.chunks[0]!, offset: index * int32Max, sizeBytes: int32Max })) };
const server = double(() => { throw new Error("no request expected"); }); const server = double(() => { throw new Error("no request expected"); });
await assert.rejects(fetchVerifiedContent(ENDPOINT, huge, { fetch: server.fetch, maxBytes: size }), codeOf("too-large")); // a host with overcommit hands out 64 TiB lazily, so the refusal is made certain: the runtime cannot allocate it
await assert.rejects(fetchVerifiedChunk(ENDPOINT, manifest.chunks[0]!.chunkHash!, size, { fetch: server.fetch, maxBytes: size }), codeOf("too-large")); const native = globalThis.Uint8Array;
const refusing = new Proxy(native, { construct: (target, args, receiver) => { if (typeof args[0] === "number" && args[0] > 2 ** 40) throw new RangeError("Array buffer allocation failed"); return Reflect.construct(target, args, receiver); } });
globalThis.Uint8Array = refusing;
try {
await assert.rejects(fetchVerifiedContent(ENDPOINT, huge, { fetch: server.fetch, maxBytes: size }), codeOf("too-large"));
await assert.rejects(fetchVerifiedChunk(ENDPOINT, manifest.chunks[0]!.chunkHash!, size, { fetch: server.fetch, maxBytes: size }), codeOf("too-large"));
} finally { globalThis.Uint8Array = native; }
assert.equal(server.calls.length, 0); assert.equal(server.calls.length, 0);
}); });

View file

@ -43,9 +43,10 @@ from cultmesh_py import (
note_doc = define_database_entry_type( note_doc = define_database_entry_type(
"mesh.note", "mesh.note",
[("body", 0), ("name", 1), ("kind", 2)], [("body", 0), ("name", 1), ("kind", 2), ("slug", 3)],
name="name", name="name",
indexes={"kind": "kind"}, indexes={"slug": "slug"}, # unique: no two notes hold one slug; watch_by_index and get_key_by_index look it up
roles={"kind": "kind"}, # many notes share a kind: a selection alias, never a lookup
) )
node = CultMesh.start_node( node = CultMesh.start_node(
"mesh.cc", "mesh.cc",
@ -55,13 +56,13 @@ node = CultMesh.start_node(
node.database.register_document(note_doc) node.database.register_document(note_doc)
unsubscribe = node.database.watch_record(note_doc, "note:1", lambda change: print(change.change_kind)) unsubscribe = node.database.watch_record(note_doc, "note:1", lambda change: print(change.change_kind))
unsubscribe_named = node.database.watch_by_name(note_doc, "intro", lambda change: print(change.record_key)) unsubscribe_named = node.database.watch_by_name(note_doc, "intro", lambda change: print(change.record_key))
unsubscribe_kind = node.database.watch_by_index(note_doc, "kind", "guide", lambda change: print(change.record_key)) unsubscribe_slug = node.database.watch_by_index(note_doc, "slug", "intro-guide", lambda change: print(change.record_key))
node.database.put(note_doc, "note:1", {"body": "hello", "name": "intro", "kind": "guide"}) node.database.put(note_doc, "note:1", {"body": "hello", "name": "intro", "kind": "guide", "slug": "intro-guide"})
live_note = node.database.get_required(note_doc, "note:1") live_note = node.database.get_required(note_doc, "note:1")
put_message = node.database.put_raw_message( put_message = node.database.put_raw_message(
note_doc, note_doc,
"note:2", "note:2",
{"body": "wire me", "name": "wire", "kind": "interop"}, {"body": "wire me", "name": "wire", "kind": "interop", "slug": "wire-me"},
shard_id="interop", shard_id="interop",
shard_epoch=1, shard_epoch=1,
) )

View file

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

View file

@ -10,7 +10,7 @@ from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from cultcache_py import CultCache, CultCacheEnvelope, SingleFileMessagePackBackingStore from cultcache_py import CultCache, CultCacheEnvelope, SingleFileMessagePackBackingStore
from cultcache_py.cache import CultCacheError from cultcache_py.cache import CultCacheError, index_text
from cultcache_py.documents import DocumentDefinition, extract_value from cultcache_py.documents import DocumentDefinition, extract_value
from cultnet_py import ( from cultnet_py import (
CultNetAppliedRecord, CultNetAppliedRecord,
@ -520,7 +520,7 @@ class CultMeshDatabase:
raise ValueError(f"Document type {document.type!r} has no index {index!r}") raise ValueError(f"Document type {document.type!r} has no index {index!r}")
def filtered(change: CultMeshDatabaseChange) -> None: def filtered(change: CultMeshDatabaseChange) -> None:
if self._change_matches_extractor(change, extractor, value): if self._change_matches_extractor(change, extractor, value, index_text):
callback(self._change_as_document(change, requested)) callback(self._change_as_document(change, requested))
return self.watch(filtered, document=document) return self.watch(filtered, document=document)
@ -1295,10 +1295,11 @@ class CultMeshDatabase:
change: CultMeshDatabaseChange, change: CultMeshDatabaseChange,
extractor: str | Callable[[Any], str | int | float | bool | None], extractor: str | Callable[[Any], str | int | float | bool | None],
expected: str, expected: str,
render: Callable[[Any], str | None] = str,
) -> bool: ) -> bool:
return ( return (
self._value_matches_extractor(change.value, extractor, expected) self._value_matches_extractor(change.value, extractor, expected, render)
or self._value_matches_extractor(change.previous_value, extractor, expected) or self._value_matches_extractor(change.previous_value, extractor, expected, render)
) )
def _value_matches_extractor( def _value_matches_extractor(
@ -1306,11 +1307,12 @@ class CultMeshDatabase:
value: Any | None, value: Any | None,
extractor: str | Callable[[Any], str | int | float | bool | None], extractor: str | Callable[[Any], str | int | float | bool | None],
expected: str, expected: str,
render: Callable[[Any], str | None] = str,
) -> bool: ) -> bool:
if value is None: if value is None:
return False return False
actual = extract_value(value, extractor) actual = extract_value(value, extractor)
return actual is not None and str(actual) == expected return actual is not None and render(actual) == expected
def _previous_values_for_snapshot_response(self, response: dict[str, Any]) -> dict[tuple[str, str], Any]: def _previous_values_for_snapshot_response(self, response: dict[str, Any]) -> dict[tuple[str, str], Any]:
if response.get("schemaVersion") != "cultnet.snapshot_response_raw.v0": if response.get("schemaVersion") != "cultnet.snapshot_response_raw.v0":

View file

@ -0,0 +1,84 @@
from __future__ import annotations
import re
import unittest
from pathlib import Path
from cultcache_py import define_database_entry_type
from cultcache_py.cache import CultCacheError
from cultmesh_py import CultMesh
# variants:ruling:unique-indexes. `indexes` are unique lookups, `roles` are selection aliases. A watch by index reads the
# lookup's own canonical text (cultcache_py.cache.index_text), never a second rendering of the value.
class WatchByIndexTests(unittest.TestCase):
def test_the_watch_and_the_lookup_agree_on_the_text_of_a_value(self) -> None:
document = define_database_entry_type("mesh.priced", [("price", 0)], indexes={"price": "price"})
node = CultMesh.create_node(runtime_id="mesh-watch-text")
node.database.register_document(document)
cases = [
(2.0, "2", "2.0"),
(0.1, "0.1000000000000000055511151231257827021181583404541015625", "0.1"),
(-0.0, "0", "-0.0"),
]
for index, (value, text, str_text) in enumerate(cases):
with self.subTest(value=value):
key = f"item:{index}"
heard: list[str] = []
misheard: list[str] = []
node.database.watch_by_index(document, "price", text, lambda change: heard.append(change.record_key))
node.database.watch_by_index(document, "price", str_text, lambda change: misheard.append(change.record_key))
node.database.put(document, key, {"price": value})
self.assertEqual(node.database.get_by_index(document, "price", text), {"price": value})
self.assertEqual(heard, [key])
if str_text != text:
self.assertEqual(misheard, [])
self.assertIsNone(node.database.get_by_index(document, "price", str_text))
def test_a_watch_hears_a_record_leaving_a_value_by_the_canonical_text(self) -> None:
document = define_database_entry_type("mesh.leaving", [("price", 0)], indexes={"price": "price"})
node = CultMesh.create_node(runtime_id="mesh-watch-leaving")
node.database.register_document(document)
node.database.put(document, "item:1", {"price": 2.0})
heard: list[str] = []
node.database.watch_by_index(document, "price", "2", lambda change: heard.append(change.record_key))
node.database.put(document, "item:1", {"price": 3.0})
self.assertEqual(heard, ["item:1"])
def test_a_blank_value_is_never_indexed_so_never_watched(self) -> None:
document = define_database_entry_type("mesh.blank", [("sku", 0)], indexes={"sku": "sku"})
node = CultMesh.create_node(runtime_id="mesh-watch-blank")
node.database.register_document(document)
heard: list[str] = []
node.database.watch_by_index(document, "sku", " ", lambda change: heard.append(change.record_key))
node.database.put(document, "item:1", {"sku": " "})
self.assertEqual(heard, [])
def test_a_role_alias_is_not_watchable_by_index(self) -> None:
document = define_database_entry_type("mesh.roles", [("kind", 0)], roles={"kind": "kind"})
node = CultMesh.create_node(runtime_id="mesh-watch-role")
node.database.register_document(document)
with self.assertRaisesRegex(ValueError, "has no index"):
node.database.watch_by_index(document, "kind", "guide", lambda _: None)
class ReadmeTests(unittest.TestCase):
def test_the_readme_note_document_admits_many_notes_of_one_kind(self) -> None:
readme = (Path(__file__).parents[1] / "README.md").read_text(encoding="utf-8")
declaration = re.search(r"^note_doc = define_database_entry_type\(\n.*?^\)\n", readme, re.S | re.M)
self.assertIsNotNone(declaration)
scope: dict = {"define_database_entry_type": define_database_entry_type}
exec(declaration.group(0), scope) # noqa: S102 - the README's own declaration
note_doc = scope["note_doc"]
node = CultMesh.create_node(runtime_id="mesh-readme")
node.database.register_document(note_doc)
node.database.put(note_doc, "note:1", {"body": "a", "name": "one", "kind": "guide", "slug": "one"})
node.database.put(note_doc, "note:2", {"body": "b", "name": "two", "kind": "guide", "slug": "two"})
self.assertEqual(node.database.get_by_index(note_doc, "slug", "two")["body"], "b")
with self.assertRaises(CultCacheError):
node.database.put(note_doc, "note:3", {"body": "c", "name": "three", "kind": "guide", "slug": "one"})
if __name__ == "__main__":
unittest.main()

View file

@ -732,6 +732,18 @@ 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");
}; };
self.apply_raw_snapshot_documents(documents)
}
/// Applies each snapshot document of a registered schema with `put_raw_envelope`. The first refusal ends the pull
/// and the documents before it stay applied; a key this node holds read-only (unread slots from a newer peer, or a
/// payload this build cannot read) is refused like any other. C# applies a raw snapshot as one cache commit, so a
/// refusal there applies nothing: this is a divergence, owed by follow-up mesh-snapshot-atomic (stage the pull through
/// `put_prepared_batch`).
fn apply_raw_snapshot_documents(
&mut self,
documents: Vec<cultnet_rs::CultNetRawDocumentRecord>,
) -> Result<usize> {
let mut applied = 0usize; let mut applied = 0usize;
for document in documents { for document in documents {
let Some(document_type) = let Some(document_type) =
@ -1199,6 +1211,47 @@ mod tests {
Note => "cultmesh.test.note.v0", Note => "cultmesh.test.note.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_ends_the_pull_unlike_csharp_which_applies_nothing() -> 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_eq!(node.get_required::<Note>("before")?.body, "b");
assert_eq!(node.get_required::<Note>("held")?.body, "newer body");
assert!(node.get::<Note>("after")?.is_none());
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

@ -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,6 +2,9 @@
## 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.
- `apply_raw_document_record` and `apply_raw_snapshot` raise `SchemaConflictError` when a remote record is of another type than the one the cache holds at its key (`cultcache-py` holds one record per key whatever its type); the cache is unchanged, and the caller decides whether to drop the record or the peer. `cultmesh-py`'s server answers such a put with `message_invalid`; its client's `read_next_change` lets the error out.
- `CultNetDatabaseChange` carries `commit_id` and `commit_count` (`commitId`/`commitCount`; None outside a multi-record commit, as C# writes nil): `from_wire` refuses an empty or non-string id and a count that is not an integer of at least 2, `to_wire` writes them when set, and the published `database_change_raw` schema lists both. Python applies no batch. The `simulation_fact` document's `shard`, `frame`, `subject` and `claim_kind` aliases are `roles` (many facts share each); the catalog bytes do not move.
- Decodes the write receipt frames of `contracts/cultnet/interop/write-receipt.md`: `parse_message` validates `cultnet.write_admitted.v0` and `cultnet.error.v1` against their closed schemas (unknown keys, empty or non-string ids, an unknown operation or code are refused; `error.v1` requires `messageId`, string or null; `limit_reached` is `error.v1` only). Added `CULTNET_ERROR_V1_CODES`, `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS`, `write_content_hash` and `hello_advertises_write_receipts`; `hello` takes `supported_message_versions`. `tests/test_write_receipt.py` replays the vectors' `contentHash`, `wire` and `schemaInvalid` sections. Not implemented here: the client write tracker (`scenarios`) and the server rules (`dedupe`, `blankFields`); this package has no receipt server and no write client. - Decodes the write receipt frames of `contracts/cultnet/interop/write-receipt.md`: `parse_message` validates `cultnet.write_admitted.v0` and `cultnet.error.v1` against their closed schemas (unknown keys, empty or non-string ids, an unknown operation or code are refused; `error.v1` requires `messageId`, string or null; `limit_reached` is `error.v1` only). Added `CULTNET_ERROR_V1_CODES`, `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS`, `write_content_hash` and `hello_advertises_write_receipts`; `hello` takes `supported_message_versions`. `tests/test_write_receipt.py` replays the vectors' `contentHash`, `wire` and `schemaInvalid` sections. Not implemented here: the client write tracker (`scenarios`) and the server rules (`dedupe`, `blankFields`); this package has no receipt server and no write client.
- Added `message_invalid` to `CULTNET_ERROR_CODES` and `message_invalid_error(field, reason)`, byte-identical to the C# `ForMessageInvalid`. `cultmesh-py` refuses a malformed put or delete with it instead of the out-of-schema `malformed_document_put` and `malformed_document_delete`, which are removed. - Added `message_invalid` to `CULTNET_ERROR_CODES` and `message_invalid_error(field, reason)`, byte-identical to the C# `ForMessageInvalid`. `cultmesh-py` refuses a malformed put or delete with it instead of the out-of-schema `malformed_document_put` and `malformed_document_delete`, which are removed.
- Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per - Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per

View file

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

View file

@ -692,7 +692,7 @@ simulation_fact_document: DocumentDefinition[CultMeshSimulationFact] = define_da
], ],
cls=CultMeshSimulationFact, cls=CultMeshSimulationFact,
name="fact_id", name="fact_id",
indexes={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"}, roles={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"},
schema_name=SIMULATION_FACT_DOCUMENT_TYPE, schema_name=SIMULATION_FACT_DOCUMENT_TYPE,
schema_version=SIMULATION_FACT_SCHEMA_VERSION, schema_version=SIMULATION_FACT_SCHEMA_VERSION,
) )

View file

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

View file

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

@ -429,9 +429,11 @@ def wire_message_schema_properties(schema_version: str) -> dict[str, Any]:
"messageId": common["messageId"], "messageId": common["messageId"],
"subscriptionId": common["subscriptionId"], "subscriptionId": common["subscriptionId"],
"changeKind": {"enum": ["added", "updated", "removed"]}, "changeKind": {"enum": ["added", "updated", "removed"]},
"document": {"$ref": "#/$defs/rawDocumentRecord"}, "document": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/rawDocumentRecord"}]},
"schemaId": common["schemaId"], "schemaId": {"type": ["string", "null"], "minLength": 1},
"recordKey": common["recordKey"], "recordKey": {"type": ["string", "null"], "minLength": 1},
"commitId": {"type": ["string", "null"], "minLength": 1},
"commitCount": {"type": ["integer", "null"], "minimum": 2},
}, },
"cultnet.shard_catalog_request.v0": { "cultnet.shard_catalog_request.v0": {
**common, **common,

View file

@ -15,6 +15,10 @@ class CultNetDatabaseChange:
raw_document: CultNetRawDocumentRecord | None = None raw_document: CultNetRawDocumentRecord | None = None
schema_id: str | None = None schema_id: str | None = None
record_key: str | None = None record_key: str | None = None
commit_id: str | None = None
"""Set on every change of one multi-record commit, the same for all of them; None outside one (C# writes nil)."""
commit_count: int | None = None
"""How many changes the commit holds (>= 2); None outside one. Carried data: Python applies no batch."""
@classmethod @classmethod
def from_wire(cls, message: dict[str, Any]) -> "CultNetDatabaseChange": def from_wire(cls, message: dict[str, Any]) -> "CultNetDatabaseChange":
@ -31,6 +35,12 @@ class CultNetDatabaseChange:
if raw_document is not None: if raw_document is not None:
schema_id = schema_id or raw_document.schema_id schema_id = schema_id or raw_document.schema_id
record_key = record_key or raw_document.record_key record_key = record_key or raw_document.record_key
commit_id = message.get("commitId")
if commit_id is not None and not (isinstance(commit_id, str) and commit_id):
raise ValueError(f"database change commitId must be a non-empty string, received {commit_id!r}")
commit_count = message.get("commitCount")
if commit_count is not None and (isinstance(commit_count, bool) or not isinstance(commit_count, int) or commit_count < 2):
raise ValueError(f"database change commitCount must be an integer of at least 2, received {commit_count!r}")
return cls( return cls(
message_id=str(message.get("messageId") or ""), message_id=str(message.get("messageId") or ""),
subscription_id=str(message.get("subscriptionId") or ""), subscription_id=str(message.get("subscriptionId") or ""),
@ -39,6 +49,8 @@ class CultNetDatabaseChange:
raw_document=raw_document, raw_document=raw_document,
schema_id=schema_id, schema_id=schema_id,
record_key=record_key, record_key=record_key,
commit_id=commit_id,
commit_count=commit_count,
) )
def to_wire(self) -> dict[str, Any]: def to_wire(self) -> dict[str, Any]:
@ -56,6 +68,10 @@ class CultNetDatabaseChange:
wire["schemaId"] = self.schema_id wire["schemaId"] = self.schema_id
if self.record_key is not None: if self.record_key is not None:
wire["recordKey"] = self.record_key wire["recordKey"] = self.record_key
if self.commit_id is not None:
wire["commitId"] = self.commit_id
if self.commit_count is not None:
wire["commitCount"] = self.commit_count
return wire return wire

View file

@ -13,7 +13,8 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
from uuid import uuid4 from uuid import uuid4
from cultcache_py import CultCache, define_database_entry_type import msgpack
from cultcache_py import CultCache, SchemaConflictError, define_database_entry_type
from cultnet_py.benchmark import run_benchmark from cultnet_py.benchmark import run_benchmark
from cultnet_py.compare_csharp import ( from cultnet_py.compare_csharp import (
DEFAULT_PARITY_THRESHOLD, DEFAULT_PARITY_THRESHOLD,
@ -1566,6 +1567,51 @@ class CultNetTests(unittest.TestCase):
self.assertEqual(delete_change.schema_id, "schema-note") self.assertEqual(delete_change.schema_id, "schema-note")
self.assertEqual(delete_change.to_wire()["recordKey"], "note:1") self.assertEqual(delete_change.to_wire()["recordKey"], "note:1")
def test_cultnet_database_change_commit_fields(self) -> None:
base = {
"schemaVersion": "cultnet.database_change_raw.v0",
"messageId": "change-commit",
"subscriptionId": "sub-1",
"changeKind": "removed",
"schemaId": "schema-note",
"recordKey": "note:1",
}
grouped = CultNetDatabaseChange.from_wire({**base, "commitId": "commit-7", "commitCount": 3})
self.assertEqual((grouped.commit_id, grouped.commit_count), ("commit-7", 3))
self.assertEqual(grouped.to_wire()["commitId"], "commit-7")
self.assertEqual(grouped.to_wire()["commitCount"], 3)
self.assertEqual(CultNetDatabaseChange.from_wire(grouped.to_wire()), grouped)
for absent in (base, {**base, "commitId": None, "commitCount": None}): # C# writes nil outside a commit
change = CultNetDatabaseChange.from_wire(absent)
self.assertIsNone(change.commit_id)
self.assertIsNone(change.commit_count)
self.assertNotIn("commitId", change.to_wire())
self.assertNotIn("commitCount", change.to_wire())
for field, bad in (
("commitId", ""),
("commitId", 7),
("commitCount", 1),
("commitCount", 0),
("commitCount", True),
("commitCount", 2.0),
("commitCount", "3"),
):
with self.subTest(field=field, bad=bad), self.assertRaisesRegex(ValueError, field):
CultNetDatabaseChange.from_wire({**base, field: bad})
def test_a_two_change_commit_written_by_csharp_parses(self) -> None:
# tests/vectors/database-change-commit/two-change-commit.msgpack: the two change messages of one commit, written by the
# C# serializer (CultNetReplicaCommitTests), so a rename or retyping of commitId/commitCount fails here.
vector = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "database-change-commit" / "two-change-commit.msgpack"
messages = list(msgpack.Unpacker(io.BytesIO(vector.read_bytes()), raw=False))
self.assertEqual(len(messages), 2)
changes = [CultNetDatabaseChange.from_wire(message) for message in messages]
self.assertEqual([change.commit_count for change in changes], [2, 2])
self.assertEqual({change.commit_id for change in changes}, {"9f1c0f0e4b7a4c1f9d2e"})
self.assertEqual([change.to_wire()["commitCount"] for change in changes], [2, 2])
def test_cultnet_raw_put_helper_carries_message_id(self) -> None: def test_cultnet_raw_put_helper_carries_message_id(self) -> None:
put = document_put_raw( put = document_put_raw(
message_id="put-1", message_id="put-1",
@ -1896,6 +1942,63 @@ class CultNetTests(unittest.TestCase):
self.assertEqual(raised.exception.response["messageId"], "subscription-error") self.assertEqual(raised.exception.response["messageId"], "subscription-error")
thread.join(2.0) thread.join(2.0)
def test_replication_refuses_a_remote_record_of_another_type_at_a_held_key_and_changes_nothing(self) -> None:
# variants:ruling:cache-refuses-retype: a cache holds one record per key whatever its type, so a peer that reuses a
# key across types is refused where the record is applied; replication does not catch it.
first = define_database_entry_type("replica.first", [("name", 0), ("category", 1), ("value", 2, 0)], cls=Item)
second = define_database_entry_type("replica.second", [("name", 0), ("category", 1), ("value", 2, 0)], cls=Item)
cache = CultCache()
cache.register_document_type(first)
cache.register_document_type(second)
cache.put(first, "shared", Item("held", "", 0))
def raw(document):
return {
"schemaId": document.catalog_entry().schema_id,
"recordKey": "shared",
"storedAt": "2026-06-13T00:00:00Z",
"payloadEncoding": "messagepack",
"payload": document.encode_payload(Item("remote", "", 0)),
}
with self.assertRaises(SchemaConflictError):
apply_raw_document_record(cache, schema_document_map([first, second]), raw(second))
snapshot = {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": "s", "documents": [raw(second)]}
with self.assertRaises(SchemaConflictError):
apply_raw_snapshot(cache, [first, second], snapshot)
self.assertEqual(cache.get_required(first, "shared").name, "held")
self.assertIsNone(cache.get(second, "shared"))
apply_raw_document_record(cache, schema_document_map([first, second]), raw(first)) # the same type still replaces
self.assertEqual(cache.get_required(first, "shared").name, "remote")
def test_a_raw_record_with_a_tail_is_unroutable_by_inference_and_refused_by_count_when_routed(self) -> None:
import msgpack # type: ignore
from cultcache_py import define_document_type
from cultnet_py import resolve_document_for_raw_record
document = define_document_type("tests.tail_note", schema_id="tests.tail_note", schema_version="tests.tail_note.v1",
payload_encoder=msgpack.packb, payload_decoder=lambda payload: msgpack.unpackb(payload, raw=False))
known = document.catalog_entry().schema_id
head = msgpack.packb(["tests.tail_note.v1", "x"], use_bin_type=True)
def raw(schema_id: str, payload: bytes) -> dict:
return {"schemaId": schema_id, "recordKey": "k", "storedAt": "2026-06-13T00:00:00Z",
"payloadEncoding": "messagepack", "payload": payload}
documents = schema_document_map([document])
# Routing by inference reads a hint, not a payload: a payload with a tail gives no hint, so an unknown schema id
# stays the unknown-schema error (KeyError) even when the first value names a registered schema.
self.assertIs(resolve_document_for_raw_record(documents, "unknown", raw("unknown", head)), document)
with self.assertRaises(KeyError):
resolve_document_for_raw_record(documents, "unknown", raw("unknown", head + b"\xc0"))
# A registered schema id routes, and the tail is refused by count when the record is applied.
cache = CultCache()
cache.register_document_type(document)
with self.assertRaises(ValueError) as caught:
apply_raw_document_record(cache, documents, raw(known, head + b"\xc0"))
self.assertIn("1 bytes after its first MessagePack value", str(caught.exception))
self.assertIsNone(cache.get(document, "k"))
def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None: def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None:
document = define_database_entry_type( document = define_database_entry_type(
"replica.item", "replica.item",
@ -2269,9 +2372,12 @@ class CultNetTests(unittest.TestCase):
change_schema = json.loads(by_version["cultnet.database_change_raw.v0"]["schemaJson"]) change_schema = json.loads(by_version["cultnet.database_change_raw.v0"]["schemaJson"])
self.assertEqual(change_schema["properties"]["changeKind"]["enum"], ["added", "updated", "removed"]) self.assertEqual(change_schema["properties"]["changeKind"]["enum"], ["added", "updated", "removed"])
self.assertIn("document", change_schema["properties"]) # The C# writer sets every key, nil where unset (a removed change's document, an added change's recordKey and schemaId).
self.assertIn("schemaId", change_schema["properties"]) self.assertEqual(change_schema["properties"]["document"]["oneOf"][0], {"type": "null"})
self.assertIn("recordKey", change_schema["properties"]) self.assertEqual(change_schema["properties"]["schemaId"], {"type": ["string", "null"], "minLength": 1})
self.assertEqual(change_schema["properties"]["recordKey"], {"type": ["string", "null"], "minLength": 1})
self.assertEqual(change_schema["properties"]["commitId"], {"type": ["string", "null"], "minLength": 1})
self.assertEqual(change_schema["properties"]["commitCount"], {"type": ["integer", "null"], "minimum": 2})
log_schema = json.loads(by_version["cultnet.shard_log_response.v0"]["schemaJson"]) log_schema = json.loads(by_version["cultnet.shard_log_response.v0"]["schemaJson"])
self.assertIn("entries", log_schema["required"]) self.assertIn("entries", log_schema["required"])
@ -2583,6 +2689,23 @@ class CultNetTests(unittest.TestCase):
self.assertEqual(responses[0]["details"]["value"], schema_version) self.assertEqual(responses[0]["details"]["value"], schema_version)
self.assertNotIn("messageId", responses[0]) self.assertNotIn("messageId", responses[0])
def test_witness_artifact_bundle_payload_is_one_value_and_depth_wins_over_a_tail(self) -> None:
import msgpack # type: ignore
from cultcache_py import CultCachePayloadDepthError
payload = msgpack.packb(["b", "k", "t", {}, [], [], [], {}], use_bin_type=True)
decode_witness_artifact_bundle_payload(payload)
for tail, count in ((b"\xc0", 1), (b"\xc1\xc1", 2)):
with self.assertRaises(ValueError) as caught:
decode_witness_artifact_bundle_payload(payload + tail)
self.assertIs(type(caught.exception), ValueError)
self.assertEqual(
str(caught.exception),
f"cultnet.witness_artifact_bundle payload has {count} bytes after its first MessagePack value (refused on read).",
)
with self.assertRaises(CultCachePayloadDepthError):
decode_witness_artifact_bundle_payload(b"\x91" * 300 + b"\xc0\xc0")
def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None: def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None:
from cultnet_py import CultNetSimulationConsensus from cultnet_py import CultNetSimulationConsensus

View file

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

View file

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

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

@ -0,0 +1,43 @@
//! `tests/vectors/database-change-commit/two-change-commit.msgpack` is written only by the C# reference: two
//! `cultnet.database_change_raw.v0` messages of one commit, every key present, nil where unset. cultnet-rs has no typed
//! `database_change_raw` message yet, so this walks the stream with the same `rmpv` values its wire decoder builds on and
//! pins the keys and values a Rust adopter will read. A rename of `commitId` or `commitCount` in the C# writer fails here.
use rmpv::Value;
use std::io::Cursor;
fn messages() -> Vec<Value> {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/vectors/database-change-commit/two-change-commit.msgpack");
let bytes = std::fs::read(path).expect("the vector is committed");
let mut cursor = Cursor::new(bytes.as_slice());
let mut out = Vec::new();
while (cursor.position() as usize) < bytes.len() {
out.push(rmpv::decode::read_value(&mut cursor).expect("a msgpack value"));
}
out
}
fn field<'a>(message: &'a Value, key: &str) -> &'a Value {
message
.as_map()
.and_then(|entries| entries.iter().find(|(k, _)| k.as_str() == Some(key)))
.map(|(_, v)| v)
.unwrap_or_else(|| panic!("the C# writer wrote no key {key}"))
}
#[test]
fn a_two_change_commit_written_by_csharp_carries_its_commit_id_count_and_changes() {
let messages = messages();
assert_eq!(messages.len(), 2);
for message in &messages {
assert_eq!(field(message, "schemaVersion").as_str(), Some("cultnet.database_change_raw.v0"));
assert_eq!(field(message, "commitId").as_str(), Some("9f1c0f0e4b7a4c1f9d2e"));
assert_eq!(field(message, "commitCount").as_u64(), Some(2));
assert_eq!(field(message, "changeKind").as_str(), Some("removed"));
assert_eq!(field(message, "subscriptionId").as_str(), Some("sub-1"));
assert_eq!(field(message, "schemaId").as_str(), Some("schema-note"));
assert!(field(message, "document").is_nil(), "a removed change's document is nil");
}
let keys: Vec<_> = messages.iter().map(|m| field(m, "recordKey").as_str().unwrap().to_string()).collect();
assert_eq!(keys, ["note:a", "note:b"]);
}

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

@ -5,8 +5,15 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
## Unreleased ## Unreleased
### Breaking
- `decodeManifest` refuses a manifest with bytes after its first MessagePack value (`CDN artifact manifest has N bytes after its first MessagePack value.`), as the C# reference does; before, the tail was ignored. `contracts/cultmesh/content-acceptance-vectors.json` now holds `trailing_nil`, `trailing_reserved_code` and `trailing_truncated_array` as refused.
### Added ### Added
- Fork card (`contracts/cultnet/governance/fork-card.md`): `raised_at`, a required key holding an RFC3339 instant string or nil (the question's first admission time), outside `options_hash`; the card vectors are re-pinned with the key, three negatives (epoch integer, date-only string, missing key) and a nil-`raised_at` valid card; every `optionsHash` digest is unchanged.
- Fork card reader views (`fork-card.md`, "Reader views"): the card gains `decider_count` (required, 1 to 64) and `deciders` may be empty (ids withheld from a non-decider); the record gains `answerer_count` (distinct answerers over every stored answer, refusals included); `answerer` is nullable inside a record shown to a non-decider. Card and record vectors are re-pinned (card map header `DE0011` to `DE0012`, record `85` to `86`); withheld twins, the R-card, R-record, R-entry, R-list and R-answer-result rule vectors and a no-leak byte check are new; every `optionsHash` and `bodyDigests` digest is unchanged.
- Fork card vectors: a nil `answerer` is now pinned for Revision and Retraction as well as Counted and Refused (`deskAnswer` `withheld_answerer_revision`, `withheld_answerer_retraction`; entries `open_counted_revision_and_retraction` and its withheld twin; a list scenario pair; negatives for a nil answerer in an `answer_result` and for a named Retraction or an over-count in a withheld entry). Additive: every existing vector, `optionsHash` and `bodyDigests` digest is unchanged.
- `WriteTracker`: a write sent after `sessionEnd` settles unconfirmed `session_ended` when it is sent (it stayed unanswered forever). The replay runner now fails on an event key outside `scenarios.semantics` and on a vector count that differs from the file's `counts`; seven tracker vectors are new. A blank text in a raw frame is Unicode White_Space, not `String.prototype.trim()`. - `WriteTracker`: a write sent after `sessionEnd` settles unconfirmed `session_ended` when it is sent (it stayed unanswered forever). The replay runner now fails on an event key outside `scenarios.semantics` and on a vector count that differs from the file's `counts`; seven tracker vectors are new. A blank text in a raw frame is Unicode White_Space, not `String.prototype.trim()`.
- Write receipt, client side (`contracts/cultnet/interop/write-receipt.md`): `cultnet.write_admitted.v0` and `cultnet.error.v1` (v0 plus `messageId`; its closed code set adds `limit_reached`, `CULTNET_ERROR_V1_CODES`) parse and encode, and `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS` / `helloAdvertisesWriteReceipts` name the hello capability. `cultnet.error.v0` and its code set are unchanged. `WriteTracker` settles each write as admitted, refused or unconfirmed (windows `RECEIPT_RESULT_WINDOW_MS` 5000 and `LEGACY_RESULT_WINDOW_MS` 500, counted from the send; late answers recorded as `admitted_late` / `refused_late`), and `tools/replay-write-receipt-vectors.mjs` replays the vectors against it (see README, "Write receipt replay runner"). The interop schemas are read from `contracts/cultnet/interop/`. - Write receipt, client side (`contracts/cultnet/interop/write-receipt.md`): `cultnet.write_admitted.v0` and `cultnet.error.v1` (v0 plus `messageId`; its closed code set adds `limit_reached`, `CULTNET_ERROR_V1_CODES`) parse and encode, and `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS` / `helloAdvertisesWriteReceipts` name the hello capability. `cultnet.error.v0` and its code set are unchanged. `WriteTracker` settles each write as admitted, refused or unconfirmed (windows `RECEIPT_RESULT_WINDOW_MS` 5000 and `LEGACY_RESULT_WINDOW_MS` 500, counted from the send; late answers recorded as `admitted_late` / `refused_late`), and `tools/replay-write-receipt-vectors.mjs` replays the vectors against it (see README, "Write receipt replay runner"). The interop schemas are read from `contracts/cultnet/interop/`.
- `message_invalid` joins the closed `cultnet.error.v0` code set, with `cultNetMessageInvalidError(field, reason)` (`details.field` names the request field at fault), byte-identical to the C# `ForMessageInvalid`. - `message_invalid` joins the closed `cultnet.error.v0` code set, with `cultNetMessageInvalidError(field, reason)` (`details.field` names the request field at fault), byte-identical to the C# `ForMessageInvalid`.

View file

@ -66,6 +66,7 @@ Current message families:
- `cultnet.snapshot_response.v0` - `cultnet.snapshot_response.v0`
- `cultnet.snapshot_response_raw.v0` - `cultnet.snapshot_response_raw.v0`
- `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1`, `cultnet.snapshot_response_raw.v1` (typed-selection carriers; no selection engine yet) - `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1`, `cultnet.snapshot_response_raw.v1` (typed-selection carriers; no selection engine yet)
- `cultnet.database_change_raw.v0` (optional `commitId` and `commitCount >= 2` mark the changes of one commit; carried and validated here, no TS code groups or applies by them)
- `cultnet.sample.change_name.v0` - `cultnet.sample.change_name.v0`
- `cultnet.sample.chat.v0` - `cultnet.sample.chat.v0`
@ -143,8 +144,8 @@ imports no `node:` module, so a browser bundle can take it.
past the shared depth limit are refused. past the shared depth limit are refused.
- The decoder reads what MessagePack-CSharp reads and refuses what it refuses: - The decoder reads what MessagePack-CSharp reads and refuses what it refuses:
nil in a string, tag or metadata slot is `null` and is written back as nil, nil in a string, tag or metadata slot is `null` and is written back as nil,
a missing trailing slot takes the reference's default, extra slots and trailing a missing trailing slot takes the reference's default, extra slots are skipped and bytes
bytes are skipped (a chunk reference's fifth slot is its element id, a string after the manifest's first value are refused (a chunk reference's fifth slot is its element id, a string
or nil, `""` when absent; `packContent` writes the decimal offset as the id, as the or nil, `""` when absent; `packContent` writes the decimal offset as the id, as the
reference does), a float-encoded integer is refused, and invalid UTF-8 reference does), a float-encoded integer is refused, and invalid UTF-8
decodes to U+FFFD. A nil `chunks` is refused, as the reference's decodes to U+FFFD. A nil `chunks` is refused, as the reference's

View file

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

View file

@ -424,6 +424,10 @@ export interface CultNetDatabaseChangeRawMessage {
document?: CultNetRawDocumentRecord; document?: CultNetRawDocumentRecord;
recordKey?: string; recordKey?: string;
schemaId?: string; schemaId?: string;
/** Opaque, one subscription: changes sharing a commitId are the changes of one commit. */
commitId?: string | null;
/** How many changes the commit carries, at least two. The C# server sends null on a change outside a grouped commit. */
commitCount?: number | null;
} }
export interface CultNetSchemaDescriptor { export interface CultNetSchemaDescriptor {
@ -1397,6 +1401,15 @@ function validateDatabaseChangeRawMessage(
); );
} }
if (candidate.commitId !== undefined && candidate.commitId !== null &&
(typeof candidate.commitId !== "string" || candidate.commitId.length === 0)) {
throw new Error("Validation failed for cultnet.database_change_raw.v0: /commitId: must be a non-empty string");
}
if (candidate.commitCount !== undefined && candidate.commitCount !== null &&
(typeof candidate.commitCount !== "number" || !Number.isInteger(candidate.commitCount) || candidate.commitCount < 2)) {
throw new Error("Validation failed for cultnet.database_change_raw.v0: /commitCount: must be an integer of at least 2");
}
if (candidate.changeKind === "removed") { if (candidate.changeKind === "removed") {
requireNonEmptyString(candidate.recordKey, "/recordKey"); requireNonEmptyString(candidate.recordKey, "/recordKey");
requireNonEmptyString(candidate.schemaId, "/schemaId"); requireNonEmptyString(candidate.schemaId, "/schemaId");

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";
@ -54,6 +56,7 @@ import {
validateGhostlightAgentStateGenerated, validateGhostlightAgentStateGenerated,
validateGhostlightAgentState, validateGhostlightAgentState,
shardServes, shardServes,
type CultNetDatabaseChangeRawMessage,
type CultNetLoginMessage, type CultNetLoginMessage,
type GhostlightAgentStateShape, type GhostlightAgentStateShape,
type GhostlightAgentStateDocument, type GhostlightAgentStateDocument,
@ -1688,6 +1691,55 @@ test("database subscription contracts match the C# live document lane", () => {
assert.equal(removed.schemaVersion, "cultnet.database_change_raw.v0"); assert.equal(removed.schemaVersion, "cultnet.database_change_raw.v0");
assert.equal(removed.recordKey, "counter:main"); assert.equal(removed.recordKey, "counter:main");
const grouped = parseCultNetMessage({
schemaVersion: "cultnet.database_change_raw.v0",
messageId: "change-counter-3",
subscriptionId: "counter:browser-1",
changeKind: "removed",
recordKey: "counter:main",
schemaId: "sample.counter_state.v1",
commitId: "c1",
commitCount: 2,
});
assert.equal((grouped as CultNetDatabaseChangeRawMessage).commitId, "c1");
assert.equal((grouped as CultNetDatabaseChangeRawMessage).commitCount, 2);
assert.deepEqual(encodeCultNetMessageForWire(grouped), grouped, "both commit fields travel");
assert.equal((removed as CultNetDatabaseChangeRawMessage).commitId, undefined, "a change without them parses as before");
const changeWith = (extra: Record<string, unknown>) => () => parseCultNetMessage({
schemaVersion: "cultnet.database_change_raw.v0",
messageId: "change-counter-4",
subscriptionId: "counter:browser-1",
changeKind: "removed",
recordKey: "counter:main",
schemaId: "sample.counter_state.v1",
...extra,
});
// The C# server writes commitId and commitCount as nil on every change outside a grouped commit, so null is absent.
const ungrouped = changeWith({ commitId: null, commitCount: null })() as CultNetDatabaseChangeRawMessage;
assert.equal(ungrouped.commitId, null);
assert.equal(ungrouped.commitCount, null);
assert.doesNotThrow(changeWith({ commitId: null }));
assert.doesNotThrow(changeWith({ commitCount: null }));
assert.doesNotThrow(() => parseCultNetMessage({
schemaVersion: "cultnet.database_change_raw.v0",
messageId: "change-counter-5",
subscriptionId: "counter:browser-1",
changeKind: "added",
document: {
schemaId: "sample.counter_state.v1",
recordKey: "counter:main",
storedAt: "2026-08-17T00:00:00Z",
payloadEncoding: "messagepack",
payload: new Uint8Array([0x81, 0xa5, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x01]),
},
recordKey: null,
schemaId: null,
commitId: null,
commitCount: null,
}));
for (const commitId of ["", 5]) assert.throws(changeWith({ commitId }), /\/commitId/, `commitId ${JSON.stringify(commitId)}`);
for (const commitCount of [1, 0, 2.5, "two", -3]) assert.throws(changeWith({ commitCount }), /\/commitCount/, `commitCount ${commitCount}`);
const unsubscribe = parseCultNetMessage({ const unsubscribe = parseCultNetMessage({
schemaVersion: "cultnet.database_unsubscribe.v0", schemaVersion: "cultnet.database_unsubscribe.v0",
messageId: "unsubscribe-counter", messageId: "unsubscribe-counter",
@ -3090,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

@ -0,0 +1,39 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import Ajv2020 from "ajv/dist/2020.js";
import { decodeMulti } from "@msgpack/msgpack";
import { cultNetSchemas, parseCultNetMessage } from "../src/index";
// tests/vectors/database-change-commit/two-change-commit.msgpack is written only by the C# reference: two removed
// database_change_raw messages of one commit, every key present, nil where unset (document included).
const vector = join(__dirname, "..", "..", "..", "..", "tests", "vectors", "database-change-commit", "two-change-commit.msgpack");
const changes = () => [...decodeMulti(readFileSync(vector))] as Record<string, unknown>[];
test("a two-change commit written by C# decodes with its commitId, commitCount and changes", () => {
const messages = changes().map((message) => parseCultNetMessage(structuredClone(message)));
assert.equal(messages.length, 2);
for (const message of messages) {
assert.equal(message.schemaVersion, "cultnet.database_change_raw.v0");
assert.equal((message as { commitId?: unknown }).commitId, "9f1c0f0e4b7a4c1f9d2e");
assert.equal((message as { commitCount?: unknown }).commitCount, 2);
assert.equal((message as { changeKind?: unknown }).changeKind, "removed");
assert.equal((message as { subscriptionId?: unknown }).subscriptionId, "sub-1");
assert.equal((message as { schemaId?: unknown }).schemaId, "schema-note");
}
assert.deepEqual(messages.map((message) => (message as { recordKey?: unknown }).recordKey), ["note:a", "note:b"]);
});
test("the database_change_raw schema accepts the C# vector's removed changes (document: nil) and still refuses an added change without a document", () => {
const ajv = new Ajv2020({ allErrors: true, strict: false });
ajv.addSchema(cultNetSchemas.rawDocumentRecordSchema);
const validate = ajv.compile(cultNetSchemas.databaseChangeRawSchema);
for (const change of changes()) {
assert.equal(change.document, null, "the C# writer wrote the key, nil");
assert.equal(validate(change), true, JSON.stringify(validate.errors));
assert.equal(validate({ ...change, changeKind: "added" }), false, "an added change needs a document");
}
});

View file

@ -90,7 +90,7 @@ const jstr = (s: string): string => {
}; };
type Option = { id: string; text: string }; type Option = { id: string; text: string };
type Shown = { title: string; context: string; options: Option[]; recommendation: string; depends: string[] }; type Shown = { title: string; context: string; options: Option[]; recommendation: string; depends: string[] };
type Card = Shown & { fork_id: string; leans: string | null; deciders: string[]; status: string; ruling: string | null; ruled_from: string[]; options_hash: string }; type Card = Shown & { fork_id: string; leans: string | null; raised_at: string | null; deciders: string[]; decider_count: number; status: string; ruling: string | null; ruled_from: string[]; options_hash: string };
// RFC 8785 over the five shown members, every string NFC-normalised first; keys sorted; no numbers in the member set. // RFC 8785 over the five shown members, every string NFC-normalised first; keys sorted; no numbers in the member set.
const nfc = (s: string): string => s.normalize("NFC"); const nfc = (s: string): string => s.normalize("NFC");
const canonical = (c: Shown): Buffer => const canonical = (c: Shown): Buffer =>
@ -104,6 +104,7 @@ const cardRuleErrors = (c: Card): string[] => {
if (!ids.includes(c.recommendation)) errors.push("recommendation is not an option"); if (!ids.includes(c.recommendation)) errors.push("recommendation is not an option");
if (c.leans !== null && !ids.includes(c.leans)) errors.push("leans is not an option"); if (c.leans !== null && !ids.includes(c.leans)) errors.push("leans is not an option");
if (new Set(c.deciders).size !== c.deciders.length) errors.push("duplicate deciders"); if (new Set(c.deciders).size !== c.deciders.length) errors.push("duplicate deciders");
if (c.deciders.length > 0 && c.deciders.length !== c.decider_count) errors.push("R-card: shown deciders are not exactly decider_count ids");
if (c.depends.some((d) => Buffer.byteLength(d, "utf8") > 1000)) errors.push("a depends line is over 1000 UTF-8 bytes"); if (c.depends.some((d) => Buffer.byteLength(d, "utf8") > 1000)) errors.push("a depends line is over 1000 UTF-8 bytes");
if (c.status === "open" && c.ruling !== null) errors.push("an open card names a ruling"); if (c.status === "open" && c.ruling !== null) errors.push("an open card names a ruling");
if (c.ruling === null && c.ruled_from.length > 0) errors.push("ruled_from without a ruling"); if (c.ruling === null && c.ruled_from.length > 0) errors.push("ruled_from without a ruling");
@ -121,6 +122,15 @@ test("fork card: options hash is SHA-256 of the canonical JSON, computed here, a
const byName = new Map<string, string>(fork.optionsHash.map((h: { name: string; sha256: string }) => [h.name, h.sha256])); const byName = new Map<string, string>(fork.optionsHash.map((h: { name: string; sha256: string }) => [h.name, h.sha256]));
const base = byName.get("heimdall_A_five_members")!; const base = byName.get("heimdall_A_five_members")!;
assert.equal(byName.get("hidden_members_do_not_move_it"), base); assert.equal(byName.get("hidden_members_do_not_move_it"), base);
const card0 = fork.card.valid[0].message as Card;
for (const raised_at of [null, "2027-01-02T03:04:05.123Z"]) {
const stamped: Card = { ...card0, raised_at };
assert.equal(optionsHash(stamped), card0.options_hash, "raised_at stays outside the hash");
}
for (const [deciders, decider_count] of [[[], 2], [card0.deciders, 64]] as const) {
const shown: Card = { ...card0, deciders: [...deciders], decider_count };
assert.equal(optionsHash(shown), card0.options_hash, "decider_count and the emptiness of deciders stay outside the hash");
}
assert.equal(byName.get("heimdall_E_decomposed_equals_D"), byName.get("heimdall_D_nfc_escapes_astral"), "an NFD and an NFC spelling hash alike"); assert.equal(byName.get("heimdall_E_decomposed_equals_D"), byName.get("heimdall_D_nfc_escapes_astral"), "an NFD and an NFC spelling hash alike");
for (const moved of ["heimdall_B_recommendation_changed", "heimdall_C_option_order_changed", "heimdall_F_option_id_changed", "heimdall_G_depends_emptied", "option_text_changed", for (const moved of ["heimdall_B_recommendation_changed", "heimdall_C_option_order_changed", "heimdall_F_option_id_changed", "heimdall_G_depends_emptied", "option_text_changed",
"title_changed", "context_changed", "depends_order_changed", "depends_order_changed_reversed"]) { "title_changed", "context_changed", "depends_order_changed", "depends_order_changed_reversed"]) {
@ -171,7 +181,7 @@ test("fork card: mutating any shown member, an option id included, changes the h
assert.notEqual(optionsHash(copy), base, `mutation ${i}`); assert.notEqual(optionsHash(copy), base, `mutation ${i}`);
} }
const hidden = structuredClone(card); const hidden = structuredClone(card);
hidden.fork_id = "other:question:id"; hidden.leans = "yes"; hidden.deciders = ["x"]; hidden.status = "closed"; hidden.ruled_from = ["n1"]; hidden.fork_id = "other:question:id"; hidden.leans = "yes"; hidden.deciders = []; hidden.decider_count = 7; hidden.status = "closed"; hidden.ruled_from = ["n1"];
assert.equal(optionsHash(hidden), base, "fork_id and the unshown members stay outside the hash"); assert.equal(optionsHash(hidden), base, "fork_id and the unshown members stay outside the hash");
}); });
@ -195,6 +205,11 @@ test("fork card: documents validate and encode to the vector bytes", () => {
assert.ok(check(forkSchema, "answerResult", v.message), v.name); assert.ok(check(forkSchema, "answerResult", v.message), v.name);
assert.equal(hexOf(v.message), v.messagePackHex, v.name); assert.equal(hexOf(v.message), v.messagePackHex, v.name);
assert.equal(v.message.card !== null, v.name === "stale_fork", "only a stale refusal shows the new fork"); assert.equal(v.message.card !== null, v.name === "stale_fork", "only a stale refusal shows the new fork");
assert.deepEqual(resultRuleErrors(v.message), [], v.name);
}
for (const v of fork.resultsInvalid) {
assert.ok(!check(forkSchema, "answerResult", v.message) || resultRuleErrors(v.message).length > 0, `${v.name}: ${v.why}`);
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
} }
for (const v of fork.unavailable) { for (const v of fork.unavailable) {
assert.ok(check(forkSchema, "unavailable", v.message), v.name); assert.ok(check(forkSchema, "unavailable", v.message), v.name);
@ -208,6 +223,7 @@ test("fork card: documents validate and encode to the vector bytes", () => {
assert.ok(check(forkSchema, "refusal", v.message), v.name); assert.ok(check(forkSchema, "refusal", v.message), v.name);
assert.equal(hexOf(v.message), v.messagePackHex, v.name); assert.equal(hexOf(v.message), v.messagePackHex, v.name);
} }
assert.equal(fork.doorRefusals.find((r: { name: string }) => r.name === "fork_too_large").httpStatus, 422);
}); });
test("fork card: every negative is refused by the schema or a stated rule", () => { test("fork card: every negative is refused by the schema or a stated rule", () => {
@ -222,12 +238,16 @@ test("fork card: every negative is refused by the schema or a stated rule", () =
} }
}); });
// R-answer-result: the answer in an answer_result is the sender's own, stamped with their account, never nil (nil exists only in a record shown to a non-decider).
const resultRuleErrors = (r: { answer: { answerer: string | null } }): string[] => (r.answer.answerer === null ? ["R-answer-result: the answerer is nil"] : []);
// Rules a JSON Schema cannot say: a note is at most 4000 UTF-8 bytes (maxLength counts code points and is only the looser bound). // Rules a JSON Schema cannot say: a note is at most 4000 UTF-8 bytes (maxLength counts code points and is only the looser bound).
const noteRuleErrors = (def: string, message: any): string[] => { const noteRuleErrors = (def: string, message: any): string[] => {
const note = def === "answerRequest" ? message?.answer?.note : def === "deskAnswer" ? message?.note : null; const note = def === "answerRequest" ? message?.answer?.note : def === "deskAnswer" ? message?.note : null;
return note != null && Buffer.byteLength(note, "utf8") > 4000 ? ["note is over 4000 UTF-8 bytes"] : []; return note != null && Buffer.byteLength(note, "utf8") > 4000 ? ["note is over 4000 UTF-8 bytes"] : [];
}; };
const OP_ID = "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f";
const TAILS: Record<string, string> = { const TAILS: Record<string, string> = {
unauthenticated: "a verified session is required", unauthenticated: "a verified session is required",
body_forbidden_field: "answerer and authority are set by the receiver", body_forbidden_field: "answerer and authority are set by the receiver",
@ -299,7 +319,7 @@ function judge(start: Card, steps: { kind: string; body: Record<string, any> | n
} }
responses.push(down && reached ? unavailable() : response); responses.push(down && reached ? unavailable() : response);
} }
return { responses, record: { schemaVersion: "gamecult.fork.record.v0", fork_id: card.fork_id, options_hash: optionsHash(card), counted_answer_id: counted(), answers } }; return { responses, record: { schemaVersion: "gamecult.fork.record.v0", fork_id: card.fork_id, options_hash: optionsHash(card), counted_answer_id: counted(), answerer_count: new Set(answers.map((a) => a.answerer)).size, answers } };
} }
test("fork card: every scenario replays to the vector's responses and record, bytes included", () => { test("fork card: every scenario replays to the vector's responses and record, bytes included", () => {
@ -311,6 +331,7 @@ test("fork card: every scenario replays to the vector's responses and record, by
assert.deepEqual(record, sc.record, sc.name); assert.deepEqual(record, sc.record, sc.name);
assert.ok(check(forkSchema, "record", record), sc.name); assert.ok(check(forkSchema, "record", record), sc.name);
assert.equal(hexOf(record), sc.recordHex, sc.name); assert.equal(hexOf(record), sc.recordHex, sc.name);
for (const s of sc.steps) if (s.response?.answer) assert.deepEqual(resultRuleErrors(s.response), [], sc.name);
for (const s of sc.steps) if (s.response) assert.ok(check(forkSchema, "answerResult", s.response) || check(forkSchema, "refusal", s.response) || check(forkSchema, "unavailable", s.response), sc.name); for (const s of sc.steps) if (s.response) assert.ok(check(forkSchema, "answerResult", s.response) || check(forkSchema, "refusal", s.response) || check(forkSchema, "unavailable", s.response), sc.name);
} }
}); });
@ -377,13 +398,43 @@ test("fork card: operator authority needs a cited Counted answer record", () =>
}); });
// ---- transport: list, read, auth (fork-card.md "Transport") ---- // ---- transport: list, read, auth (fork-card.md "Transport") ----
const entryRuleErrors = (e: { card: Card; record: { fork_id: string; options_hash: string; counted_answer_id: string | null; answers: Record<string, any>[] } }): string[] => { type Entry = { card: Card; record: { fork_id: string; options_hash: string; counted_answer_id: string | null; answerer_count: number; answers: Record<string, any>[] } };
const entryRuleErrors = (e: Entry): string[] => {
const errors = cardRuleErrors(e.card); const errors = cardRuleErrors(e.card);
if (e.record.fork_id !== e.card.fork_id) errors.push("record of another fork"); if (e.record.fork_id !== e.card.fork_id) errors.push("record of another fork");
if (e.record.options_hash !== e.card.options_hash) errors.push("record under another hash"); if (e.record.options_hash !== e.card.options_hash) errors.push("record under another hash");
const counted = e.record.answers.find((a) => (a.outcome === "Counted" || a.outcome === "Revision") && a.options_hash === e.card.options_hash const counted = e.record.answers.find((a) => (a.outcome === "Counted" || a.outcome === "Revision") && a.options_hash === e.card.options_hash
&& !e.record.answers.some((r) => r.outcome === "Retraction" && r.retracts === a.answer_id)); && !e.record.answers.some((r) => r.outcome === "Retraction" && r.retracts === a.answer_id));
if ((counted?.answer_id ?? null) !== e.record.counted_answer_id) errors.push("counted_answer_id is not derived"); if ((counted?.answer_id ?? null) !== e.record.counted_answer_id) errors.push("counted_answer_id is not derived");
// R-record: named answerers are counted by distinct id, nil ones by a bound
const named = e.record.answers.filter((a) => a.answerer !== null).length, total = e.record.answers.length, n = e.record.answerer_count;
if (total === 0 && n !== 0) errors.push("R-record: no answers and a count other than 0");
else if (named === total && total > 0 && n !== new Set(e.record.answers.map((a) => a.answerer)).size) errors.push("R-record: answerer_count is not the distinct answerers");
else if (named === 0 && total > 0 && (n < 1 || n > total)) errors.push("R-record: a withheld count outside 1..answers");
// R-entry: the card's view and the record's view agree, so a withheld card never rides beside a named account
if (e.card.deciders.length > 0 && named < total) errors.push("R-entry: a decider-view card with withheld answerers");
if (e.card.deciders.length === 0 && named > 0) errors.push("R-entry: a withheld card beside a named answerer");
return errors;
};
// R-list: all entries of one page are one reader's view
const viewRuleErrors = (forks: { card: Card }[]): string[] => (new Set(forks.map((f) => f.card.deciders.length > 0)).size > 1 ? ["R-list: a page mixes views"] : []);
// The non-decider twin of a decider-view entry: ids withheld, counts kept.
const withheld = (e: Entry): Entry => ({ ...e, card: { ...e.card, deciders: [] }, record: { ...e.record, answers: e.record.answers.map((a) => ({ ...a, answerer: null })) } });
const idsOf = (e: Entry): string[] => [...e.card.deciders, ...e.record.answers.map((a) => a.answerer as string)];
const bytesHaveNoId = (hex: string, ids: string[], name: string): void => {
const bytes = Buffer.from(hex, "hex");
assert.ok(ids.length > 0, name);
for (const id of ids) assert.ok(!bytes.includes(Buffer.from(id, "utf8")), `${name}: the withheld bytes carry ${id}`);
};
// Order is a unique rank per Mind: the refusal names both fork ids, the first holder and the second.
const orderErrors = (cards: { fork_id: string; order: number }[]): string[] => {
const holder = new Map<number, string>();
const errors: string[] = [];
for (const c of cards) {
if (holder.has(c.order)) errors.push(`order ${c.order} is shared by ${holder.get(c.order)} and ${c.fork_id}`);
else holder.set(c.order, c.fork_id);
}
return errors; return errors;
}; };
@ -453,6 +504,13 @@ test("fork transport: requests and entries validate and encode to the vector byt
for (const v of t[section].valid) { for (const v of t[section].valid) {
assert.ok(check(forkSchema, def, v.message), v.name); assert.ok(check(forkSchema, def, v.message), v.name);
if (def === "entry") assert.deepEqual(entryRuleErrors(v.message), [], v.name); if (def === "entry") assert.deepEqual(entryRuleErrors(v.message), [], v.name);
if (def === "entry" && v.twin !== undefined) {
const twin = t.entry.valid.find((x: any) => x.name === v.twin);
assert.deepEqual(v.message, withheld(twin.message), `${v.name}: the twin of ${v.twin}`);
assert.equal(v.message.record.answerer_count, twin.message.record.answerer_count, "a decider and a non-decider read the same count");
bytesHaveNoId(v.messagePackHex, idsOf(twin.message), v.name);
assert.ok(twin.messagePackHex.includes(Buffer.from(idsOf(twin.message)[0], "utf8").toString("hex").toUpperCase()), "the decider view does carry the ids");
}
assert.equal(hexOf(v.message), v.messagePackHex, v.name); assert.equal(hexOf(v.message), v.messagePackHex, v.name);
} }
for (const v of t[section].invalid) { for (const v of t[section].invalid) {
@ -461,7 +519,10 @@ test("fork transport: requests and entries validate and encode to the vector byt
} }
} }
for (const v of t.listInvalid) { for (const v of t.listInvalid) {
assert.equal(check(forkSchema, "list", v.message), false, `${v.name}: ${v.why}`); const refused = !check(forkSchema, "list", v.message) || orderErrors(v.message.forks.map((f: any) => f.card)).length > 0 || viewRuleErrors(v.message.forks).length > 0;
assert.ok(refused, `${v.name}: ${v.why}`);
if (v.refusal !== undefined) assert.deepEqual(orderErrors(v.message.forks.map((f: any) => f.card)), [v.refusal], v.name);
if (v.name === "a_page_mixes_views") assert.equal(viewRuleErrors(v.message.forks).length, 1, v.name);
assert.equal(hexOf(v.message), v.messagePackHex, v.name); assert.equal(hexOf(v.message), v.messagePackHex, v.name);
} }
}); });
@ -469,12 +530,27 @@ test("fork transport: requests and entries validate and encode to the vector byt
test("fork transport: every list request replays to the vector's response, newest first, paged by the cursor", () => { test("fork transport: every list request replays to the vector's response, newest first, paged by the cursor", () => {
for (const sc of fork.transport.listScenarios) { for (const sc of fork.transport.listScenarios) {
for (const e of sc.entries) assert.deepEqual(entryRuleErrors(e), [], sc.name); for (const e of sc.entries) assert.deepEqual(entryRuleErrors(e), [], sc.name);
assert.deepEqual(orderErrors(sc.entries.map((e: any) => e.card)), [], sc.name);
if (sc.twin !== undefined) {
// the non-decider page is the decider page with the ids withheld: counts, order, cursor and stamp equal, byte for byte but the withheld members
const twin = fork.transport.listScenarios.find((x: any) => x.name === sc.twin);
assert.deepEqual(sc.entries, twin.entries.map(withheld), sc.name);
assert.equal(sc.steps.length, twin.steps.length);
sc.steps.forEach((step: any, i: number) => {
const other = twin.steps[i].response;
assert.deepEqual(step.response, { ...other, forks: other.forks.map(withheld) }, sc.name);
assert.ok(twin.entries.length > 0 && step.response.forks.length > 0);
bytesHaveNoId(step.responseHex, twin.entries.flatMap(idsOf), sc.name);
});
assert.ok(twin.steps[0].responseHex.includes(Buffer.from(OP_ID, "utf8").toString("hex").toUpperCase()), "the decider page does carry the ids");
}
let previous: { next_cursor: string | null } | null = null; let previous: { next_cursor: string | null } | null = null;
for (const step of sc.steps) { for (const step of sc.steps) {
const response = judgeList(sc.entries, sc.ordinal, step.request); const response = judgeList(sc.entries, sc.ordinal, step.request);
assert.deepEqual(response, step.response, sc.name); assert.deepEqual(response, step.response, sc.name);
assert.ok(check(forkSchema, "list", response), sc.name); assert.ok(check(forkSchema, "list", response), sc.name);
assert.equal(hexOf(response), step.responseHex, sc.name); assert.equal(hexOf(response), step.responseHex, sc.name);
assert.deepEqual(viewRuleErrors(response.forks), [], sc.name);
const orders = response.forks.map((f: any) => f.card.order); const orders = response.forks.map((f: any) => f.card.order);
assert.deepEqual(orders, [...orders].sort((a: number, b: number) => b - a), sc.name); assert.deepEqual(orders, [...orders].sort((a: number, b: number) => b - a), sc.name);
if (previous !== null) assert.equal(step.request.cursor, previous.next_cursor, `${sc.name}: the second request follows the first page's cursor`); if (previous !== null) assert.equal(step.request.cursor, previous.next_cursor, `${sc.name}: the second request follows the first page's cursor`);
@ -483,6 +559,95 @@ test("fork transport: every list request replays to the vector's response, newes
} }
}); });
test("fork transport: order 0 is legal, no cursor names it, and the page ending at order 1 hands on to it", () => {
const sc = fork.transport.listScenarios.find((x: any) => x.name === "order_zero_is_legal_but_no_cursor_names_it");
assert.deepEqual(sc.steps.map((s: any) => s.response.next_cursor), ["c1", null]);
assert.deepEqual(sc.steps[1].response.forks.map((f: any) => f.card.order), [0]);
assert.equal(check(forkSchema, "cursor", "c0"), false);
});
test("fork transport: a page may stop short of limit, and a first entry past the budget is fork_too_large (422) continued below its order", () => {
const sc = fork.transport.listShortPages;
assert.deepEqual(sc.map((x: any) => x.name), ["a_short_page_resumes_below_its_last_entry", "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", "fork_too_large_on_order_zero_leaves_nothing_below"]);
assert.ok((forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum.includes("fork_too_large"));
const covered: number[] = [];
for (const s of sc) {
let last: string | null | undefined = undefined;
s.steps.forEach((step: any, i: number) => {
if (last !== undefined) assert.equal(step.request.cursor, last, `${s.name}: the step follows the cursor or the refused order`);
const full = judgeList(s.entries, s.ordinal, step.request);
if (step.response !== undefined) {
const r = step.response as any, n = r.forks.length;
assert.ok(check(forkSchema, "list", r), s.name);
assert.equal(hexOf(r), step.responseHex, s.name);
assert.deepEqual(r.forks, full.forks.slice(0, n), `${s.name}: a prefix of the unbudgeted page`);
assert.ok(n >= 1, s.name);
assert.equal(r.next_cursor, full.next_cursor !== null || n < full.forks.length ? `c${r.forks[n - 1].card.order}` : null, s.name);
assert.equal(r.stamp, full.stamp); assert.equal(r.open_count, full.open_count); assert.equal(r.recorded_count, full.recorded_count);
covered.push(...r.forks.map((f: any) => f.card.order));
last = r.next_cursor;
} else {
const r = step.refusal, first = full.forks[0].card as any;
assert.ok(check(forkSchema, "refusal", r), s.name);
assert.equal(hexOf(r), step.refusalHex, s.name);
assert.equal(r.code, "fork_too_large");
assert.equal(step.httpStatus, 422);
assert.equal(step.fork_id, first.fork_id); assert.equal(step.order, first.order);
assert.ok(r.error.includes(first.fork_id) && new RegExp(`\\b${first.order}\\b`).test(r.error.replace(first.fork_id, "")), `${s.name}: the text names the fork id and the order`);
assert.ok(step.request.limit >= 1, "limit 0 never refuses");
if (first.order >= 1) { assert.ok(i + 1 < s.steps.length, s.name); last = `c${first.order}`; assert.ok(check(forkSchema, "cursor", last)); }
else { assert.equal(i, s.steps.length - 1, `${s.name}: order 0 is the end`); assert.equal(check(forkSchema, "cursor", "c0"), false); last = undefined; }
}
});
}
assert.deepEqual(sc[0].steps.map((x: any) => x.response.next_cursor), ["c2", null]);
assert.deepEqual(sc[0].steps.map((x: any) => x.response.forks.length), [2, 2]);
assert.deepEqual([sc[1].steps[0].order, sc[2].steps[1].order], [3, 0]);
assert.deepEqual(covered, [3, 2, 1, 0, 2, 1, 0, 1]);
});
test("fork transport: a read whose entry is past the cap is fork_too_large (422), named by fork id alone", () => {
const doorStatus = fork.doorRefusals.find((r: any) => r.name === "fork_too_large").httpStatus;
assert.equal(fork.transport.readTooLarge.length, 1);
for (const c of fork.transport.readTooLarge) {
const held = c.entries.find((e: any) => e.card.fork_id === c.request.fork_id);
assert.ok(held !== undefined && check(forkSchema, "readRequest", c.request), c.name);
assert.ok(check(forkSchema, "refusal", c.refusal), c.name);
assert.equal(c.refusal.code, "fork_too_large");
assert.ok(c.refusal.error.includes(held.card.fork_id), "the text names the fork id");
assert.equal(hexOf(c.refusal), c.refusalHex, c.name);
assert.equal(c.httpStatus, 422); assert.equal(c.httpStatus, doorStatus);
}
});
test("fork transport: a stale answer whose card re-read is past the cap is the read-form fork_too_large (422), over a stored Refused(stale_fork)", () => {
assert.equal(fork.transport.answerTooLarge.length, 1);
const read = fork.transport.readTooLarge[0];
for (const c of fork.transport.answerTooLarge) {
const held = c.entries.find((e: any) => e.card.fork_id === c.request.fork_id);
assert.ok(held !== undefined && check(forkSchema, "answerRequest", c.request), c.name);
assert.notEqual(c.request.options_hash, held.card.options_hash, "the card moved: the answer is stale");
assert.ok(check(forkSchema, "refusal", c.refusal), c.name);
assert.equal(c.refusal.code, "fork_too_large");
assert.ok(c.refusal.error.includes(held.card.fork_id), "the text names the fork id");
assert.equal(hexOf(c.refusal), c.refusalHex, c.name);
assert.equal(c.refusal.code, read.refusal.code, "the same refusal as the read form");
assert.equal(c.httpStatus, 422); assert.equal(c.httpStatus, fork.doorRefusals.find((r: any) => r.name === "fork_too_large").httpStatus);
assert.equal(c.outcome, "Refused"); assert.equal(c.code, "stale_fork"); assert.equal(c.counted, false);
}
});
test("fork transport: a door request_id_conflict is a fault on read and list (503 unavailable) and a refusal only on the answer", () => {
assert.deepEqual(fork.transport.doorFaults.map((c: any) => c.route), ["read", "list", "answer"]);
for (const c of fork.transport.doorFaults) {
const source = c.route === "answer" ? fork.doorRefusals.find((r: any) => r.name === c.doorCode) : fork.unavailable[0];
assert.equal(c.responseHex, source.messagePackHex, c.name);
assert.equal(hexOf(c.response), c.responseHex, c.name);
assert.equal(c.httpStatus, source.httpStatus, c.name);
assert.ok(check(forkSchema, c.route === "answer" ? "refusal" : "unavailable", c.response), c.name);
}
});
test("fork transport: auth is bearer, then audience, then scope, and a votes token touches nothing", () => { test("fork transport: auth is bearer, then audience, then scope, and a votes token touches nothing", () => {
const codes = (forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum; const codes = (forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum;
const seen = new Set<string>(); const seen = new Set<string>();

View file

@ -115,6 +115,8 @@ test("a client reconnecting from the same endpoint replaces only its own session
assert.throws(() => server.send(oldPeer, "schema", enc("stale")), /not connected/); assert.throws(() => server.send(oldPeer, "schema", enc("stale")), /not connected/);
assert.equal(server.peers.length, 2); assert.equal(server.peers.length, 2);
// The server accepting is not the client hearing it: send only once the client's new session is connected.
await waitFor(() => a.connection.connected, "the reconnecting client's handshake");
a.connection.send("schema", enc("after")); a.connection.send("schema", enc("after"));
b.connection.send("schema", enc("still")); b.connection.send("schema", enc("still"));
await waitFor(() => a.received.includes("re:after") && b.received.includes("re:still"), "replies after the reconnect"); await waitFor(() => a.received.includes("re:after") && b.received.includes("re:still"), "replies after the reconnect");

View file

@ -173,9 +173,11 @@ def chash(c): return sha(canonical_json(c["title"], c["context"], c["options"],
def card(**kw): def card(**kw):
c = {"schemaVersion": "gamecult.fork.card.v0", "fork_id": FID, "title": "Slot mark consent", "context": "Should a slot mark be shared?", c = {"schemaVersion": "gamecult.fork.card.v0", "fork_id": FID, "title": "Slot mark consent", "context": "Should a slot mark be shared?",
"options": OPT, "recommendation": "no", "depends": DEP, "leans": None, "flag": None, "options": OPT, "recommendation": "no", "depends": DEP, "leans": None, "flag": None,
"raised_in": {"kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4"}, "deciders": [OP], "raised_in": {"kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4"}, "raised_at": "2026-11-10T09:30:00Z", "deciders": [OP], "decider_count": 1,
"status": "open", "ruling": None, "ruled_from": [], "order": 3, "options_hash": ""} "status": "open", "ruling": None, "ruled_from": [], "order": 3, "options_hash": ""}
c.update(kw); c["options_hash"] = chash(c); return c c.update(kw)
if "decider_count" not in kw: c["decider_count"] = len(c["deciders"]) # the decider view: the count equals the ids; a withheld card passes its own
c["options_hash"] = chash(c); return c
CARD = card(); H = CARD["options_hash"] CARD = card(); H = CARD["options_hash"]
hashes = [] hashes = []
@ -202,7 +204,7 @@ hv("title_changed", card(title="Slot mark consent?"))
hv("context_changed", card(context="Should slot marks be shared?")) hv("context_changed", card(context="Should slot marks be shared?"))
hv("depends_order_changed", card(depends=DEP + ["x:question:y"])) hv("depends_order_changed", card(depends=DEP + ["x:question:y"]))
hv("depends_order_changed_reversed", card(depends=["x:question:y"] + DEP)) hv("depends_order_changed_reversed", card(depends=["x:question:y"] + DEP))
hv("hidden_members_do_not_move_it", card(leans="yes", flag="provisional", deciders=[OP, HM], status="closed", ruling="r:1", ruled_from=["rc-1"], order=9, raised_in=None, fork_id="other:question:id")) hv("hidden_members_do_not_move_it", card(leans="yes", flag="provisional", deciders=[], decider_count=2, status="closed", ruling="r:1", ruled_from=["rc-1"], order=9, raised_in=None, raised_at="2027-01-02T03:04:05.123Z", fork_id="other:question:id"))
hv("control_characters_and_markup_are_escaped_minimally", card(title='q " b \\ t \t n \n bell \u0007 del \u007f', context="</script> \u2028 &")) hv("control_characters_and_markup_are_escaped_minimally", card(title='q " b \\ t \t n \n bell \u0007 del \u007f', context="</script> \u2028 &"))
hv("astral_option_id_and_text", card(options=[{"id": "\U0001F600", "text": "\U0001D11E"}, OPT[1]])) hv("astral_option_id_and_text", card(options=[{"id": "\U0001F600", "text": "\U0001D11E"}, OPT[1]]))
assert [h["sha256"] for h in hashes if h["heimdall"] == "D"] == [h["sha256"] for h in hashes if h["heimdall"] == "E"] assert [h["sha256"] for h in hashes if h["heimdall"] == "D"] == [h["sha256"] for h in hashes if h["heimdall"] == "E"]
@ -218,6 +220,7 @@ def body(choice=None, note=None, h=None, at="2026-11-12T19:02:00Z", fork=FID, re
return {"schemaVersion": "gamecult.fork.answer_request.v0", "fork_id": fork, "options_hash": h or H, "answer": a, return {"schemaVersion": "gamecult.fork.answer_request.v0", "fork_id": fork, "options_hash": h or H, "answer": a,
"retracted": retracts is not None, "retracts": retracts, "answered_at": at, "request_id": rid or next_rid()} "retracted": retracts is not None, "retracts": retracts, "answered_at": at, "request_id": rid or next_rid()}
def retraction(target, **kw): return body(retracts=target, **kw) def retraction(target, **kw): return body(retracts=target, **kw)
NULLANS_RID = "0000b001-0000-4000-8000-00000000b001"
NOTE = "Keep it local: a slot mark is the author's call, and sharing it is a separate act." NOTE = "Keep it local: a slot mark is the author's call, and sharing it is a separate act."
def srv(aid, who, at, auth="Operator"): return {"answer_id": aid, "answerer": who, "authority": auth, "received_at": at} def srv(aid, who, at, auth="Operator"): return {"answer_id": aid, "answerer": who, "authority": auth, "received_at": at}
TAIL = {"unauthenticated": "a verified session is required", "body_forbidden_field": "answerer and authority are set by the receiver", "body_unknown_field": "the body is a closed shape", TAIL = {"unauthenticated": "a verified session is required", "body_forbidden_field": "answerer and authority are set by the receiver", "body_unknown_field": "the body is a closed shape",
@ -237,7 +240,10 @@ def da(outcome, link, code, b, server):
"options_hash": b["options_hash"], "choice": a["choice"], "note": a["note"], "answered_at": b["answered_at"], "received_at": server["received_at"], "options_hash": b["options_hash"], "choice": a["choice"], "note": a["note"], "answered_at": b["answered_at"], "received_at": server["received_at"],
"claim_in_future": future(b["answered_at"], server["received_at"]), "answerer": server["answerer"], "authority": server["authority"], "request_id": b["request_id"]} "claim_in_future": future(b["answered_at"], server["received_at"]), "answerer": server["answerer"], "authority": server["authority"], "request_id": b["request_id"]}
def result(answer, c=None): return {"schemaVersion": "gamecult.fork.answer_result.v0", "answer": answer, "card": c, "counted_answer_id": None} # sc() fills counted_answer_id def result(answer, c=None): return {"schemaVersion": "gamecult.fork.answer_result.v0", "answer": answer, "card": c, "counted_answer_id": None} # sc() fills counted_answer_id
def record(counted, answers, h=H, fork=FID): return {"schemaVersion": "gamecult.fork.record.v0", "fork_id": fork, "options_hash": h, "counted_answer_id": counted, "answers": answers} def record(counted, answers, h=H, fork=FID, answerer_count=None):
# answerer_count: distinct answerers over every stored answer, refusals included; a withheld record passes the count it kept
n = len({a["answerer"] for a in answers}) if answerer_count is None else answerer_count
return {"schemaVersion": "gamecult.fork.record.v0", "fork_id": fork, "options_hash": h, "counted_answer_id": counted, "answerer_count": n, "answers": answers}
T = lambda m, sec=5: f"2026-11-12T19:{m:02d}:{sec:02d}Z" T = lambda m, sec=5: f"2026-11-12T19:{m:02d}:{sec:02d}Z"
def step(kind, body_=None, server=None, response=None, **kw): def step(kind, body_=None, server=None, response=None, **kw):
d = {"kind": kind, "body": body_, "server": server, "response": response}; d.update(kw); return d d = {"kind": kind, "body": body_, "server": server, "response": response}; d.update(kw); return d
@ -358,7 +364,7 @@ scen += [
sc("R1_a_target_less_retraction_with_retracted_flipped_is_body_invalid_before_any_request_id_lookup", CARD, [X1, rf(IL_B, sr("n2"), "bad_retraction"), door(dict(IL_B, retracted=False), sr("n9"), "body_invalid")], "n1"), sc("R1_a_target_less_retraction_with_retracted_flipped_is_body_invalid_before_any_request_id_lookup", CARD, [X1, rf(IL_B, sr("n2"), "bad_retraction"), door(dict(IL_B, retracted=False), sr("n9"), "body_invalid")], "n1"),
] ]
REASON_CODES = ["stale_fork", "not_a_decider", "fork_closed", "choice_invalid", "bad_retraction", "already_retracted"] REASON_CODES = ["stale_fork", "not_a_decider", "fork_closed", "choice_invalid", "bad_retraction", "already_retracted"]
HTTP = {"unauthenticated": 401, "body_forbidden_field": 400, "body_unknown_field": 400, "body_invalid": 400, "fork_unknown": 404, "insufficient_scope": 403, "forbidden": 403, "request_id_conflict": 409, "request_invalid": 400, HTTP = {"unauthenticated": 401, "body_forbidden_field": 400, "body_unknown_field": 400, "body_invalid": 400, "fork_unknown": 404, "insufficient_scope": 403, "forbidden": 403, "request_id_conflict": 409, "request_invalid": 400, "fork_too_large": 422,
"stale_fork": 409, "not_a_decider": 403, "fork_closed": 409, "choice_invalid": 400, "bad_retraction": 400, "already_retracted": 409} "stale_fork": 409, "not_a_decider": 403, "fork_closed": 409, "choice_invalid": 400, "bad_retraction": 400, "already_retracted": 409}
STORED_REFUSALS = [] STORED_REFUSALS = []
for reason in REASON_CODES: for reason in REASON_CODES:
@ -385,14 +391,47 @@ def fcard(n, status="open", **kw):
return c return c
def fbody(c, choice, aid_note): def fbody(c, choice, aid_note):
return body(choice, aid_note, h=c["options_hash"], fork=c["fork_id"]) return body(choice, aid_note, h=c["options_hash"], fork=c["fork_id"])
def with_count(e, n): return dict(e, record=dict(e["record"], answerer_count=n))
def withheld_answers(e):
e = json.loads(json.dumps(e))
for a in e["record"]["answers"]: a["answerer"] = None
return e
def fentry(c, answers=(), counted=None): def fentry(c, answers=(), counted=None):
return {"schemaVersion": "gamecult.fork.entry.v0", "card": c, "record": record(counted, list(answers), c["options_hash"], c["fork_id"])} return {"schemaVersion": "gamecult.fork.entry.v0", "card": c, "record": record(counted, list(answers), c["options_hash"], c["fork_id"])}
def withheld(e):
"""The non-decider twin of a decider-view entry: ids withheld, counts kept (fork-card.md, Reader views)."""
e = json.loads(json.dumps(e)); e["card"]["deciders"] = []
for a in e["record"]["answers"]: a["answerer"] = None
return e
q5 = fcard(5); q4 = fcard(4); q2 = fcard(2) q5 = fcard(5); q4 = fcard(4); q2 = fcard(2)
q3 = fcard(3, "closed", ruling="cultlib-gaps:resolution:question.q3.n1", ruled_from=["n1"]); q1 = fcard(1, "closed", ruling="cultlib-gaps:resolution:question.q1.n1") q3 = fcard(3, "closed", ruling="cultlib-gaps:resolution:question.q3.n1", ruled_from=["n1"]); q1 = fcard(1, "closed", ruling="cultlib-gaps:resolution:question.q1.n1")
a5 = da("Counted", None, None, fbody(q5, "no", NOTE), srv("n1", OP, T(2))) a5 = da("Counted", None, None, fbody(q5, "no", NOTE), srv("n1", OP, T(2)))
a3 = da("Counted", None, None, fbody(q3, "yes", NOTE), srv("n1", OP, T(2))) a3 = da("Counted", None, None, fbody(q3, "yes", NOTE), srv("n1", OP, T(2)))
r3 = da("Refused", None, "fork_closed", fbody(q3, "no", NOTE), srv("n2", HM, T(4), "Steward")) r3 = da("Refused", None, "fork_closed", fbody(q3, "no", NOTE), srv("n2", HM, T(4), "Steward"))
ENTRIES = [fentry(q5, [a5], "n1"), fentry(q4), fentry(q3, [a3, r3], "n1"), fentry(q2), fentry(q1)] ENTRIES = [fentry(q5, [a5], "n1"), fentry(q4), fentry(q3, [a3, r3], "n1"), fentry(q2), fentry(q1)]
a5b = da("Revision", "n1", None, fbody(q5, "yes", NOTE), srv("n2", OP, T(4)))
TWICE = fentry(q5, [a5, a5b], "n1") # two answers, one account
q0 = fcard(0)
# Revision and Retraction answers are nullable like Counted and Refused: one fork holding a Counted, its Revision and a Retraction of the Counted (appended after the older entries; nothing above moves)
q6 = fcard(6); _RID = RID[0] # the request_id counter is restored after this block: no later body changes
a6 = da("Counted", None, None, fbody(q6, "no", NOTE), srv("n1", OP, T(2)))
a6r = da("Revision", "n1", None, fbody(q6, "yes", NOTE), srv("n2", OP, T(4)))
a6x = da("Retraction", "n1", None, body(retracts="n1", h=q6["options_hash"], fork=q6["fork_id"]), srv("n3", OP, T(6)))
RID[0] = _RID
REVRET = fentry(q6, [a6, a6r, a6x], "n2")
WREVRET = withheld(REVRET)
NAMED_RETRACTION = json.loads(json.dumps(WREVRET)); NAMED_RETRACTION["record"]["answers"][2]["answerer"] = HM
WENTRIES = [withheld(e) for e in ENTRIES]
MIXED = json.loads(json.dumps(WENTRIES[2])); MIXED["record"]["answers"][0]["answerer"] = OP # one answer named among nils
ORDER0 = [fentry(fcard(3)), fentry(fcard(2)), fentry(fcard(1)), fentry(q0)] # a Mind whose oldest card has order 0
q4b = dict(fcard(4), fork_id="gamecult-chat:question:q4b") # a second card on order 4 in the same Mind
def order_shared(entries):
seen, out = {}, []
for e in entries:
c = e["card"]
if c["order"] in seen: out.append(f"order {c['order']} is shared by {seen[c['order']]} and {c['fork_id']}")
else: seen[c["order"]] = c["fork_id"]
return out
def lreq(mind="gamecult-chat", status=None, cursor=None, limit=10): return {"schemaVersion": "gamecult.fork.list_request.v0", "mind": mind, "status": status, "cursor": cursor, "limit": limit} def lreq(mind="gamecult-chat", status=None, cursor=None, limit=10): return {"schemaVersion": "gamecult.fork.list_request.v0", "mind": mind, "status": status, "cursor": cursor, "limit": limit}
def judge_list(entries, ordinal, rq): def judge_list(entries, ordinal, rq):
rows = sorted(entries, key=lambda e: -e["card"]["order"]) rows = sorted(entries, key=lambda e: -e["card"]["order"])
@ -409,8 +448,41 @@ LISTS = [
{"name": "all_forks_one_page", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(limit=50))]}, {"name": "all_forks_one_page", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(limit=50))]},
{"name": "recorded_only_paged", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(status="recorded", limit=1)), lstep(ENTRIES, 412, lreq(status="recorded", cursor="c3", limit=1))]}, {"name": "recorded_only_paged", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(status="recorded", limit=1)), lstep(ENTRIES, 412, lreq(status="recorded", cursor="c3", limit=1))]},
{"name": "limit_zero_is_the_count_and_stamp_only", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(limit=0))]}, {"name": "limit_zero_is_the_count_and_stamp_only", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(limit=0))]},
{"name": "order_zero_is_legal_but_no_cursor_names_it", "ordinal": 9, "entries": ORDER0, "steps": [lstep(ORDER0, 9, lreq(limit=3)), lstep(ORDER0, 9, lreq(cursor="c1", limit=3))]},
{"name": "non_decider_page", "ordinal": 412, "entries": WENTRIES, "twin": "all_forks_one_page", "steps": [lstep(WENTRIES, 412, lreq(limit=50))]},
{"name": "a_mind_with_no_forks", "ordinal": 7, "entries": [], "steps": [lstep([], 7, lreq(mind="nibu", limit=10))]}, {"name": "a_mind_with_no_forks", "ordinal": 7, "entries": [], "steps": [lstep([], 7, lreq(mind="nibu", limit=10))]},
{"name": "counted_revision_and_retraction_page", "ordinal": 413, "entries": [REVRET], "steps": [lstep([REVRET], 413, lreq(limit=10))]},
{"name": "non_decider_counted_revision_and_retraction_page", "ordinal": 413, "entries": [WREVRET], "twin": "counted_revision_and_retraction_page", "steps": [lstep([WREVRET], 413, lreq(limit=10))]},
] ]
# A page may stop short of `limit` when the next entry would pass the receiver's response budget; a list whose first
# entry alone passes it is refused `fork_too_large` (422). The budget is not in the vector: a step records what the receiver answered, and the
# judges check it against the unbudgeted page (a nonempty strict prefix, resumed below its last entry) or against the entry the page would open with.
def too_large_doc(c): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", "error": f"fork_too_large: fork {c['fork_id']} (order {c['order']}) alone is past the response budget"}
def read_too_large_doc(c): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", "error": f"fork_too_large: fork {c['fork_id']} alone is past the response budget"}
READ_TOO_LARGE = [{"name": "a_read_whose_entry_is_past_the_cap_is_fork_too_large_without_an_order", "ordinal": 9, "entries": ORDER0,
"request": {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": ORDER0[0]["card"]["fork_id"]},
"refusal": read_too_large_doc(ORDER0[0]["card"]), "refusalHex": hx(read_too_large_doc(ORDER0[0]["card"])), "httpStatus": HTTP["fork_too_large"], "fork_id": ORDER0[0]["card"]["fork_id"]}]
# An answer on a stale card whose current-card re-read is past the cap: the same refusal document as the read form over a stored Refused(stale_fork), never counted.
_big = ORDER0[0]["card"]
ANSWER_TOO_LARGE = [{"name": "a_stale_answer_whose_card_reread_is_past_the_cap_is_fork_too_large_over_a_stored_stale_refusal",
"entries": ORDER0, "request": body("yes", NOTE, h=STALE_H, fork=_big["fork_id"], rid="0000a001-0000-4000-8000-00000000a001"),
"refusal": read_too_large_doc(_big), "refusalHex": hx(read_too_large_doc(_big)), "httpStatus": HTTP["fork_too_large"], "outcome": "Refused", "code": "stale_fork", "counted": False, "fork_id": _big["fork_id"]}]
def short_step(entries, ordinal, rq, keep):
full = judge_list(entries, ordinal, rq); page = full["forks"][:keep]
assert 0 < keep < len(full["forks"])
r = dict(full, forks=page, next_cursor=f"c{page[-1]['card']['order']}"); return {"request": rq, "response": r, "responseHex": hx(r)}
def large_step(entries, ordinal, rq):
c = judge_list(entries, ordinal, rq)["forks"][0]["card"]; r = too_large_doc(c)
return {"request": rq, "refusal": r, "refusalHex": hx(r), "httpStatus": HTTP["fork_too_large"], "fork_id": c["fork_id"], "order": c["order"]}
SHORT_PAGES = [
{"name": "a_short_page_resumes_below_its_last_entry", "ordinal": 9, "entries": ORDER0, "steps": [short_step(ORDER0, 9, lreq(limit=3), 2), lstep(ORDER0, 9, lreq(cursor="c2", limit=3))]},
{"name": "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", "ordinal": 9, "entries": ORDER0,
"steps": [large_step(ORDER0, 9, lreq(limit=3)), lstep(ORDER0, 9, lreq(cursor="c3", limit=3))]},
{"name": "fork_too_large_on_order_zero_leaves_nothing_below", "ordinal": 9, "entries": ORDER0[2:],
"steps": [short_step(ORDER0[2:], 9, lreq(limit=5), 1), large_step(ORDER0[2:], 9, lreq(cursor="c1", limit=5))]},
]
DUP_LIST = judge_list(ENTRIES + [fentry(q4b)], 412, lreq(limit=50))
assert order_shared(DUP_LIST["forks"]) == ["order 4 is shared by gamecult-chat:question:q4 and gamecult-chat:question:q4b"]
TOK = lambda aud, *sc, active=True: {"audience": aud, "scopes": list(sc), "active": active} TOK = lambda aud, *sc, active=True: {"audience": aud, "scopes": list(sc), "active": active}
AUTH = [ AUTH = [
("list_with_desk_read", "list", TOK("bifrost-desk", "desk.read"), "ok"), ("read_with_desk_read", "read", TOK("bifrost-desk", "desk.read"), "ok"), ("list_with_desk_read", "list", TOK("bifrost-desk", "desk.read"), "ok"), ("read_with_desk_read", "read", TOK("bifrost-desk", "desk.read"), "ok"),
@ -507,8 +579,10 @@ fork = {
"card": {"valid": [{"name": "operator_fork", "message": CARD, "messagePackHex": hx(CARD)}, "card": {"valid": [{"name": "operator_fork", "message": CARD, "messagePackHex": hx(CARD)},
{"name": "leans_flag_closed_two_deciders", "message": card(leans="yes", flag="provisional: Self expects this to move", deciders=[OP, HM], depends=[], raised_in=None, status="closed", ruling="x:resolution:y", ruled_from=["n1"], order=0), {"name": "leans_flag_closed_two_deciders", "message": card(leans="yes", flag="provisional: Self expects this to move", deciders=[OP, HM], depends=[], raised_in=None, status="closed", ruling="x:resolution:y", ruled_from=["n1"], order=0),
"messagePackHex": hx(card(leans="yes", flag="provisional: Self expects this to move", deciders=[OP, HM], depends=[], raised_in=None, status="closed", ruling="x:resolution:y", ruled_from=["n1"], order=0))}, "messagePackHex": hx(card(leans="yes", flag="provisional: Self expects this to move", deciders=[OP, HM], depends=[], raised_in=None, status="closed", ruling="x:resolution:y", ruled_from=["n1"], order=0))},
{"name": "raised_at_nil", "message": card(raised_at=None), "messagePackHex": hx(card(raised_at=None))},
{"name": "depends_at_both_caps", "message": card(depends=["a"] * 7 + ["\u00e9" * 500]), "messagePackHex": hx(card(depends=["a"] * 7 + ["\u00e9" * 500]))}, {"name": "depends_at_both_caps", "message": card(depends=["a"] * 7 + ["\u00e9" * 500]), "messagePackHex": hx(card(depends=["a"] * 7 + ["\u00e9" * 500]))},
{"name": "closed_without_a_ruling_the_question_was_withdrawn", "message": card(status="closed"), "messagePackHex": hx(card(status="closed"))}, {"name": "closed_without_a_ruling_the_question_was_withdrawn", "message": card(status="closed"), "messagePackHex": hx(card(status="closed"))},
{"name": "withheld_deciders", "message": card(deciders=[], decider_count=2), "messagePackHex": hx(card(deciders=[], decider_count=2))},
{"name": "deciders_at_the_cap_of_64", "message": card(deciders=DECIDERS_64), "messagePackHex": hx(card(deciders=DECIDERS_64))}], {"name": "deciders_at_the_cap_of_64", "message": card(deciders=DECIDERS_64), "messagePackHex": hx(card(deciders=DECIDERS_64))}],
"invalid": [bad(n, w, m) for n, w, m in [ "invalid": [bad(n, w, m) for n, w, m in [
("depends_nine_lines", "depends holds at most 8 lines (the stored question's limit)", card(depends=["a"] * 9)), ("depends_nine_lines", "depends holds at most 8 lines (the stored question's limit)", card(depends=["a"] * 9)),
@ -516,7 +590,13 @@ fork = {
("depends_line_1001_bytes", "rule: one byte over the limit (500 two-byte characters and one more; not expressible in the schema)", card(depends=["\u00e9" * 500 + "a"])), ("depends_line_1001_bytes", "rule: one byte over the limit (500 two-byte characters and one more; not expressible in the schema)", card(depends=["\u00e9" * 500 + "a"])),
("depends_line_over_1000_bytes", "rule: a depends line is at most 1000 UTF-8 bytes (the stored Line limit; not expressible in the schema)", card(depends=["\u00e9" * 501])), ("depends_line_over_1000_bytes", "rule: a depends line is at most 1000 UTF-8 bytes (the stored Line limit; not expressible in the schema)", card(depends=["\u00e9" * 501])),
("one_option", "two or more options", card(options=[OPT[0]], recommendation="yes")), ("one_option", "two or more options", card(options=[OPT[0]], recommendation="yes")),
("no_deciders", "a fork with no decider cannot be answered", card(deciders=[])), ("decider_count_zero", "a fork with no decider cannot be answered; an empty deciders list now means withheld, so the count refuses it", card(deciders=[], decider_count=0)),
("decider_count_missing", "map-mode writes every key: decider_count is required", {k: v for k, v in card().items() if k != "decider_count"}),
("decider_count_65", "decider_count is at most 64 (Bifrost's per-call list)", card(deciders=[], decider_count=65)),
("decider_count_string", "decider_count is an integer", card(deciders=[], decider_count="2")),
("decider_count_null", "decider_count is never nil: it states the count even when the ids are withheld", card(deciders=[], decider_count=None)),
("decider_count_fewer_than_the_ids", "rule R-card: a card that shows deciders shows all of them, so the count equals the ids (not expressible in the schema)", card(deciders=[OP, HM], decider_count=1)),
("decider_count_more_than_the_ids_shown", "rule R-card: a visible list must be whole; a partial list is no view (not expressible in the schema)", card(deciders=[OP, HM], decider_count=3)),
("null_recommendation", "every card recommends an option", dict(card(), recommendation=None)), ("null_recommendation", "every card recommends an option", dict(card(), recommendation=None)),
("unknown_key", "closed document", dict(card(), note="x")), ("unknown_key", "closed document", dict(card(), note="x")),
("option_with_a_label", "options are {id,text}; the old format's display label is not a member (fork-card.md, Old format)", card(options=[dict(OPT[0], label="Yes"), OPT[1]])), ("option_with_a_label", "options are {id,text}; the old format's display label is not a member (fork-card.md, Old format)", card(options=[dict(OPT[0], label="Yes"), OPT[1]])),
@ -525,12 +605,15 @@ fork = {
("duplicate_option_ids", "rule: option ids are unique (not expressible in the schema)", card(options=[OPT[0], dict(OPT[1], id=OPT[0]["id"])])), ("duplicate_option_ids", "rule: option ids are unique (not expressible in the schema)", card(options=[OPT[0], dict(OPT[1], id=OPT[0]["id"])])),
("recommendation_not_an_option", "rule: recommendation is an option id (not expressible in the schema)", card(recommendation="maybe")), ("recommendation_not_an_option", "rule: recommendation is an option id (not expressible in the schema)", card(recommendation="maybe")),
("leans_not_an_option", "rule: leans is nil or an option id (not expressible in the schema)", card(leans="maybe")), ("leans_not_an_option", "rule: leans is nil or an option id (not expressible in the schema)", card(leans="maybe")),
("duplicate_deciders", "rule: deciders are unique (not expressible in the schema)", card(deciders=[OP, OP])), ("duplicate_deciders", "rule: deciders are unique (not expressible in the schema)", card(deciders=[OP, OP], decider_count=2)),
("ruled_from_on_an_open_card", "rule: a ruling that answers a fork closes it, so an open card cites no answer in ruled_from (not expressible in the schema)", card(ruled_from=["n1"])), ("ruled_from_on_an_open_card", "rule: a ruling that answers a fork closes it, so an open card cites no answer in ruled_from (not expressible in the schema)", card(ruled_from=["n1"])),
("open_card_names_a_ruling", "rule: only a closed card names a ruling (not expressible in the schema)", card(ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1")), ("open_card_names_a_ruling", "rule: only a closed card names a ruling (not expressible in the schema)", card(ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1")),
("ruled_from_without_a_ruling", "rule: ruled_from lists answers a ruling was admitted from, so it needs the ruling (not expressible in the schema)", card(status="closed", ruled_from=["n1"])), ("ruled_from_without_a_ruling", "rule: ruled_from lists answers a ruling was admitted from, so it needs the ruling (not expressible in the schema)", card(status="closed", ruled_from=["n1"])),
("options_hash_of_another_card", "rule: options_hash equals the hash of the shown members (not expressible in the schema)", dict(card(), options_hash=STALE_H)), ("options_hash_of_another_card", "rule: options_hash equals the hash of the shown members (not expressible in the schema)", dict(card(), options_hash=STALE_H)),
("deciders_65", "deciders holds at most 64 (Huginn's bound)", card(deciders=DECIDERS_64 + ["00000041-0000-4000-8000-000000000041"])), ("raised_at_epoch_integer", "raised_at is an RFC3339 instant string or nil, not epoch milliseconds", card(raised_at=1762767000000)),
("raised_at_date_only", "raised_at is an instant with a time and a Z, not a date", card(raised_at="2026-11-10")),
("raised_at_missing", "map-mode writes every key: raised_at is required, nil when unknown", {k: v for k, v in card().items() if k != "raised_at"}),
("deciders_65", "deciders holds at most 64 (Huginn's bound)", card(deciders=DECIDERS_64 + ["00000041-0000-4000-8000-000000000041"], decider_count=65)),
]]}, ]]},
"request": {"valid": [{"name": n, "message": m, "messagePackHex": hx(m)} for n, m in "request": {"valid": [{"name": n, "message": m, "messagePackHex": hx(m)} for n, m in
[("choice_and_note", x1), ("choice_alone", pick), ("note_alone_clear_pick", words), ("retraction", retraction("n1")), [("choice_and_note", x1), ("choice_alone", pick), ("note_alone_clear_pick", words), ("retraction", retraction("n1")),
@ -556,8 +639,11 @@ fork = {
]]}, ]]},
"deskAnswer": {"valid": [{"name": n, "message": m, "messagePackHex": hx(m)} for n, m in "deskAnswer": {"valid": [{"name": n, "message": m, "messagePackHex": hx(m)} for n, m in
[("counted", CNT), ("revision", REV), ("retraction", da("Retraction", "n1", None, retraction("n1"), sr("n8"))), [("counted", CNT), ("revision", REV), ("retraction", da("Retraction", "n1", None, retraction("n1"), sr("n8"))),
("withheld_answerer", dict(CNT, answerer=None)),
("refused_stale", da("Refused", None, "stale_fork", stale_body(), sx1)), ("refused_stale", da("Refused", None, "stale_fork", stale_body(), sx1)),
("refused_closed_retraction_names_its_target", da("Refused", None, "fork_closed", retraction("n1"), sr("n9")))]], ("refused_closed_retraction_names_its_target", da("Refused", None, "fork_closed", retraction("n1"), sr("n9"))),
("withheld_answerer_revision", dict(REV, answerer=None)),
("withheld_answerer_retraction", dict(da("Retraction", "n1", None, retraction("n1", rid=NULLANS_RID), sr("n8")), answerer=None))]],
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [ "invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
("counted_with_a_code", "only Refused carries a code", dict(CNT, code="fork_closed")), ("counted_with_a_code", "only Refused carries a code", dict(CNT, code="fork_closed")),
("refused_without_a_code", "Refused carries a reason code", dict(REF, code=None)), ("refused_without_a_code", "Refused carries a reason code", dict(REF, code=None)),
@ -572,8 +658,12 @@ fork = {
("outcome_lower_case", "outcome tags are Counted, Revision, Retraction, Refused", dict(CNT, outcome="counted")), ("outcome_lower_case", "outcome tags are Counted, Revision, Retraction, Refused", dict(CNT, outcome="counted")),
("no_answerer", "answerer is stamped on every stored answer", dict(CNT, answerer="")), ("no_answerer", "answerer is stamped on every stored answer", dict(CNT, answerer="")),
]]}, ]]},
"resultsInvalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
("answer_result_with_a_nil_answerer", "rule R-answer-result: the answer is the sender's own, stamped with their account; a nil answerer exists only inside a record shown to a non-decider (not expressible in the schema)", dict(result(dict(CNT, answerer=None)), counted_answer_id="n1")),
("answer_result_with_a_nil_answerer_on_a_revision", "rule R-answer-result holds for every outcome: a Revision the sender just made is stamped with their account (not expressible in the schema)", dict(result(dict(REV, answerer=None)), counted_answer_id="n2")),
("answer_result_with_a_nil_answerer_on_a_retraction", "rule R-answer-result holds for every outcome: a Retraction the sender just made is stamped with their account (not expressible in the schema)", dict(result(dict(da("Retraction", "n1", None, retraction("n1", rid=NULLANS_RID), sr("n8")), answerer=None)), counted_answer_id=None))]],
"results": [{"name": r["name"], "httpStatus": r["httpStatus"], "message": r["message"], "messagePackHex": hx(r["message"])} for r in STORED_REFUSALS], "results": [{"name": r["name"], "httpStatus": r["httpStatus"], "message": r["message"], "messagePackHex": hx(r["message"])} for r in STORED_REFUSALS],
"doorRefusals": [{"name": c, "httpStatus": HTTP[c], "message": refusal_doc(c), "messagePackHex": hx(refusal_doc(c))} for c in TAIL], "doorRefusals": [{"name": c, "httpStatus": HTTP[c], "message": refusal_doc(c), "messagePackHex": hx(refusal_doc(c))} for c in TAIL] + [{"name": "fork_too_large", "httpStatus": HTTP["fork_too_large"], "message": SHORT_PAGES[1]["steps"][0]["refusal"], "messagePackHex": SHORT_PAGES[1]["steps"][0]["refusalHex"]}],
"unavailable": [{"name": "second_door_down", "httpStatus": 503, "message": unavailable_doc(30), "messagePackHex": hx(unavailable_doc(30))}, "unavailable": [{"name": "second_door_down", "httpStatus": 503, "message": unavailable_doc(30), "messagePackHex": hx(unavailable_doc(30))},
{"name": "second_door_down_no_hint", "httpStatus": 503, "message": unavailable_doc(None), "messagePackHex": hx(unavailable_doc(None))}], {"name": "second_door_down_no_hint", "httpStatus": 503, "message": unavailable_doc(None), "messagePackHex": hx(unavailable_doc(None))}],
"rulingCites": cites, "rulingCites": cites,
@ -591,18 +681,44 @@ fork = {
("fork_id_empty", "a read names a fork", {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": ""}), ("fork_id_empty", "a read names a fork", {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": ""}),
("mind_empty", "a read names a Mind", {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "", "fork_id": FID}), ("mind_empty", "a read names a Mind", {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "", "fork_id": FID}),
("mind_missing", "map-mode writes every key; the Mind is the route's", {"schemaVersion": "gamecult.fork.read_request.v0", "fork_id": FID})]]}, ("mind_missing", "map-mode writes every key; the Mind is the route's", {"schemaVersion": "gamecult.fork.read_request.v0", "fork_id": FID})]]},
"entry": {"valid": [{"name": n, "message": e, "messagePackHex": hx(e)} for n, e in [("open_no_answers", ENTRIES[1]), ("open_counted", ENTRIES[0]), ("closed_with_ruling_and_a_refusal", ENTRIES[2])]], "entry": {"valid": [{"name": n, "message": e, "messagePackHex": hx(e)} for n, e in [("open_no_answers", ENTRIES[1]), ("open_counted", ENTRIES[0]), ("closed_with_ruling_and_a_refusal", ENTRIES[2])]]
+ [dict({"name": n, "message": e, "messagePackHex": hx(e)}, twin=t) for n, e, t in [("withheld_open_counted", WENTRIES[0], "open_counted"), ("withheld_closed_with_ruling_and_a_refusal", WENTRIES[2], "closed_with_ruling_and_a_refusal"), ("withheld_no_answers", WENTRIES[1], "open_no_answers")]]
+ [{"name": "open_counted_revision_and_retraction", "message": REVRET, "messagePackHex": hx(REVRET)},
dict({"name": "withheld_counted_revision_and_retraction", "message": WREVRET, "messagePackHex": hx(WREVRET)}, twin="open_counted_revision_and_retraction")],
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [ "invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
("entry_without_record", "an entry holds the card and its record", {k: v for k, v in ENTRIES[0].items() if k != "record"}), ("entry_without_record", "an entry holds the card and its record", {k: v for k, v in ENTRIES[0].items() if k != "record"}),
("record_of_another_fork", "rule: the record names the card's fork (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], fork_id="gamecult-chat:question:q9"))), ("record_of_another_fork", "rule: the record names the card's fork (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], fork_id="gamecult-chat:question:q9"))),
("record_under_another_hash", "rule: the record is under the card's options_hash (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], options_hash=STALE_H))), ("record_under_another_hash", "rule: the record is under the card's options_hash (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], options_hash=STALE_H))),
("counted_answer_not_derived", "rule: counted_answer_id is the first live Counted or Revision answer under the card's hash (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], counted_answer_id=None))), ("counted_answer_not_derived", "rule: counted_answer_id is the first live Counted or Revision answer under the card's hash (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], counted_answer_id=None))),
("card_hash_wrong", "rule: the server computes options_hash and a client recomputes it; a card with another hash is no card", dict(ENTRIES[1], card=dict(ENTRIES[1]["card"], options_hash=STALE_H), record=dict(ENTRIES[1]["record"], options_hash=STALE_H)))]]}, ("answerer_count_missing", "map-mode writes every key: answerer_count is required", dict(ENTRIES[0], record={k: v for k, v in ENTRIES[0]["record"].items() if k != "answerer_count"})),
("answerer_count_negative", "answerer_count is not negative", with_count(ENTRIES[0], -1)),
("answerer_count_null", "answerer_count is never nil: a decider and a non-decider read the same number", with_count(ENTRIES[0], None)),
("answerer_count_disagrees_with_the_ids", "rule R-record: in the decider view answerer_count is the number of distinct answerers (two here, not three; not expressible in the schema)", with_count(ENTRIES[2], 3)),
("answerer_count_counts_answers_not_people", "rule R-record: two answers by one account are one answerer (not expressible in the schema)", with_count(TWICE, 2)),
("withheld_card_with_a_named_answerer_among_nils", "rule R-entry: a withheld card beside one named answerer, even among nils (not expressible in the schema)", MIXED),
("withheld_count_over_the_answers", "rule R-record: withheld answers are at most as many answerers as answers (two answers, count 3; not expressible in the schema)", with_count(WENTRIES[2], 3)),
("withheld_count_zero_over_answers", "rule R-record: with at least one answer the count is at least 1 (not expressible in the schema)", with_count(WENTRIES[0], 0)),
("withheld_count_over_no_answers", "rule R-record: with no answers the count is 0 in both views (not expressible in the schema)", with_count(WENTRIES[1], 1)),
("decider_card_with_withheld_answers", "rule R-entry: a card that shows deciders is the decider view, so every answerer is named (not expressible in the schema)", withheld_answers(ENTRIES[0])),
("withheld_card_with_named_answers", "rule R-entry, the leak: a card with deciders withheld beside an answer that still names its account (not expressible in the schema)", dict(ENTRIES[0], card=WENTRIES[0]["card"])),
("card_hash_wrong", "rule: the server computes options_hash and a client recomputes it; a card with another hash is no card", dict(ENTRIES[1], card=dict(ENTRIES[1]["card"], options_hash=STALE_H), record=dict(ENTRIES[1]["record"], options_hash=STALE_H))),
("withheld_revision_and_retraction_beside_a_named_retraction", "rule R-entry: a withheld card beside one named answerer, a Retraction's included (not expressible in the schema)", NAMED_RETRACTION),
("withheld_revision_and_retraction_count_over_the_answers", "rule R-record: withheld answers are at most as many answerers as answers (three answers, count 4; not expressible in the schema)", with_count(WREVRET, 4))]]},
"listInvalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [ "listInvalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
("next_cursor_malformed", "a cursor is c<N>", dict(LISTS[0]["steps"][0]["response"], next_cursor="more")), ("next_cursor_malformed", "a cursor is c<N>", dict(LISTS[0]["steps"][0]["response"], next_cursor="more")),
("stamp_malformed", "a stamp is s<N>", dict(LISTS[0]["steps"][0]["response"], stamp="412")), ("stamp_malformed", "a stamp is s<N>", dict(LISTS[0]["steps"][0]["response"], stamp="412")),
("count_negative", "counts are not negative", dict(LISTS[0]["steps"][0]["response"], open_count=-1))]], ("count_negative", "counts are not negative", dict(LISTS[0]["steps"][0]["response"], open_count=-1)),
"listScenarios": [{"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]} for l in LISTS], ("a_page_mixes_views", "rule R-list: a page is one reader's view, so every entry agrees on whether the deciders are shown (not expressible in the schema)",
dict(LISTS[1]["steps"][0]["response"], forks=[WENTRIES[0]] + LISTS[1]["steps"][0]["response"]["forks"][1:]))]]
+ [dict(bad("two_cards_share_one_order", "rule: order is a unique rank per Mind; the refusal names both fork ids (not expressible in the schema)", DUP_LIST),
messagePackHex=hx(DUP_LIST), refusal=order_shared(DUP_LIST["forks"])[0])],
"listScenarios": [dict({"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]}, **({"twin": l["twin"]} if "twin" in l else {})) for l in LISTS],
"listShortPages": SHORT_PAGES,
"readTooLarge": READ_TOO_LARGE,
"answerTooLarge": ANSWER_TOO_LARGE,
"doorFaults": [dict(c, response=r, responseHex=hx(r)) for c, r in
[({"name": f"{x}_door_answers_request_id_conflict_the_client_sees_unavailable", "route": x, "doorCode": "request_id_conflict", "httpStatus": 503}, unavailable_doc(30)) for x in ("read", "list")]
+ [({"name": "answer_door_answers_request_id_conflict_the_client_sees_it", "route": "answer", "doorCode": "request_id_conflict", "httpStatus": HTTP["request_id_conflict"]}, refusal_doc("request_id_conflict"))]],
"mindRoutes": {"minds": MINDS, "forks": MIND_FORKS, "cases": MINDCASES, "sameResponse": SAME_RESPONSE}, "mindRoutes": {"minds": MINDS, "forks": MIND_FORKS, "cases": MINDCASES, "sameResponse": SAME_RESPONSE},
"authCases": [{"name": a[0], "route": a[1], "token": a[2], "expect": a[3], "huginn_down": len(a) > 4} for a in AUTH], "authCases": [{"name": a[0], "route": a[1], "token": a[2], "expect": a[3], "huginn_down": len(a) > 4} for a in AUTH],
}, },

View file

@ -166,7 +166,7 @@ public static class CultDocumentMessagePackSerialization
private static (MessagePackSerializerOptions Options, IFormatterResolver[] Ahead) Compose(IFormatterResolver[] consumerResolvers) private static (MessagePackSerializerOptions Options, IFormatterResolver[] Ahead) Compose(IFormatterResolver[] consumerResolvers)
{ {
// The guard goes first and wraps whatever formatter the rest of the composite would hand out, so a consumer // The guard goes first and wraps whatever formatter the rest of the composite would hand out (a union, or a concrete int-keyed object for its slot count), so a consumer
// resolver still encodes an arm while the key check runs. // resolver still encodes an arm while the key check runs.
var ahead = consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(CultMathExprResolver.Instance).ToArray(); var ahead = consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(CultMathExprResolver.Instance).ToArray();
var inner = CompositeResolver.Create(ahead.Append(StandardResolver.Instance).ToArray()); var inner = CompositeResolver.Create(ahead.Append(StandardResolver.Instance).ToArray());
@ -214,6 +214,19 @@ public static class CultDocumentMessagePackSerialization
?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}."); ?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}.");
} }
// The same decode, with the slots it read past noted (CultSlotScope). Opened around the one decode and after the depth check,
// so a payload past the nesting limit is refused, never marked.
internal static CultDecoded DeserializeMarked(Type type, byte[] payload, CultDocumentRegistry registry)
{
if (registry == null) throw new ArgumentNullException(nameof(registry));
registry.GetRequired(type);
CultPayloadDepth.Check(payload, type, PayloadDirection.Read);
using var scope = CultSlotScope.Open();
var document = MessagePackSerializer.Deserialize(type, payload, OptionsForDocument(type))
?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}.");
return new CultDecoded(document, scope.Finding());
}
/// <summary> /// <summary>
/// Replaces slots of a serialized document payload (a MessagePack array indexed by slot) with already-encoded member /// Replaces slots of a serialized document payload (a MessagePack array indexed by slot) with already-encoded member
/// values, leaving every other slot byte for byte as it was. A slot past the end extends the array with nil. /// values, leaving every other slot byte for byte as it was. A slot past the end extends the array with nil.
@ -310,7 +323,7 @@ public static class CultDocumentMessagePackSerialization
/// <summary> /// <summary>
/// Reads a store file: exactly one complete MessagePack array of three slots (header, schema catalog, records), or an empty /// Reads a store file: exactly one complete MessagePack array of three slots (header, schema catalog, records), or an empty
/// array, which is an empty store. A truncated file, bytes after the array, a value that is not an array, a first slot that /// array, which is an empty store. A truncated file, bytes after the array, a value that is not an array, a first slot that
/// is not a header string, a store with fewer or more slots, a record whose schema the catalog does not publish, or a body /// is not a header string, a store with fewer or more slots, a record whose schema the catalog does not publish, two records under one key, or a body
/// that does not decode are refused with /// that does not decode are refused with
/// <see cref="CultStoreUnreadableException"/>, so a reader never takes part of a file for the whole and never skips a slot /// <see cref="CultStoreUnreadableException"/>, so a reader never takes part of a file for the whole and never skips a slot
/// it does not understand. The rewriting paths use this same reader as their verdict on whether a file may be replaced. /// it does not understand. The rewriting paths use this same reader as their verdict on whether a file may be replaced.
@ -366,6 +379,7 @@ public static class CultDocumentMessagePackSerialization
} }
RequirePublishedSchemas(snapshot); RequirePublishedSchemas(snapshot);
RequireUniqueKeys(snapshot);
return (snapshot, catalogBytes, recordBytes); return (snapshot, catalogBytes, recordBytes);
} }
catch (CultStoreUnreadableException) catch (CultStoreUnreadableException)
@ -397,6 +411,17 @@ public static class CultDocumentMessagePackSerialization
} }
} }
// A key names one record: identity is the key string alone, compared by ordinal code units (no case folding, no normalisation).
private static void RequireUniqueKeys(CultPersistedStoreSnapshot snapshot)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var record in snapshot.Records)
{
if (!seen.Add(record.Key))
throw new CultStoreUnreadableException($"Record '{record.Key}' appears more than once.");
}
}
// Slot 0 of a non-empty store, read before anything after it: a binary or nil stamp is refused as such, and a string stamp is // Slot 0 of a non-empty store, read before anything after it: a binary or nil stamp is refused as such, and a string stamp is
// judged by the caller's rule when it passes one. Bytes that do not open with an array and a string are left to the full read. // judged by the caller's rule when it passes one. Bytes that do not open with an array and a string are left to the full read.
private static void JudgeStamp(byte[] payload, Action<string>? requireReadable) private static void JudgeStamp(byte[] payload, Action<string>? requireReadable)
@ -811,4 +836,7 @@ public class SingleFileMessagePackBackingStore : SingleFileBackingStore
{ {
return CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry); return CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry);
} }
internal override CultDecoded DeserializeMarked(Type documentType, byte[] payload) =>
CultDocumentMessagePackSerialization.DeserializeMarked(documentType, payload, Registry);
} }

View file

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

View file

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

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