diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index f8a6e5da..19bf1b34 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -9,44 +9,6 @@ on: workflow_dispatch: jobs: - getting-started: - name: ${{ matrix.os }} one-command multiplayer onboarding - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - os: [windows-latest, ubuntu-latest] - - steps: - - name: Check out CultLib - uses: actions/checkout@v4 - with: - path: CultLib - - - name: Check out the matching Eve contract - uses: actions/checkout@v4 - with: - repository: GameCult/Eve - # Eve main is the contract this sample is written against; the codex - # branch this used to pin was never merged and stopped on 2026-08-25. - ref: ${{ vars.EVE_INTEGRATION_REF || 'main' }} - path: Eve - - - name: Set up .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - - - name: Run the documented one-command checkpoint - shell: pwsh - run: ./CultLib/scripts/verify-eve-getting-started.ps1 -EveRoot ./Eve - reactive-and-resource-lifetime: name: ${{ matrix.os }} reactive semantics and leases runs-on: ${{ matrix.os }} @@ -54,7 +16,8 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-latest, ubuntu-latest] + # The forge runner is Ubuntu-only: Windows legs run on workflow_dispatch (GitHub or a Windows runner), Ubuntu gates push and PR. + os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "ubuntu-latest"]' || '["ubuntu-latest"]') }} steps: - name: Check out CultLib diff --git a/.github/workflows/cultnet-interop.yml b/.github/workflows/cultnet-interop.yml index 9072e0cb..118b496f 100644 --- a/.github/workflows/cultnet-interop.yml +++ b/.github/workflows/cultnet-interop.yml @@ -1,10 +1,9 @@ name: CultNet cross-runtime interop +# workflow_dispatch only: this job is Windows-only (pwsh Kotlin build, setup steps), and the forge +# runner is Ubuntu-only, so a push or pull_request trigger would queue a job that can never run. +# Run it from GitHub or any Windows runner. on: - push: - branches: - - main - pull_request: workflow_dispatch: jobs: diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index eb798bdf..caa24336 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -8,15 +8,26 @@ name: Publish packages # directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin # consume the core stack. # -# Tag a release to publish, for example `cultcache-ts-v0.13.6` or -# `cultnet-py-v0.1.0`. The tag's version must match the version in the package -# manifest; scripts/release-after-suite.mjs refuses the release otherwise rather -# than shipping a mislabelled artifact. +# Tag a Python release to publish, for example `cultnet-py-v0.1.0`. The tag's +# version must match the version in the package manifest; +# scripts/release-after-suite.mjs refuses the release otherwise rather than +# shipping a mislabelled artifact. +# +# npm publishing of @gamecult/cultcache-ts is deliberately off: `cultcache-ts-v*` +# is not a tag trigger until it is ruled on (the cultcache-ts job below runs from +# workflow_dispatch only, which publishes nothing). A tag run uses the workflow +# file at the tagged commit, not the one on main, so a tag on an older commit +# (`cultcache-ts-v0.15.0` sits on 7d292085, before the guard) still runs that +# commit's triggers and jobs. Read this file at the tagged sha before pushing a tag. on: push: + # cultcache-ts-v* is deliberately not a trigger: publishing @gamecult/cultcache-ts is disabled + # until it is ruled on, and whether NPM_TOKEN is set is unknown, so a pushed tag must not reach + # `npm publish`. The cultcache-ts jobs below still run from workflow_dispatch, which publishes nothing. + # (The comment sits above `tags:` because the tag-prefix pin in scripts/check-changelog-semver.test.mjs + # reads the list as `tags:` followed directly by its items.) tags: - - "cultcache-ts-v*" - "cultcache-py-v*" - "cultnet-py-v*" - "cultmesh-py-v*" diff --git a/.gitignore b/.gitignore index 6d0f1f77..30d5f28e 100644 --- a/.gitignore +++ b/.gitignore @@ -322,6 +322,10 @@ node_modules/ !packages/cultmesh-browser/** !packages/cultmesh-kotlin/ !packages/cultmesh-kotlin/** +!packages/heimdall-delegation/ +!packages/heimdall-delegation/** +!packages/heimdall-stake/ +!packages/heimdall-stake/** packages/**/node_modules/ packages/**/dist/ packages/**/dist-test/ diff --git a/CultLib.sln b/CultLib.sln index fbc20922..b72f2d76 100644 --- a/CultLib.sln +++ b/CultLib.sln @@ -35,10 +35,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSock EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets.Tests", "tests\GameCult.Networking.WebSockets.Tests\GameCult.Networking.WebSockets.Tests.csproj", "{B9A441EC-DADE-4500-B092-C635F3EB296A}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EveBrowserNetworkSample", "samples\eve-browser-network\EveBrowserNetworkSample.csproj", "{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cultmath", "cultmath", "{B48EAE30-152F-01E3-6E31-82B350494D3C}" @@ -49,6 +45,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath.Tests", "packages\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.ConsumerBuild", "tests\GameCult.Caching.ConsumerBuild\GameCult.Caching.ConsumerBuild.csproj", "{D3138F3C-9531-4906-A5D3-74BD857D0685}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.GeneratorConsumer", "tests\GameCult.Caching.GeneratorConsumer\GameCult.Caching.GeneratorConsumer.csproj", "{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.OutsideStoreTests", "tests\GameCult.Caching.OutsideStoreTests\GameCult.Caching.OutsideStoreTests.csproj", "{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -227,18 +227,6 @@ Global {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.Build.0 = Release|Any CPU {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.ActiveCfg = Release|Any CPU {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.Build.0 = Release|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.ActiveCfg = Debug|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.Build.0 = Debug|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.ActiveCfg = Debug|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.Build.0 = Debug|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.Build.0 = Release|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.ActiveCfg = Release|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.Build.0 = Release|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.ActiveCfg = Release|Any CPU - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.Build.0 = Release|Any CPU {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.Build.0 = Debug|Any CPU {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -275,6 +263,30 @@ Global {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x64.Build.0 = Release|Any CPU {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.ActiveCfg = Release|Any CPU {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.Build.0 = Release|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x64.ActiveCfg = Debug|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x64.Build.0 = Debug|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x86.Build.0 = Debug|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|Any CPU.Build.0 = Release|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x64.ActiveCfg = Release|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x64.Build.0 = Release|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x86.ActiveCfg = Release|Any CPU + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x86.Build.0 = Release|Any CPU + {A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|x64.ActiveCfg = Debug|Any CPU + {A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|x64.Build.0 = Debug|Any CPU + {A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|x86.ActiveCfg = Debug|Any CPU + {A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|x86.Build.0 = Debug|Any CPU + {A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|Any CPU.Build.0 = Release|Any CPU + {A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|x64.ActiveCfg = Release|Any CPU + {A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|x64.Build.0 = Release|Any CPU + {A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|x86.ActiveCfg = Release|Any CPU + {A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -294,10 +306,11 @@ Global {21F26329-2119-4037-93CC-DD1801A1AB78} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {B9A441EC-DADE-4500-B092-C635F3EB296A} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} - {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {B48EAE30-152F-01E3-6E31-82B350494D3C} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE} {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F} = {B48EAE30-152F-01E3-6E31-82B350494D3C} {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D} = {B48EAE30-152F-01E3-6E31-82B350494D3C} {D3138F3C-9531-4906-A5D3-74BD857D0685} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} + {1A75EA8F-00B3-49AB-8398-B1B7723BE10B} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} + {A54C2C29-941A-46A9-9DDE-9711B2F2EADD} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 4977a02a..b916cabe 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # CultLib +**New here? Start at [`docs/start`](docs/start/README.md)**: a language x package matrix with tested first-call snippets. + CultLib is GameCult's shared state, transport, and runtime substrate. It started as reusable C# game infrastructure, but the useful thing now is larger: typed `.cc` persistence, schema-stable wire formats, ergonomic reliable-UDP @@ -30,6 +32,30 @@ bespoke JSON bridge at every boundary. A frontend is just another CultMesh client: the daemon publishes a typed Eve/CultUI surface, and any compatible runtime can lower that surface where it needs to live. +## Entry Points + +- C# reference runtime: `src/` (`GameCult.Caching`, `GameCult.Networking`, + `GameCult.Mesh`, `GameCult.Geometry`, `GameCult.Unity`). Build and test with + `dotnet build CultLib.sln` and `dotnet test CultLib.sln`. +- Other runtimes, wire-compatible with the C# CultCache: `packages/cultcache-ts`, + `cultcache-py`, `cultcache-rs`, and the CultNet and CultMesh packages beside + them under `packages/`; portable math in `packages/cultmath`. +- Smallest working path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`. + Application path: `src/GameCult.Mesh/docs/getting-started/README.md` and + `samples/eve-two-runtime`. + +## Live And Planned + +Live: typed CultCache persistence, CultNet schema-v0 transports and native RUDP, +CultMesh nodes with Verse discovery, shard replication and authority leases, +the geometry documents, and CultUI for Unity. The browser client boundary +(`packages/cultmesh-browser`, `GameCult.Networking.WebSockets`) is proven on +loopback under an explicit `local-development` trust policy by +`samples/eve-browser-network`. + +Planned: a complete public Verse proof over deployed infrastructure; the +remaining gates are listed in `src/GameCult.Mesh/docs/browser-verse-transport.md`. + ## Which Package Do I Want? If the job is shared game state, start here before inventing a fourth drawer @@ -72,7 +98,8 @@ start with the maintained series. Its first executable package-consumer checkpoint is [`samples/eve-two-runtime`](samples/eve-two-runtime/README.md). The real-process browser continuation is -[`samples/eve-browser-network`](samples/eve-browser-network/README.md). +[`samples/eve-browser-network`](samples/eve-browser-network/README.md); it +needs a sibling Eve checkout (`--eve-root`) and is not in `CultLib.sln`. One documented command runs both layers: ```powershell @@ -97,7 +124,6 @@ The solution includes: - `GameCult.Logging`: common logging abstraction plus console and file implementations - `GameCult.Caching`: `DatabaseEntry`-based cache, indexes, global entries, and backing-store abstractions - `GameCult.Caching.MessagePack`: MessagePack-backed persistence for the cache -- `GameCult.Caching.NewtonsoftJson`: Newtonsoft.Json-backed persistence for the cache - `GameCult.Math` / CultMath: portable numeric primitives, deterministic random, noise, and shader-parity helpers - `GameCult.Geometry` / CultGeometry: engine-neutral geometry algorithms plus CultCache-native domain, selected-cut, and mesh artifact documents - `GameCult.Networking`: encrypted login/register/verify flows, schema-v0 contracts, transport adapters, and native RUDP sessions @@ -125,7 +151,6 @@ src/ GameCult.Logging/ GameCult.Caching/ GameCult.Caching.MessagePack/ - GameCult.Caching.NewtonsoftJson/ GameCult.Geometry/ GameCult.Networking/ GameCult.Networking.WebSockets/ @@ -344,7 +369,6 @@ Each subproject has a local README with package-specific detail: - [GameCult.Logging](src/GameCult.Logging/README.md) - [GameCult.Caching](src/GameCult.Caching/README.md) - [GameCult.Caching.MessagePack](src/GameCult.Caching.MessagePack/README.md) -- [GameCult.Caching.NewtonsoftJson](src/GameCult.Caching.NewtonsoftJson/README.md) - [GameCult.Geometry](src/GameCult.Geometry/README.md) - [GameCult.Networking](src/GameCult.Networking/README.md) - [GameCult.Mesh](src/GameCult.Mesh/README.md) diff --git a/contracts/cultmesh/content-acceptance-vectors.json b/contracts/cultmesh/content-acceptance-vectors.json index a938f430..d9868a63 100644 --- a/contracts/cultmesh/content-acceptance-vectors.json +++ b/contracts/cultmesh/content-acceptance-vectors.json @@ -4,7 +4,7 @@ "label": "base", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { @@ -67,112 +67,112 @@ "label": "short_8", "inputBase64": "mKNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWs=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuQgA==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkIA=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "short_9", "inputBase64": "maNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXg=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiA", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gA==", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "array16_header", "inputBase64": "3AAKo2EvYqVhc3NldKEx2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "array32_header", "inputBase64": "3QAAAAqjYS9ipWFzc2V0oTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "extra_11_nil", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdsA=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "extra_11_str", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdqF4", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "extra_11_nested", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdpKRoXiBoWHLAAAAAAAAAAA=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "extra_11_ext", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdtQBAg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "extra_11_ext8", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdscCAQcH", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "extra_11_bin", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdsQCAQI=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "extra_11_float", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdsoAAAAA", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "extra_11_uint64", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhds8AAAAAAAAABw==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "extra_11_array16", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdtwAAgEC", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "extra_11_map16", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdt4AAQEC", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "extra_11_str8", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdtkBQQ==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "extra_12", "inputBase64": "nKNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdsCheQ==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { @@ -193,40 +193,40 @@ "label": "deep_extra_250", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdpGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZHA", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "deep_chunkextra_250", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRwJGheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZHAkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "deep_extra_253", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdpGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZHA", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "deep_chunkextra_253", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRwJGheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZHAkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "deep_extra_254", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdpGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRwA==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "deep_chunkextra_254", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkcCRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRwJGheIGha6F2", "accepted": false, "reencodedBase64": null, "recordKey": null @@ -235,12 +235,12 @@ "label": "deep_extra_255", "inputBase64": "m6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdpGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkcA=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "deep_chunkextra_255", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZHAkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkcCRoXiBoWuhdg==", "accepted": false, "reencodedBase64": null, "recordKey": null @@ -254,7 +254,7 @@ }, { "label": "deep_chunkextra_256", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRwJGheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZHAkaF4gaFroXY=", "accepted": false, "reencodedBase64": null, "recordKey": null @@ -268,7 +268,7 @@ }, { "label": "deep_chunkextra_257", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkcCRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRwJGheIGha6F2", "accepted": false, "reencodedBase64": null, "recordKey": null @@ -282,7 +282,7 @@ }, { "label": "deep_chunkextra_300", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZHAkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkcCRoXiBoWuhdg==", "accepted": false, "reencodedBase64": null, "recordKey": null @@ -291,329 +291,357 @@ "label": "chunk_3", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRk9lAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoJGheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoKCRoXiBoWuhdg==", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_5", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvAkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvAkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_5_nested", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuSAQKRoXiBoWuhdg==", + "accepted": false, + "reencodedBase64": null, + "recordKey": null + }, + { + "label": "chunk_id_str", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", + "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "label": "chunk_id_int", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWsAkaF4gaFroXY=", + "accepted": false, + "reencodedBase64": null, + "recordKey": null + }, + { + "label": "chunk_id_str8", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvZATCRoXiBoWuhdg==", + "accepted": true, + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", + "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "label": "chunk_6", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMCRoXiBoWuhdg==", + "accepted": true, + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_map", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuCoWGhYqFjoWSRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMIKhYaFioWOhZJGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_map16", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWveAAIBAgMEkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMN4AAgECAwSRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_map32", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvfAAAAAQECkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMN8AAAABAQKRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_array", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuTAQIDkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJMBAgORoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_array16", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvcAAIBApGheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMNwAAgECkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_array32", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvdAAAAAQGRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMN0AAAABAZGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_fixext1", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvUAQKRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMNQBApGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_fixext2", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvVAQIDkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMNUBAgORoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_fixext4", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvWAQIDBAWRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMNYBAgMEBZGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_fixext8", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvXAQIDBAUGBwgJkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMNcBAgMEBQYHCAmRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_fixext16", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvYAQAAAAAAAAAAAAAAAAAAAACRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMNgBAAAAAAAAAAAAAAAAAAAAAJGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_ext8", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvHAgEHB5GheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMcCAQcHkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_ext16", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvIAAIBBweRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMgAAgEHB5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_ext32", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvJAAAAAgEHB5GheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMkAAAACAQcHkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_bin8", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvEAgECkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMQCAQKRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_bin16", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvFAAIBApGheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMUAAgECkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_bin32", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvGAAAAAgECkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMYAAAACAQKRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_str8", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvZAkFCkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMNkCQUKRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_str16", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvaAAJBQpGheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMNoAAkFCkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_str32", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvbAAAAAkFCkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMNsAAAACQUKRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_fixstr", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuiYWKRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMKJhYpGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_f32", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvKAAAAAZGheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMoAAAABkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_f64", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvLAAAAAAAAAAGRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMsAAAAAAAAAAZGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_u8", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvMAZGheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMwBkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_u16", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvNAAGRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMM0AAZGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_u32", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvOAAAAAZGheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMM4AAAABkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_u64", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvPAAAAAAAAAAGRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMM8AAAAAAAAAAZGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_i8", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvQAZGheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMNABkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_i16", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvRAAGRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMNEAAZGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_i32", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvSAAAAAZGheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMNIAAAABkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_i64", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvTAAAAAAAAAAGRoXiBoWuhdg==", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMNMAAAAAAAAAAZGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_true", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvDkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMORoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_false", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvCkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMKRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_nil", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvAkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMCRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_negfixint", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWv/kaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMP\u002BRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_reserved", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvBkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMMGRoXiBoWuhdg==", "accepted": false, "reencodedBase64": null, "recordKey": null }, { "label": "chunk_extra_fixint_max", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWt/kaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMH\u002BRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_negfixint_min", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvgkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMOCRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_fixstr_31", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWu/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJGheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhML8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_fixmap_15", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuPAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMI8BAgECAQIBAgECAQIBAgECAQIBAgECAQIBAgECAQKRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_fixarray_15", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWufAAAAAAAAAAAAAAAAAAAAkaF4gaFroXY=", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJ8AAAAAAAAAAAAAAAAAAACRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_extra_nested", - "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuSgaFhkgECkJGheIGha6F2", + "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRltlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJKBoWGSAQKQkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuhMJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_1", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRkdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NZGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoJGheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoKCRoXiBoWuhdg==", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunk_0", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRkJGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlKAAAKCRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlaAAAKCgkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { @@ -669,42 +697,42 @@ "label": "nil_artifactId", "inputBase64": "msClYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "msClYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "msClYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": null }, { "label": "nil_kind", "inputBase64": "mqNhL2LAoTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2LAoTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2LAoTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "nil_version", "inputBase64": "mqNhL2KlYXNzZXTA2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXTA2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXTA2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AK9hcHBsaWNhdGlvbi94LXShdJGV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha6CRoXiBoWuhdg==", "recordKey": "mesh:cdn:artifact:a-b:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "nil_contentHash", "inputBase64": "mqNhL2KlYXNzZXShMcAAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMcAAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2KlYXNzZXShMcAAr2FwcGxpY2F0aW9uL3gtdKF0kZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFroJGheIGha6F2", "recordKey": null }, { "label": "nil_mimeType", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQDAoXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQDAoXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQDAoXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "nil_createdAtUtc", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10wJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10wJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10wJGV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha6CRoXiBoWuhdg==", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { @@ -725,42 +753,42 @@ "label": "nil_chunk_hash", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlMAAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlMAAAKFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlcAAAKFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "nil_chunk_recordKey", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAwJGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAwJGheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAwKCRoXiBoWuhdg==", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "nil_tags", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvAgaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvAgaFroXY=", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugwIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "nil_tag_element", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuSwKF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuSwKF4gaFroXY=", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugksCheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "nil_metadata", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXjA", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXjA", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4wA==", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "nil_metadata_value", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWvA", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWvA", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFrwA==", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { @@ -788,7 +816,7 @@ "label": "metadata_integer_like_keys", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiDojEwoWGlYWxwaGGhYqEyoWM=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiDojEwoWGlYWxwaGGhYqEyoWM=", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4g6IxMKFhpWFscGhhoWKhMqFj", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { @@ -809,28 +837,28 @@ "label": "metadata_empty_key", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoKF2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoKF2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaChdg==", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "metadata_proto_key", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBqV9fcHJvdG9fX6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBqV9fcHJvdG9fX6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4galfX3Byb3RvX1\u002Bhdg==", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "metadata_map16", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXjeAAGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "metadata_map32", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXjfAAAAAaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { @@ -865,28 +893,28 @@ "label": "tags_array16", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWvcAAGheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "trailing_nil", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdsA=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "trailing_reserved_code", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdsE=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "trailing_truncated_array", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdpI=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { @@ -935,168 +963,168 @@ "label": "size_u8", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NcwAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "offset_u8", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NcwAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunksize_u8", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQDMAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "size_u16", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0AAK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "offset_u16", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0AAACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunksize_u16", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQDNAACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "size_u32", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc4AAAAAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "offset_u32", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc4AAAAAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunksize_u32", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQDOAAAAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "size_u64", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc8AAAAAAAAAAK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "offset_u64", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc8AAAAAAAAAAACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunksize_u64", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQDPAAAAAAAAAACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "size_i8", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdAAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "offset_i8", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdAAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunksize_i8", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQDQAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "size_i16", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdEAAK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "offset_i16", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdEAAACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunksize_i16", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQDRAACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "size_i32", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdIAAAAAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "offset_i32", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdIAAAAAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunksize_i32", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQDSAAAAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "size_i64", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdMAAAAAAAAAAK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "offset_i64", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdMAAAAAAAAAAACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunksize_i64", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQDTAAAAAAAAAACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { @@ -1425,7 +1453,7 @@ "label": "chunksize_int_max", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc5/////r2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAzn////\u002Bha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc5/////r2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAzn////\u002Bha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc5/////r2FwcGxpY2F0aW9uL3gtdKF0kZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAzn////\u002Bha6CRoXiBoWuhdg==", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { @@ -1453,14 +1481,14 @@ "label": "offset_beyond_int32", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc8AAAABf////a9hcHBsaWNhdGlvbi94LXShdJOU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM5/////oWuU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1zn/////Of////6FrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc7////\u002Bzn////\u002Bha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc8AAAABf////a9hcHBsaWNhdGlvbi94LXShdJOU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM5/////oWuU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1zn/////Of////6FrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc7////\u002Bzn////\u002Bha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc8AAAABf////a9hcHBsaWNhdGlvbi94LXShdJOV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM5/////oWugldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc5/////zn////\u002Bha6CV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1zv////7Of////6FroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "chunks_out_of_order", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQKvYXBwbGljYXRpb24veC10oXSSlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQEBoWuU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AAGha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQKvYXBwbGljYXRpb24veC10oXSSlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQEBoWuU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AAGha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQKvYXBwbGljYXRpb24veC10oXSSldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQEBoWugldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQABoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { @@ -1502,105 +1530,105 @@ "label": "str_str16", "inputBase64": "mqNhL2LaAAFBoTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KhQaEx2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", + "reencodedBase64": "mqNhL2KhQaEx2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AK9hcHBsaWNhdGlvbi94LXShdJGV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha6CRoXiBoWuhdg==", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "str_str32", "inputBase64": "mqNhL2LbAAAAAUGhMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KhQaEx2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", + "reencodedBase64": "mqNhL2KhQaEx2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AK9hcHBsaWNhdGlvbi94LXShdJGV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha6CRoXiBoWuhdg==", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "str_empty", "inputBase64": "mqNhL2KgoTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KgoTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2KgoTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "str_invalid_utf8", "inputBase64": "mqNhL2Ki//6hMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2Km77\u002B977\u002B9oTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2Km77\u002B977\u002B9oTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "str_lone_surrogate_cesu", "inputBase64": "mqNhL2Kj7aCAoTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2Kp77\u002B977\u002B977\u002B9oTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2Kp77\u002B977\u002B977\u002B9oTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "str_overlong", "inputBase64": "mqNhL2KiwK\u002BhMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2Km77\u002B977\u002B9oTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2Km77\u002B977\u002B9oTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "str_truncated_sequence", "inputBase64": "mqNhL2Ki4oKhMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2Kj77\u002B9oTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2Kj77\u002B9oTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "str_bom", "inputBase64": "mqbvu79ib22lYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqbvu79ib22lYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqbvu79ib22lYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:bom:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "str_nul", "inputBase64": "mqNhAGKlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhAGKlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhAGKlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "id_invalid_utf8", "inputBase64": "mqH/pWFzc2V0oTHZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqPvv72lYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqPvv72lYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:83d544ccc223c057d2bf80d3f2a32982c32c3c0db8e2674820da5064783fb097:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "hash_invalid_utf8", "inputBase64": "mqNhL2KlYXNzZXShMaH/AK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMaPvv70Ar2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2KlYXNzZXShMaPvv70Ar2FwcGxpY2F0aW9uL3gtdKF0kZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:\uFFFD" }, { "label": "version_invalid_utf8", "inputBase64": "mqNhL2KlYXNzZXSi//7ZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAAKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXSm77\u002B977\u002B92UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXSm77\u002B977\u002B92UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AK9hcHBsaWNhdGlvbi94LXShdJGV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha6CRoXiBoWuhdg==", "recordKey": "mesh:cdn:artifact:a-b:52793f8dc1d85e409f8c88be99d8b31d58f676246340150f406289e04a11151e:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "id_blank", "inputBase64": "mqIgIKVhc3NldKEx2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqIgIKVhc3NldKEx2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha5GheIGha6F2", + "reencodedBase64": "mqIgIKVhc3NldKEx2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AK9hcHBsaWNhdGlvbi94LXShdJGV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AACha6CRoXiBoWuhdg==", "recordKey": null }, { "label": "hash_blank", "inputBase64": "mqNhL2KlYXNzZXShMaIgIACvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMaIgIACvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMaIgIACvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": null }, { "label": "hash_prefix_upper_long_s", "inputBase64": "mqNhL2KlYXNzZXShMavFv2hhMjU2OmFiYwCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMavFv2hhMjU2OmFiYwCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMavFv2hhMjU2OmFiYwCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:\u017Fha256:abc" }, { @@ -1656,14 +1684,14 @@ "label": "array_15", "inputBase64": "n6NhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdsDAwMDA", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "metadata_15_entries", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiPomswoXaiazGhdqJrMqF2omszoXaiazShdqJrNaF2oms2oXaiazehdqJrOKF2oms5oXajazEwoXajazExoXajazEyoXajazEzoXajazE0oXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiPomswoXaiazGhdqJrMqF2omszoXaiazShdqJrNaF2oms2oXaiazehdqJrOKF2oms5oXajazEwoXajazExoXajazEyoXajazEzoXajazE0oXY=", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4j6JrMKF2omsxoXaiazKhdqJrM6F2oms0oXaiazWhdqJrNqF2oms3oXaiazihdqJrOaF2o2sxMKF2o2sxMaF2o2sxMqF2o2sxM6F2o2sxNKF2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { @@ -1698,147 +1726,147 @@ "label": "width_u8_200", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NczIr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAzMiha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NczIr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAzMiha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NczIr2FwcGxpY2F0aW9uL3gtdKF0kZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAzMiha6CRoXiBoWuhdg==", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_u8_200_offset", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0Aya9hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0AyKFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NczIAaFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NczJr2FwcGxpY2F0aW9uL3gtdKF0kpTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAzMiha5TZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTXMyAGha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NczJr2FwcGxpY2F0aW9uL3gtdKF0kpXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAzMiha6CV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1zMgBoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_u16_258", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAq9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAq9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAq9hcHBsaWNhdGlvbi94LXShdJGV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_u16_258_offset", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAgGha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAgGha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFroJXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTXNAQIBoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_u16_40000", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc2cQK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM2cQKFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc2cQK9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM2cQKFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc2cQK9hcHBsaWNhdGlvbi94LXShdJGV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM2cQKFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_u16_40000_offset", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc2cQa9hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM2cQKFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc2cQAGha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc2cQa9hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM2cQKFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc2cQAGha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc2cQa9hcHBsaWNhdGlvbi94LXShdJKV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM2cQKFroJXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTXNnEABoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_u32_258", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc4AAAECr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAzgAAAQKha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAq9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAq9hcHBsaWNhdGlvbi94LXShdJGV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_u32_258_offset", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc4AAAECAaFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAgGha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFroJXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTXNAQIBoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_u64_258", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc8AAAAAAAABAq9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM8AAAAAAAABAqFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAq9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAq9hcHBsaWNhdGlvbi94LXShdJGV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_u64_258_offset", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc8AAAAAAAABAgGha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAgGha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFroJXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTXNAQIBoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_i8_100", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdBkr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUA0GSha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NWSvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQBkoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NWSvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQBkoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_i8_100_offset", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0AZa9hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0AZKFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdBkAaFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NWWvYXBwbGljYXRpb24veC10oXSSlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQBkoWuU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1ZAGha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NWWvYXBwbGljYXRpb24veC10oXSSldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQBkoWugldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NWQBoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_i16_258", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdEBAq9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1ANEBAqFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAq9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAq9hcHBsaWNhdGlvbi94LXShdJGV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_i16_258_offset", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdEBAgGha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAgGha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFroJXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTXNAQIBoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_i32_258", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdIAAAECr2FwcGxpY2F0aW9uL3gtdKF0kZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUA0gAAAQKha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAq9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAq9hcHBsaWNhdGlvbi94LXShdJGV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_i32_258_offset", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdIAAAECAaFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAgGha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFroJXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTXNAQIBoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_i64_258", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdMAAAAAAAABAq9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1ANMAAAAAAAABAqFrkaF4gaFroXY=", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAq9hcHBsaWNhdGlvbi94LXShdJGU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrkaF4gaFroXY=", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAq9hcHBsaWNhdGlvbi94LXShdJGV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_i64_258_offset", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NdMAAAAAAAABAgGha5GheIGha6F2", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BAgGha5GheIGha6F2", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0BA69hcHBsaWNhdGlvbi94LXShdJKV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0BAqFroJXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTXNAQIBoWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_fixint_127", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NX\u002BvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQB/oWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NX\u002BvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQB/oWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NX\u002BvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQB/oWugkaF4gaFroXY=", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_fixint_127_offset", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc0AgK9hcHBsaWNhdGlvbi94LXShdJKU2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AM0Af6FrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NX8BoWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NcyAr2FwcGxpY2F0aW9uL3gtdKF0kpTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAf6FrlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NX8BoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NcyAr2FwcGxpY2F0aW9uL3gtdKF0kpXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAf6FroJXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTV/AaFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { "label": "width_u32_over_int32", "inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc6y0F4Ar2FwcGxpY2F0aW9uL3gtdKF0kpTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAzn////\u002Bha5TZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTXOf////84y0F4BoWuRoXiBoWuhdg==", "accepted": true, - "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc6y0F4Ar2FwcGxpY2F0aW9uL3gtdKF0kpTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAzn////\u002Bha5TZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTXOf////84y0F4BoWuRoXiBoWuhdg==", + "reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1Nc6y0F4Ar2FwcGxpY2F0aW9uL3gtdKF0kpXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAzn////\u002Bha6CV2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1zn/////OMtBeAaFroJGheIGha6F2", "recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }, { diff --git a/contracts/cultmesh/content-vectors.cs-written.json b/contracts/cultmesh/content-vectors.cs-written.json index 0eaaaece..26444761 100644 --- a/contracts/cultmesh/content-vectors.cs-written.json +++ b/contracts/cultmesh/content-vectors.cs-written.json @@ -168,22 +168,22 @@ { "label": "manifest", "kind": "manifest", - "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQgA==" + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaV2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJloTCV2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NqYyNjIxNDSV2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZqY1MjQyODiV2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY6Y3ODY0MzKV2UAyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAyzgAQAADOAAQAANlPbWVzaDpjZG46Y2h1bms6MjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMqcxMDQ4NTc2ldlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y84AFAAAzRLf2U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjpzEzMTA3MjCQgA==" }, { "label": "manifest_out_of_order", "kind": "manifest", - "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY5TZQDMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmUAzgAEAADZT21lc2g6Y2RuOmNodW5rOjMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmWU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOU2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NpTZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAylNlAYzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZs4ACAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmM3ZjFiZGU5ZTFlZmE3ZDEzODVkNTFiMDc3ZWE4N2IwYzM4NjBkMjhkYTQzZDVkNmYyMjFlODQ1YzRjMjM2MGaQgA==" + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaV2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY6Y3ODY0MzKV2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJloTCV2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOnMTMxMDcyMJXZQGUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzbOAAQAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazplMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2pjI2MjE0NJXZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAypzEwNDg1NzaV2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZqY1MjQyODiQgA==" }, { "label": "manifest_metadata_order", "kind": "manifest", - "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQg6R6ZXRhoTGlYWxwaGGhMqNtaWShMw==" + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaV2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJloTCV2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NqYyNjIxNDSV2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZqY1MjQyODiV2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY6Y3ODY0MzKV2UAyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAyzgAQAADOAAQAANlPbWVzaDpjZG46Y2h1bms6MjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMqcxMDQ4NTc2ldlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y84AFAAAzRLf2U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjpzEzMTA3MjCQg6R6ZXRhoTGlYWxwaGGhMqNtaWShMw==" }, { "label": "manifest_empty_body", "kind": "manifest", - "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQC8YXBwbGljYXRpb24veC1jdWx0bmV0LXZlY3RvctkhMjAyNi0wOS0yMlQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAANlPbWVzaDpjZG46Y2h1bms6ZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NZCA" + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQC8YXBwbGljYXRpb24veC1jdWx0bmV0LXZlY3RvctkhMjAyNi0wOS0yMlQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAANlPbWVzaDpjZG46Y2h1bms6ZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NaEwkIA=" } ] -} +} \ No newline at end of file diff --git a/contracts/cultmesh/content-vectors.rs-written.json b/contracts/cultmesh/content-vectors.rs-written.json index 38538e2b..fbbe51ea 100644 --- a/contracts/cultmesh/content-vectors.rs-written.json +++ b/contracts/cultmesh/content-vectors.rs-written.json @@ -138,22 +138,22 @@ { "label": "manifest", "kind": "manifest", - "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQgA==" + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaV2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJloTCV2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NqYyNjIxNDSV2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZqY1MjQyODiV2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY6Y3ODY0MzKV2UAyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAyzgAQAADOAAQAANlPbWVzaDpjZG46Y2h1bms6MjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMqcxMDQ4NTc2ldlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y84AFAAAzRLf2U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjpzEzMTA3MjCQgA==" }, { "label": "manifest_out_of_order", "kind": "manifest", - "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY5TZQDMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmUAzgAEAADZT21lc2g6Y2RuOmNodW5rOjMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmWU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOU2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NpTZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAylNlAYzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZs4ACAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmM3ZjFiZGU5ZTFlZmE3ZDEzODVkNTFiMDc3ZWE4N2IwYzM4NjBkMjhkYTQzZDVkNmYyMjFlODQ1YzRjMjM2MGaQgA==" + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaV2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY6Y3ODY0MzKV2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJloTCV2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOnMTMxMDcyMJXZQGUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzbOAAQAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazplMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2pjI2MjE0NJXZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAypzEwNDg1NzaV2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZqY1MjQyODiQgA==" }, { "label": "manifest_metadata_order", "kind": "manifest", - "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQg6R6ZXRhoTGlYWxwaGGhMqNtaWShMw==" + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaV2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJloTCV2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NqYyNjIxNDSV2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZqY1MjQyODiV2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY6Y3ODY0MzKV2UAyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAyzgAQAADOAAQAANlPbWVzaDpjZG46Y2h1bms6MjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMqcxMDQ4NTc2ldlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y84AFAAAzRLf2U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjpzEzMTA3MjCQg6R6ZXRhoTGlYWxwaGGhMqNtaWShMw==" }, { "label": "manifest_empty_body", "kind": "manifest", - "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQC8YXBwbGljYXRpb24veC1jdWx0bmV0LXZlY3RvctkhMjAyNi0wOS0yMlQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAANlPbWVzaDpjZG46Y2h1bms6ZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NZCA" + "messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQC8YXBwbGljYXRpb24veC1jdWx0bmV0LXZlY3RvctkhMjAyNi0wOS0yMlQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAANlPbWVzaDpjZG46Y2h1bms6ZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NaEwkIA=" } ] -} +} \ No newline at end of file diff --git a/contracts/cultmesh/content-vectors.ts-written.json b/contracts/cultmesh/content-vectors.ts-written.json index be9e1549..4efd8cc3 100644 --- a/contracts/cultmesh/content-vectors.ts-written.json +++ b/contracts/cultmesh/content-vectors.ts-written.json @@ -35,7 +35,7 @@ } }, "recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:unversioned:cf8a453a36981321977355448d24df49bcefee22be6e3730b9140e87c29013f9", - "messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBjZjhhNDUzYTM2OTgxMzIxOTc3MzU1NDQ4ZDI0ZGY0OWJjZWZlZTIyYmU2ZTM3MzBiOTE0MGU4N2MyOTAxM2Y5zgAL3jG/YXBwbGljYXRpb24veC1jdWx0bmV0LXRzLXZlY3RvctkhMjAyNi0xMC0xOVQwMDowMDowMC4wMDAwMDAwKzAwOjAwlpTZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDgAzgACAADZT21lc2g6Y2RuOmNodW5rOjM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDiU2UAzNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4zgACAADOAAIAANlPbWVzaDpjZG46Y2h1bms6Mzc3YzdiMGNjMjI5ZjQ3MDU2NzBkNjNkNTQ0YzI3NWI2NjNkZjZlOGM4MzFiMDk4NDZhNWM0OGNjYTA4Yjg0OJTZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAQAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4lNlAMzc3YzdiMGNjMjI5ZjQ3MDU2NzBkNjNkNTQ0YzI3NWI2NjNkZjZlOGM4MzFiMDk4NDZhNWM0OGNjYTA4Yjg0OM4ABgAAzgACAADZT21lc2g6Y2RuOmNodW5rOjM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDiU2UAzNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4zgAIAADOAAIAANlPbWVzaDpjZG46Y2h1bms6Mzc3YzdiMGNjMjI5ZjQ3MDU2NzBkNjNkNTQ0YzI3NWI2NjNkZjZlOGM4MzFiMDk4NDZhNWM0OGNjYTA4Yjg0OJTZQGEwYmI2OWY0NDg2MWViM2RiNTYwMjZhMWFiYmY5MWZmYTYyMTMyNTA4ZDVhMDZlMGQwZGRjNWNjMjZiZTMzZmPOAAoAAM4AAd4x2U9tZXNoOmNkbjpjaHVuazphMGJiNjlmNDQ4NjFlYjNkYjU2MDI2YTFhYmJmOTFmZmE2MjEzMjUwOGQ1YTA2ZTBkMGRkYzVjYzI2YmUzM2ZjkqFhoWKDojEwoXilYWxwaGGheaEyoXo=" + "messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBjZjhhNDUzYTM2OTgxMzIxOTc3MzU1NDQ4ZDI0ZGY0OWJjZWZlZTIyYmU2ZTM3MzBiOTE0MGU4N2MyOTAxM2Y5zgAL3jG/YXBwbGljYXRpb24veC1jdWx0bmV0LXRzLXZlY3RvctkhMjAyNi0xMC0xOVQwMDowMDowMC4wMDAwMDAwKzAwOjAwlpXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDgAzgACAADZT21lc2g6Y2RuOmNodW5rOjM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDihMJXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAIAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4pjEzMTA3MpXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAQAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4pjI2MjE0NJXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAYAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4pjM5MzIxNpXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAgAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4pjUyNDI4OJXZQGEwYmI2OWY0NDg2MWViM2RiNTYwMjZhMWFiYmY5MWZmYTYyMTMyNTA4ZDVhMDZlMGQwZGRjNWNjMjZiZTMzZmPOAAoAAM4AAd4x2U9tZXNoOmNkbjpjaHVuazphMGJiNjlmNDQ4NjFlYjNkYjU2MDI2YTFhYmJmOTFmZmE2MjEzMjUwOGQ1YTA2ZTBkMGRkYzVjYzI2YmUzM2ZjpjY1NTM2MJKhYaFig6IxMKF4pWFscGhhoXmhMqF6" }, { "label": "ts empty body", @@ -72,7 +72,7 @@ } }, "recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AL9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAA2U9tZXNoOmNkbjpjaHVuazplM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1kqFhoWKDojEwoXilYWxwaGGheaEyoXo=" + "messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AL9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAA2U9tZXNoOmNkbjpjaHVuazplM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1oTCSoWGhYoOiMTCheKVhbHBoYaF5oTKheg==" }, { "label": "ts single chunk, versioned", @@ -93,7 +93,7 @@ } }, "recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:v1-beta:89f4ff56a25dd1db06a4ce6033603775d705fb96f30f8693733fef602a1ca532", - "messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSnVjEgQmV0YdlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMs0D6L9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRlNlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMgDNA+jZT21lc2g6Y2RuOmNodW5rOjg5ZjRmZjU2YTI1ZGQxZGIwNmE0Y2U2MDMzNjAzNzc1ZDcwNWZiOTZmMzBmODY5MzczM2ZlZjYwMmExY2E1MzKQgA==" + "messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSnVjEgQmV0YdlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMs0D6L9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRldlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMgDNA+jZT21lc2g6Y2RuOmNodW5rOjg5ZjRmZjU2YTI1ZGQxZGIwNmE0Y2U2MDMzNjAzNzc1ZDcwNWZiOTZmMzBmODY5MzczM2ZlZjYwMmExY2E1MzKhMJCA" }, { "label": "ts whitespace version, padded kind and mime", @@ -114,7 +114,7 @@ } }, "recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:unversioned:89f4ff56a25dd1db06a4ce6033603775d705fb96f30f8693733fef602a1ca532", - "messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOnIGJ1aWxkIKIgINlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMs0D6L0gIHRleHQvcGxhaW47IGNoYXJzZXQ9dXRmLTggINkhMjAyNi0xMC0xOVQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZTZQDg5ZjRmZjU2YTI1ZGQxZGIwNmE0Y2U2MDMzNjAzNzc1ZDcwNWZiOTZmMzBmODY5MzczM2ZlZjYwMmExY2E1MzIAzQPo2U9tZXNoOmNkbjpjaHVuazo4OWY0ZmY1NmEyNWRkMWRiMDZhNGNlNjAzMzYwMzc3NWQ3MDVmYjk2ZjMwZjg2OTM3MzNmZWY2MDJhMWNhNTMykIA=" + "messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOnIGJ1aWxkIKIgINlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMs0D6L0gIHRleHQvcGxhaW47IGNoYXJzZXQ9dXRmLTggINkhMjAyNi0xMC0xOVQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZXZQDg5ZjRmZjU2YTI1ZGQxZGIwNmE0Y2U2MDMzNjAzNzc1ZDcwNWZiOTZmMzBmODY5MzczM2ZlZjYwMmExY2E1MzIAzQPo2U9tZXNoOmNkbjpjaHVuazo4OWY0ZmY1NmEyNWRkMWRiMDZhNGNlNjAzMzYwMzc3NWQ3MDVmYjk2ZjMwZjg2OTM3MzNmZWY2MDJhMWNhNTMyoTCQgA==" } ] } diff --git a/contracts/cultnet/cultnet-distributed-database.md b/contracts/cultnet/cultnet-distributed-database.md index 66bb450e..cc6a6a57 100644 --- a/contracts/cultnet/cultnet-distributed-database.md +++ b/contracts/cultnet/cultnet-distributed-database.md @@ -111,8 +111,10 @@ Not implemented yet: not guess. Adding a code is therefore a coordinated change across every runtime, made together: the schema (both copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and a captured-bytes case in `packages/cultnet-rs/tests/error_message.rs` for it. TypeScript reads the schema. The current codes are -`selection_invalid`, `cursor_stale`, `cursor_invalid`, `reference_outside_target`, `variant_unsupported` and -`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns). +`selection_invalid`, `cursor_stale`, `cursor_invalid`, `reference_outside_target`, `variant_unsupported`, +`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns), +`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant) and +`unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value). ## Live Invariants @@ -122,6 +124,9 @@ copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and - One primary owns writes for a shard until a stronger policy exists. - Non-owner writes are rejected or explicitly forwarded. They are not silently applied. +- A peer's put or delete is applied or forwarded only when the server's + `RemoteWriteAuthority` grants it; none configured refuses every remote write. + The connection key admits a peer to the transport and is never a grant. - Raw snapshot/put/delete messages must pass through `CultNetDatabase`, not bypass shard policy. - The accepted shard log is stored in replica catch-up wire form. Object logs diff --git a/contracts/cultnet/cultnet.error.schema.json b/contracts/cultnet/cultnet.error.schema.json index 59764338..341de5af 100644 --- a/contracts/cultnet/cultnet.error.schema.json +++ b/contracts/cultnet/cultnet.error.schema.json @@ -28,7 +28,7 @@ "code": { "description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).", "type": ["string", "null"], - "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema"] + "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version"] }, "details": { "description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.", @@ -36,8 +36,8 @@ "additionalProperties": false, "required": ["field", "value", "asOf", "current"], "properties": { - "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\".", "type": ["string", "null"] }, - "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns.", "type": ["string", "null"] }, + "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\".", "type": ["string", "null"] }, + "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] }, "asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 }, "current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 } } diff --git a/contracts/cultnet/cultnet.witness-artifact-bundle.schema.json b/contracts/cultnet/cultnet.witness-artifact-bundle.schema.json index 6dd6971d..936a0a04 100644 --- a/contracts/cultnet/cultnet.witness-artifact-bundle.schema.json +++ b/contracts/cultnet/cultnet.witness-artifact-bundle.schema.json @@ -59,6 +59,7 @@ ], "additionalProperties": false, "properties": { + "id": { "type": "string", "minLength": 1 }, "role": { "type": "string", "minLength": 1 }, "schemaId": { "type": "string", "minLength": 1 }, "schemaVersion": { "type": "string", "minLength": 1 }, @@ -74,6 +75,7 @@ ], "additionalProperties": false, "properties": { + "id": { "type": "string", "minLength": 1 }, "role": { "type": "string", "minLength": 1 }, "uri": { "type": "string", "minLength": 1 }, "mediaType": { "type": "string", "minLength": 1 }, @@ -92,6 +94,7 @@ ], "additionalProperties": false, "properties": { + "id": { "type": "string", "minLength": 1 }, "stage": { "type": "string", "minLength": 1 }, "startedAt": { "type": "string", "minLength": 1 }, "completedAt": { "type": "string", "minLength": 1 }, diff --git a/contracts/cultnet/interop/carrier-vectors.cs-written.json b/contracts/cultnet/interop/carrier-vectors.cs-written.json new file mode 100644 index 00000000..394724cf --- /dev/null +++ b/contracts/cultnet/interop/carrier-vectors.cs-written.json @@ -0,0 +1,346 @@ +{ + "vectors": [ + { + "name": "snapshot_request_v1_defaults", + "schemaVersion": "cultnet.snapshot_request.v1", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWShbalzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywKdzaGFyZElkwKpzaGFyZEVwb2NowA==", + "expected": { + "schemaVersion": "cultnet.snapshot_request.v1", + "messageId": "m", + "selection": { + "schemas": null, + "keys": null, + "fields": null, + "cites": null, + "cited": null, + "projection": "header", + "descending": false, + "limit": null, + "cursor": null + }, + "shardId": null, + "shardEpoch": null + } + }, + { + "name": "snapshot_request_v1_full_selection_and_shard", + "schemaVersion": "cultnet.snapshot_request.v1", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWSmbS1mdWxsqXNlbGVjdGlvbomnc2NoZW1hc5KpbGVhZl9hLnY52UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqRrZXlzkqRhLWVxqPCfmIAtcm93pmZpZWxkc5KEpWluZGV4pGtpbmSib3CmYW55X29mpnZhbHVlc5Kmd2VhcG9upHRvb2ymbnVtYmVywISlaW5kZXikbWFzc6JvcKJnZaZ2YWx1ZXPApm51bWJlcqstMjIzMzc1OS4yNaVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26lY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqhkb2N1bWVudKpkZXNjZW5kaW5nw6VsaW1pdMzIpmN1cnNvcq1vcGFxdWUtY3Vyc29yp3NoYXJkSWSnc2hhcmQtMapzaGFyZEVwb2NoBw==", + "expected": { + "schemaVersion": "cultnet.snapshot_request.v1", + "messageId": "m-full", + "selection": { + "schemas": [ + "leaf_a.v9", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff" + ], + "keys": [ + "a-eq", + "\uD83D\uDE00-row" + ], + "fields": [ + { + "index": "kind", + "op": "any_of", + "values": [ + "weapon", + "tool" + ], + "number": null + }, + { + "index": "mass", + "op": "ge", + "values": null, + "number": "-2233759.25" + } + ], + "cites": { + "target": { + "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", + "recordKey": "a-eq" + }, + "role": "Design" + }, + "cited": { + "role": "Design", + "exists": false + }, + "projection": "document", + "descending": true, + "limit": 200, + "cursor": "opaque-cursor" + }, + "shardId": "shard-1", + "shardEpoch": 7 + } + }, + { + "name": "database_subscribe_v1_defaults", + "schemaVersion": "cultnet.database_subscribe.v1", + "messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKFtrnN1YnNjcmlwdGlvbklko3N1YqlzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywK9pbmNsdWRlU25hcHNob3TDsWNvbnN1bWVyUnVudGltZUlkwKdib2R5SWRzwLdzdXBwb3J0ZWRCb2R5VHJhbnNwb3J0c8A=", + "expected": { + "schemaVersion": "cultnet.database_subscribe.v1", + "messageId": "m", + "subscriptionId": "sub", + "selection": { + "schemas": null, + "keys": null, + "fields": null, + "cites": null, + "cited": null, + "projection": "header", + "descending": false, + "limit": null, + "cursor": null + }, + "includeSnapshot": true, + "consumerRuntimeId": null, + "bodyIds": null, + "supportedBodyTransports": null + } + }, + { + "name": "database_subscribe_v1_full", + "schemaVersion": "cultnet.database_subscribe.v1", + "messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKZtLWZ1bGyuc3Vic2NyaXB0aW9uSWSoc3ViLWZ1bGypc2VsZWN0aW9uiadzY2hlbWFzkqlsZWFmX2EudjnZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmpGtleXOSpGEtZXGo8J\u002BYgC1yb3emZmllbGRzkoSlaW5kZXika2luZKJvcKZhbnlfb2amdmFsdWVzkqZ3ZWFwb26kdG9vbKZudW1iZXLAhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyqy0yMjMzNzU5LjI1pWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqVjaXRlZIKkcm9sZaZEZXNpZ26mZXhpc3Rzwqpwcm9qZWN0aW9uqGRvY3VtZW50qmRlc2NlbmRpbmfDpWxpbWl0zMimY3Vyc29yrW9wYXF1ZS1jdXJzb3KvaW5jbHVkZVNuYXBzaG90wrFjb25zdW1lclJ1bnRpbWVJZKhjb25zdW1lcqdib2R5SWRzkaZib2R5LWG3c3VwcG9ydGVkQm9keVRyYW5zcG9ydHOSpHJ1ZHCkcXVpYw==", + "expected": { + "schemaVersion": "cultnet.database_subscribe.v1", + "messageId": "m-full", + "subscriptionId": "sub-full", + "selection": { + "schemas": [ + "leaf_a.v9", + "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff" + ], + "keys": [ + "a-eq", + "\uD83D\uDE00-row" + ], + "fields": [ + { + "index": "kind", + "op": "any_of", + "values": [ + "weapon", + "tool" + ], + "number": null + }, + { + "index": "mass", + "op": "ge", + "values": null, + "number": "-2233759.25" + } + ], + "cites": { + "target": { + "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", + "recordKey": "a-eq" + }, + "role": "Design" + }, + "cited": { + "role": "Design", + "exists": false + }, + "projection": "document", + "descending": true, + "limit": 200, + "cursor": "opaque-cursor" + }, + "includeSnapshot": false, + "consumerRuntimeId": "consumer", + "bodyIds": [ + "body-a" + ], + "supportedBodyTransports": [ + "rudp", + "quic" + ] + } + }, + { + "name": "snapshot_response_raw_v1_headers", + "schemaVersion": "cultnet.snapshot_response_raw.v1", + "messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAqRhc09mBaRuZXh0q25leHQtY3Vyc29yp2hlYWRlcnOSiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1lcahzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3PAiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1sb6hzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3OSpWFscGhhpGJldGGpZG9jdW1lbnRzwKVlZGdlc8Cnc2hhcmRJZKdzaGFyZC0xqnNoYXJkRXBvY2gDsHNoYXJkTG9nU2VxdWVuY2Up", + "expected": { + "schemaVersion": "cultnet.snapshot_response_raw.v1", + "messageId": "m", + "matched": 2, + "asOf": 5, + "next": "next-cursor", + "headers": [ + { + "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", + "schemaName": "leaf_a", + "schemaVersion": "1", + "schemaContentHash": "abc", + "recordKey": "a-eq", + "storedAt": "1970-01-01T00:00:00.000Z", + "sourceRuntimeId": "rt", + "sourceAgentId": "agent", + "sourceRole": "role", + "tags": null + }, + { + "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", + "schemaName": "leaf_a", + "schemaVersion": "1", + "schemaContentHash": "abc", + "recordKey": "a-lo", + "storedAt": "1970-01-01T00:00:00.000Z", + "sourceRuntimeId": "rt", + "sourceAgentId": "agent", + "sourceRole": "role", + "tags": [ + "alpha", + "beta" + ] + } + ], + "documents": null, + "edges": null, + "shardId": "shard-1", + "shardEpoch": 3, + "shardLogSequence": 41 + } + }, + { + "name": "snapshot_response_raw_v1_documents_and_edges", + "schemaVersion": "cultnet.snapshot_response_raw.v1", + "messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAaRhc09mBaRuZXh0wKdoZWFkZXJzwKlkb2N1bWVudHORjKhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZcCtc2NoZW1hVmVyc2lvbsCxc2NoZW1hQ29udGVudEhhc2jAqXJlY29yZEtleaRhLWVxqHN0b3JlZEF0uDE5NzAtMDEtMDFUMDA6MDA6MDAuMDAwWq9wYXlsb2FkRW5jb2RpbmerbWVzc2FnZXBhY2uncGF5bG9hZMQEgaFrAa9zb3VyY2VSdW50aW1lSWTArXNvdXJjZUFnZW50SWTAqnNvdXJjZVJvbGXApHRhZ3PApWVkZ2VzkoWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nwKdwYXlsb2FkwIWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nq21lc3NhZ2VwYWNrp3BheWxvYWTEAwECA6dzaGFyZElkwKpzaGFyZEVwb2NowLBzaGFyZExvZ1NlcXVlbmNlwA==", + "expected": { + "schemaVersion": "cultnet.snapshot_response_raw.v1", + "messageId": "m", + "matched": 1, + "asOf": 5, + "next": null, + "headers": null, + "documents": [ + { + "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", + "schemaName": null, + "schemaVersion": null, + "schemaContentHash": null, + "recordKey": "a-eq", + "storedAt": "1970-01-01T00:00:00.000Z", + "payloadEncoding": "messagepack", + "payload": "gaFrAQ==", + "sourceRuntimeId": null, + "sourceAgentId": null, + "sourceRole": null, + "tags": null + } + ], + "edges": [ + { + "from": { + "schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc", + "recordKey": "citer-1" + }, + "role": "Design", + "to": { + "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", + "recordKey": "a-eq" + }, + "payloadEncoding": null, + "payload": null + }, + { + "from": { + "schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc", + "recordKey": "citer-1" + }, + "role": "Design", + "to": { + "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", + "recordKey": "a-eq" + }, + "payloadEncoding": "messagepack", + "payload": "AQID" + } + ], + "shardId": null, + "shardEpoch": null, + "shardLogSequence": null + } + }, + { + "name": "error_plain", + "schemaVersion": "cultnet.error.v0", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KkYm9vbatyb3V0aW5nSGludMCkY29kZcCnZGV0YWlsc8A=", + "expected": { + "schemaVersion": "cultnet.error.v0", + "error": "boom", + "routingHint": null, + "code": null, + "details": null + } + }, + { + "name": "error_selection_invalid", + "schemaVersion": "cultnet.error.v0", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3LZOnNlbGVjdGlvbl9pbnZhbGlkOiBmaWVsZHNbMF0uaW5kZXggImtpbmQiIGlzIG5vdCBkZWNsYXJlZC6rcm91dGluZ0hpbnTApGNvZGWxc2VsZWN0aW9uX2ludmFsaWSnZGV0YWlsc4SlZmllbGSvZmllbGRzWzBdLmluZGV4pXZhbHVlpGtpbmSkYXNPZsCnY3VycmVudMA=", + "expected": { + "schemaVersion": "cultnet.error.v0", + "error": "selection_invalid: fields[0].index \u0022kind\u0022 is not declared.", + "routingHint": null, + "code": "selection_invalid", + "details": { + "field": "fields[0].index", + "value": "kind", + "asOf": null, + "current": null + } + } + }, + { + "name": "error_cursor_stale", + "schemaVersion": "cultnet.error.v0", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX3N0YWxlOiBzdGFsZatyb3V0aW5nSGludMCkY29kZaxjdXJzb3Jfc3RhbGWnZGV0YWlsc4SlZmllbGTApXZhbHVlwKRhc09mA6djdXJyZW50CQ==", + "expected": { + "schemaVersion": "cultnet.error.v0", + "error": "cursor_stale: stale", + "routingHint": null, + "code": "cursor_stale", + "details": { + "field": null, + "value": null, + "asOf": 3, + "current": 9 + } + } + }, + { + "name": "error_cursor_invalid_no_details", + "schemaVersion": "cultnet.error.v0", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX2ludmFsaWQ6IGJhZKtyb3V0aW5nSGludMCkY29kZa5jdXJzb3JfaW52YWxpZKdkZXRhaWxzwA==", + "expected": { + "schemaVersion": "cultnet.error.v0", + "error": "cursor_invalid: bad", + "routingHint": null, + "code": "cursor_invalid", + "details": null + } + }, + { + "name": "error_write_not_authorized", + "schemaVersion": "cultnet.error.v0", + "messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3K0d3JpdGVfbm90X2F1dGhvcml6ZWSrcm91dGluZ0hpbnTApGNvZGW0d3JpdGVfbm90X2F1dGhvcml6ZWSnZGV0YWlsc4SlZmllbGSpcmVjb3JkS2V5pXZhbHVloWukYXNPZsCnY3VycmVudMA=", + "expected": { + "schemaVersion": "cultnet.error.v0", + "error": "write_not_authorized", + "routingHint": null, + "code": "write_not_authorized", + "details": { + "field": "recordKey", + "value": "k", + "asOf": null, + "current": null + } + } + } + ] +} \ No newline at end of file diff --git a/contracts/cultnet/interop/error-vectors.json b/contracts/cultnet/interop/error-vectors.json new file mode 100644 index 00000000..db5e79cd --- /dev/null +++ b/contracts/cultnet/interop/error-vectors.json @@ -0,0 +1,150 @@ +{ + "provenance": "Captured from MessagePack C# 3.1.7 (CultNetErrorMessage/CultNetErrorDetails, Options = Standard.WithSecurity(UntrustedData)); pinned byte-for-byte by packages/cultnet-rs/tests/error_message.rs, whose inline hex equals messagePackHex here (test error_vectors_file_matches_the_inline_csharp_captures). Rust encodes to these exact bytes, so C#-written and Rust-written bytes are one set. Every key is written on the wire, nil included.", + "vectors": [ + { + "name": "selection_invalid_schemas_null", + "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92473656C656374696F6E5F696E76616C69643A206D757374206E6F7420626520656D707479AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A7736368656D6173A576616C7565C0A461734F66C0A763757272656E74C0", + "message": { + "schemaVersion": "cultnet.error.v0", + "error": "selection_invalid: must not be empty", + "routingHint": null, + "code": "selection_invalid", + "details": { + "field": "schemas", + "value": null, + "asOf": null, + "current": null + } + } + }, + { + "name": "selection_invalid_field_value", + "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92273656C656374696F6E5F696E76616C69643A20626C616E6B206B657920656E747279AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A46B657973A576616C7565A3202020A461734F66C0A763757272656E74C0", + "message": { + "schemaVersion": "cultnet.error.v0", + "error": "selection_invalid: blank key entry", + "routingHint": null, + "code": "selection_invalid", + "details": { + "field": "keys", + "value": " ", + "asOf": null, + "current": null + } + } + }, + { + "name": "cursor_stale", + "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72BD637572736F725F7374616C653A20637572736F72206973207374616C65AB726F7574696E6748696E74C0A4636F6465AC637572736F725F7374616C65A764657461696C7384A56669656C64C0A576616C7565C0A461734F6601A763757272656E7402", + "message": { + "schemaVersion": "cultnet.error.v0", + "error": "cursor_stale: cursor is stale", + "routingHint": null, + "code": "cursor_stale", + "details": { + "field": null, + "value": null, + "asOf": 1, + "current": 2 + } + } + }, + { + "name": "cursor_invalid", + "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D923637572736F725F696E76616C69643A2063616E6E6F7420706172736520637572736F72AB726F7574696E6748696E74C0A4636F6465AE637572736F725F696E76616C6964A764657461696C73C0", + "message": { + "schemaVersion": "cultnet.error.v0", + "error": "cursor_invalid: cannot parse cursor", + "routingHint": null, + "code": "cursor_invalid", + "details": null + } + }, + { + "name": "reference_outside_target", + "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D9317265666572656E63655F6F7574736964655F7461726765743A206F757473696465206465636C6172656420746172676574AB726F7574696E6748696E74C0A4636F6465B87265666572656E63655F6F7574736964655F746172676574A764657461696C73C0", + "message": { + "schemaVersion": "cultnet.error.v0", + "error": "reference_outside_target: outside declared target", + "routingHint": null, + "code": "reference_outside_target", + "details": null + } + }, + { + "name": "variant_unsupported_subscription_ended", + "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C7384A56669656C64AE737562736372697074696F6E4964A576616C7565A57375622D31A461734F66C0A763757272656E74C0", + "message": { + "schemaVersion": "cultnet.error.v0", + "error": "variant unsupported here", + "routingHint": null, + "code": "variant_unsupported", + "details": { + "field": "subscriptionId", + "value": "sub-1", + "asOf": null, + "current": null + } + } + }, + { + "name": "variant_unsupported_no_subscription", + "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C73C0", + "message": { + "schemaVersion": "cultnet.error.v0", + "error": "variant unsupported here", + "routingHint": null, + "code": "variant_unsupported", + "details": null + } + }, + { + "name": "unowned_schema", + "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D95C756E6F776E65645F736368656D613A204E6F207368617264206F776E7320736368656D612027736F6D652E736368656D612E763127206B65792027736F6D653A6B6579273B207468652077726974652077617320726566757365642EAB726F7574696E6748696E74C0A4636F6465AE756E6F776E65645F736368656D61A764657461696C7384A56669656C64A8736368656D614964A576616C7565AE736F6D652E736368656D612E7631A461734F66C0A763757272656E74C0", + "message": { + "schemaVersion": "cultnet.error.v0", + "error": "unowned_schema: No shard owns schema 'some.schema.v1' key 'some:key'; the write was refused.", + "routingHint": null, + "code": "unowned_schema", + "details": { + "field": "schemaId", + "value": "some.schema.v1", + "asOf": null, + "current": null + } + } + }, + { + "name": "write_not_authorized", + "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0", + "message": { + "schemaVersion": "cultnet.error.v0", + "error": "write_not_authorized: not granted", + "routingHint": null, + "code": "write_not_authorized", + "details": { + "field": "recordKey", + "value": "some:key", + "asOf": null, + "current": null + } + } + }, + { + "name": "unsupported_schema_version", + "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0", + "message": { + "schemaVersion": "cultnet.error.v0", + "error": "unsupported_schema_version: cultnet.hello.v1", + "routingHint": null, + "code": "unsupported_schema_version", + "details": { + "field": "schemaVersion", + "value": "cultnet.hello.v1", + "asOf": null, + "current": null + } + } + } + ] +} diff --git a/docs/c2a-land-map.md b/docs/c2a-land-map.md new file mode 100644 index 00000000..3f31f789 --- /dev/null +++ b/docs/c2a-land-map.md @@ -0,0 +1,441 @@ +# C2a land map + +What C2a (`hands/variants-c2a`, a5c7954e) must still prove to merge to main, measured +on 2026-10-22 against main 7a8933b0 and the 2.0.0 release candidate +`hands/cultlib-unity-release-2.0.0` (5b5d8573, which carries f91e30ed). The land cut +is the merge plus the gate. Everything else is classified below. Governing ruling: +`cultlib-gaps:ruling:release-now-c2a-bumps-format`. + +## Cut sequence to main (2026-10-27, branch head 7c7cc799) + +Landed since the facts below: `land-c2a` (h1), `land-c2a-merge-api-docs` (h1-h6), the 2.0.1 and +cultcache-rs 0.3.1 merges (5baa02fa, f54472e5), `land-c2a-gate` r4 (h1), `store-gate-one-owner` +(h1-h7, Soul s6) and `restamp-on-write` (h1-h2, 8ee66714..7c7cc799, Soul s2 holds). One static `Owns` +decides ownership for cache and cacheless writers, and the High finding +`cut-land-c2a-gate.s1.reflist-record-read-only-at-head` is Fixed by 7c7cc799. origin/main is 73e684b1: +21 commits past the merge base 02cf04b5, all docs and research (15 files, +1879, none under src/, +tests/ or packages/, none on a path the branch changed). What stands between the branch and main, in +order: + +1. **claimant-one-owner** (spec r1 at 7c7cc799, Hands then Soul). Restamp's Soul s2 left four Lows + on the landing path. Which entry reads a persisted entry is chosen twice (`Claimant` at + CultCache.cs:703-750, `ClaimantEntry` at :797-799), and so a cacheless writer handed registry {A,B} + overwrites a record that a cache with {A,B} holds read-only (probe X4). The cut keeps one `Claimant` + precedence over a by-id/by-name lookup that the registry or a single entry supplies. A cacheless + writer with a registry then takes its claimant from that registry. The cut also pins the + unpublished-id case and fixes the stale test comment. It closes `claimant-selection-duplicated`, + `cacheless-inverts-registry-claimant`, `unpublished-id-guard-unpinned` and `stale-flag-copy-comment`. + It is kept apart from the gate so that Soul can falsify it alone. Its files (CultCache.cs, the + persistence contract, RestampOnWriteTests.cs) are disjoint from the gate's, so it can run before or + beside step 2. The second cut to land rebases. +2. **land-c2a-gate** (spec r7 at 7c7cc799, Hands h2 then Soul). The ref-list gate does the same write + at 2.0.1 and at head and compares schema ids. The committed vectors are pinned byte for byte under a + fixed clock, and every stage counts its cases. r7 drops r6's head-only restamp assertions, which + `RestampOnWriteTests.TheCommitted1060VectorsListRecordIsWrittenUnderTheHoldersOwnId` already pins. + It also drops the red-at-193cf1c3 run, because restamp is measured. The cut closes + `single-vector-hand-edit-passes`, `gate-green-with-no-refusal-probes` and follow-up + `gate-reads-1x-written-store`. +3. **Ledger** (Self, no Hands; can run now, beside steps 1 and 2). Each of these is in force though + its fix has landed: + - The seven `cut-land-c2a.s1.*` findings. `land-c2a-merge-api-docs` carried their pins (source reads + 2026-10-27: GameCult.Caching grants InternalsVisibleTo only to `.Tests` and `.MessagePack`, the + residual being follow-up `caching-messagepack-ivt`; PreCut2StoreFormatTests pins "with a suffix + of"). Close each one against its commit, or defer it. `stale-unity-plugin-dlls` defers to the + Unity 3.0.0 release cut (the plugins are 2.0.1 builds, 1eeb1a01). + - `cut-c2a-write-set.s1.untouched-dropped-slot-resurrects` and follow-up `slot-reuse-contract-doc`. + Ruling `slot-reuse-documented` decided the behaviour, and the paragraph is in + cultcache-schema-compatibility.md:64-66. + - `cut-restamp-on-write.s1.websockets-tests-unreported`, Fixed by h2 (s2 promise + `websockets-tests-reported` holds). + - Follow-up `gate-bound-test-tighten`, carried by restamp r2 (50362447, a768b821). Close it if + restamp's Soul passes measured it; otherwise route it. + - After steps 1 and 2 hold: the four s2 Lows, the two remaining `cut-land-c2a-gate.s1` Lows and + `gate-reads-1x-written-store`. +4. **Consumer pins** (ruling `unpinned-consumers-at-land-3`, follow-up `consumer-pins-before-land`). + Brokkr, Aquarium, Ymir, AquaSynth and Mimir have no `CultLibRevision`/`VerifyCultLibRevision` guard, + and each builds against the sibling `..\CultLib` checkout. In its own repo, each adopts Aetheria's + guard at the 2.0.0 tag, with one Hands per repo, before the merge reaches main. The pins touch no + CultLib file, so they can start now and run in parallel with steps 1-3. Eve and Gjallar get no pin + (ruled). The step is done when each repo's guard fails a build against a CultLib checkout at + another revision and passes at the tag, and the follow-up is resolved. +5. **Main into the branch, then full verification.** After steps 1, 2 and 4, `git merge origin/main` + into `hands/variants-c2a`. It should be clean, because main's commits since 02cf04b5 are docs and + research only. Re-check that before merging (`git diff --stat 02cf04b5 origin/main -- src tests + packages unity`). Then run every suite at the merged head on Yggdrasil: Caching, Mesh, Networking, + Geometry, WebSockets, ConsumerBuild, InteropPeer, cultcache-ts/-rs/-py, cultnet-ts and cultmesh-ts. + Run `old-reader-refusal.sh gate` for csharp, typescript, python and rust. If main gained code by then, + a Soul pass covers the merge. +6. **Land.** The land is `git merge --no-ff`, never a squash, so the SHAs that every report and verdict cites stay reachable from main (skip the branch's non-building commits with `git bisect --first-parent`). Steps 1 and 2 closed at 723b7996 (claimant-one-owner, land-c2a-gate); step 5 closed at the merge commit 3157d4b3 (Soul `cut-land-c2a-main.s1` holds). Step 3 closed except `cut-land-c2a.s1.stale-unity-plugin-dlls` and `cut-land-c2a-gate.s2.mutation-table-reads-gate-as-zero`, which wait on the 3.0.0 release cut and the Eureka tools respectively; step 4 closed with `consumer-pins-before-land` (pins since 2026-10-22). Run `git merge --no-ff hands/variants-c2a` onto main, with an empty diff against the + verified head. There is no tag: tags, the Unity plugin rebuild and `declared-range-gate-own-version` + belong to the 3.0.0 release cut. Open follow-ups `c2a-load-slower` (load 2 to 2.3 times slower than + main on a synthetic Aetheria catalog) and `drift-write-ownership-leaves` do not say "before land". + `c2a-load-slower` still needs a reason or a fix before the 3.0.0 release, under + `cultlib-gaps:ruling:database-shaped-built-for-load`. + +## Facts + +**1. The merge.** A trial `git merge origin/main` on a5c7954e gives 12 conflicted files and +33 hunks of about 950 marker lines (CultCache.cs 17 hunks/196; cult-cache.test.ts 337; +single-file-messagepack-backing-store.ts 4/106; rs lib.rs 2/51; VariantTests 51; three +CHANGELOG add/adds; store-composition contract 32; Directory store 14; InteropPeer 21). +Main moved 4.8k lines in the cache runtimes since base 1a12bc2d. Three semantic collisions +are not textual: + +- *Durable record identity has two owners.* Main added `CultStoredDocument.PersistedSchemaId` + and `CacheBackingStore.MarkWritten` (73d438db, afd799ba, 3f6dda8b). Conditional commit + compares the persisted schema id plus storedAt. C2a already owns this as + `StoredSchemaId` + `StoredVersion` (SHA-256 of the stored bytes, which names the schema + id and storedAt) set by `CultCache.Wrote`. After the merge, 9 references in + ConditionalCommitTests and the contract name main's owner. The version is the owner. + PersistedSchemaId and MarkWritten are deleted, and main's +309 test lines are ported as + pins against StoredVersion. 2.0.0 ships MarkWritten as protected API, and C2a also + removes `SerializeSnapshot`/`DeserializeSnapshot` overrides and changes `ConditionsHold`. + C2a is therefore a major release (3.0.0), which semver forces. +- *TS store commit moved.* Main's `store-file.ts` (0e6452c9, 0010ee92; 391 lines) now owns + commit, replace and the `.lock`. C2a's v3 read and keep-v3-on-rewrite + (`STORE_FORMAT_ELEMENT_IDS`) lives in the old store file and must move into store-file.ts. +- *Rust decode door.* Main's cultcache-rs 0.3.0 grown decoder (`decode_stack`, fc630d3b) + must sit under C2a's `readable_store_format` and v3 `decode_store_file`. + +**2. The gate already holds; it needs a test, not a format change.** C2a stamps +`cultcache.store.v3` (single file) or `...v5.directory-content-addressed-pages` exactly when +a record holds an id (`HeaderFor`, CultCache.cs:3543). It also marks when a copied record +decodes holding one (`CopiedHoldIds`, :4285). The 2.0 C# reader accepts only v1/v2 +(CultDocumentMessagePackSerialization.cs:229) and directory v4 (Directory store :452). +Main's TS, Rust and Python readers accept only v1 (store-format.ts `isV1Snapshot`, lib.rs:1274, +stores.py:192). Probe on Yggdrasil at 5b5d8573 plus C2a's `v3-base.msgpack` (job +`gate1.log`): 4/4 pass. The single file is refused naming v3, and a later flush leaves it +byte-identical. A v5-stamped directory store is refused naming v5, and its 4 files are +byte-identical after a later add and flush. **The pin:** C2a already ships +`tests/vectors/document-variants-c2a/old-reader-refusal.sh`, which stages an old revision +plus probes on a scratch ref and runs them per runtime. The land cut sets its +`OLD_READER_REV` default to the tag `cultlib-unity-v2.0.0`. It also adds the never-rewritten +assertion and the v5 directory case to the C# probe, and runs all four runtimes at the +tag. If the tag is absent, the land waits for it. The tag, not a package, is the pin: the tag's +tree holds every runtime's released reader. + +*Outside C2a's reach (release session):* in 2.0, `CultMesh.WriteSingleFileDocument`/ +`WriteSingleFileDocumentPayload` (CultMeshSingleFileDocuments.cs:211-245 at 7a8933b0) build +a fresh one-record v1 snapshot and replace the file without reading it. A 2.0 Mesh write +to a C2a v3 path therefore discards it whole, ids and other records included, and no header +gate can stop a writer that never reads. C2a's version reads under the lock and refuses. +Fix it in 2.0.0 before tagging, or record it. + +**5. Consumers.** Aetheria pins a CultLib revision (`VerifyCultLibRevision`) and Unity tags. +Ghostlight, Eve's Rust crates, Huginn and the Brokkr daemon pin cultcache-rs by git rev. +None of them moves when main moves. Two C# consumers build against an unpinned sibling +`..\CultLib` checkout: Eve `GameCult.Eve.Surface.csproj:16` and Brokkr's three csprojs. +Eve.Surface has `[MessagePackObject]` element arrays with no `[CultElementId]` +(`EveAssetCatalogEntry[]`, `EveEntitySoaBuffer[]`, `EveInputActionDocument[]`, +`IReadOnlyList`, 0 ids in the repo), so C2a registration refuses its +documents as soon as the checkout pulls the land. Grep finds no object lists in Brokkr's +`[CultDocument]` files (not build-verified). Removed C2a API (`Options`, `AsSchemaAlias`) is +unused by Eve and Brokkr. Fork: `variants:question:unpinned-consumers-at-land`. + +## Classification + +| Item | Class | Why | +|---|---|---| +| c2a-format-version-gate | **a** | The gate test above. No new header: v3/v5 already refuse. | +| slot-reuse-contract-doc | **a** | The ruled consequence of the write set C2a lands. One paragraph in cultcache-schema-compatibility.md, absent at a5c7954e (grep). | +| foreign-ids-under-v1-header | **b** | Obsoleted by fb065f0d (write-time `CopiedHoldIds`, after the finding) for every C# writer with the type. The residual (non-C# or opaque writers) is what `non-csharp-writers-mark-ids-2` ruled to carry, and the contract states it (persistence-format.md:434-439). The gate changes neither side, so no fork remains. Resolve as Superseded by that ruling. | +| custom-resolver-element-ids | **b** | Fixed by b091d21f (19:52Z, after the 18:27Z follow-up): `ListElementsClaimedByAResolverAreRefused`, `AResolverClaimingTheListTypeOfAnObjectListIsRefused`. s6 holds on the claim loop. | +| codec-door-proven-mismatch | **b** | Done in h6 57fdf70c (s6 `only-proven-mismatch` Holds; Geometry 76/0). | +| consumer-element-id-sweep-r2 | **c** | Each repo's sweep is its own work (q3). Only the unpinned trackers matter at land, and that is the question below. Aetheria bumps a pin on purpose (C4). | +| schema-discovery-holder-recogniser | **c** | `TryGetDictionaryRefTypes` is identical on main (CultCache.cs:1288). It describes record-ref dictionaries and is not on the id path. Changing it moves schema hashes, which is its own epoch event. | +| copy-check-memo | **c** | Performance only (about 3x flush on unmarked stores of 20k records). Correct as is. | +| string-key-field-and-shadow-pins | **c** | No wrong behaviour at 64ec14f9. These are test pins and may ride the land cut if Hands touches the file. | +| declared-range-gate-own-version, range-check-in-ci | **c** | The script exists only on C2a and runs in no CI. It guards a TS publish, not a merge. Fix both before the first TS release from main after the land. | +| s6 Unproven: Unity/IL2CPP identity, other MessagePack versions | **c** | The merge reaches no Unity build. Unity sees C2a only at the 3.0.0 tag, so measure it in the Unity release cut (operator Unity check, C3). | +| multi-store-commit-atomicity, store-replace-cut, reader-refuses-duplicate-keys, soft-flush-all-runtimes, writer-dangling-link-tolerance, unique-index-cut, py-shared-write-lock, rust-lock-open-waits, ts-lock-test-hardening, c0/legacy/directory vectors, culture-test, wire-id-cuts, remaining-variant-cuts, golden-path, paged store | **c** | None says "before land". Each is pre-existing on main or C2a-independent. None lets a released reader strip ids or corrupt a C2a store. | + +## The land cut's shape (transcribe once the question is ruled) + +- **Base.** Merge main at or after the `cultlib-unity-v2.0.0` tag into `hands/variants-c2a`. + Versions stay main's. CHANGELOGs get Unreleased entries, and the Unity entry says major. +- **Deletes and collapses.** Delete `PersistedSchemaId`, `PersistedIdentity`, + `CultStoredDocument.MarkWritten` and `CacheBackingStore.MarkWritten` (main, about 60 lines), + and the store-composition contract's MarkWritten paragraph. C2a's `StoredVersion`/`Wrote` + is the one owner. In TS, delete C2a's header handling in + single-file-messagepack-backing-store.ts and move it into store-file.ts. In Rust, C2a's v3 + decode goes through the grown decoder. +- **Adds.** Retarget old-reader-refusal.sh to the tag. Add the C# probe's never-rewritten + assertion and the v5 directory case (as in gate1.log). Add the slot-reuse paragraph. + Port main's ConditionalCommitTests to the version identity. +- **Verification.** Full suites: Caching, Mesh, Networking, Geometry, WebSockets, + ConsumerBuild, InteropPeer, cultcache-ts/-rs/-py, cultnet-ts and cultmesh-ts, all on + Yggdrasil. Run old-reader-refusal.sh for all four runtimes at the tag, and it must refuse. + Negative greps over the merged tree: `PersistedSchemaId|MarkWritten`, conflict markers. + Mutation: drop the v3/v5 stamp in `HeaderFor` and the gate probe must fail. +- **Estimate.** About 950 conflict lines resolved. Net -60 to -150 from the identity + collapse and TS de-duplication, +80 to +120 tests and docs. One Hands pass of 100-150 + calls, then one Soul pass. +- **Precondition (question).** Eve.Surface, and Brokkr if confirmed, are pinned or swept + before the merge reaches main. + +## Store gate: attachment is the attach's hold (store-gate-one-owner r2) + +Spec `variants:cut_spec:cut-store-gate-one-owner.r2` (base ec94ab98), question +`variants:question:pulled-variant-codec`. It closes Soul s3's +`attach-window-cacheless-write` (Medium) and `codec-loan-pinned-by-reflection` (Low). + +**Facts (source reads at ec94ab98 and a probe, 2026-10-25).** + +- The window. AddBackingStore holds the cache's gate, then the store's gate through + `InStoreHold`, lends the registry and codec, sets `Loaded`, adds the store to `_stores`, + pulls, and only then sets `store.Cache` (CultCache.cs 2268-2312). `InStoreHold` lets any + call on the same store's gate re-enter it (2825-2831). So a journal the pull's Admit runs + on the attaching thread can call `store.WriteDocument`: `Gate(cacheless)` guesses null, + re-enters the store's gate, finds `Cache == null` and runs the write, and its `finally` + calls `UseRegistry(null, null)` (Soul s3 P1, full1.log lines 49-53). Other threads are not + in the window: they queue on the store's gate and see either the published cache or a + store that the failed attach put back. +- The codec loan has no reader on the attach path. A pulled variant's codec is read only + by `CultStoredDocument.HoldsIds` (543-557). Header decisions read `HoldsIds` only on + staged entries (single-file 4318, 4447 and 4472; directory store 362), and a staged entry + is always the document the cache pushed (`Push` sets `Entries[key] = entry`, 4211-4215). + A single-file `PullAll` returns without loading while the store is dirty (4157-4159), so + a pulled copy is never staged. A refused write unstages its keys before it adopts the + reload. The directory store refuses variants (ruling `q6-directory-and-cultnet-refuse`; + DirectoryMessagePackBackingStore.cs 139-144 and 642-643). Admit re-resolves every pulled + variant with the cache's codec (CultCache.cs 3259). Soul's N4 mutant (the attach lends + no codec) was caught only by the reflection test, and h4's M2 only by a bare-store test + that sets `Loaded` and calls `UseRegistry` by hand. +- Probe (scratch clone `imagination-store-gate-r2/clone`, commits 4663934e and c099b9e8 on + ec94ab98; Yggdrasil t1.log, t2.log and t3.log; 2026-10-25 around 20:50 CEST). The + probe did three things. `InStoreHold` refuses a cacheless call that re-enters the same + store's gate. The `_codec` field and the two-argument `UseRegistry` are deleted. The pull + builds variants with a null codec. Results: + - Mesh is 342/0/4. + - In the Caching suite, the only failures were two probe tests, both from authoring + errors: a key `AddAsync` minted, and an element id that was not 12 hex characters. + No existing test failed, `UserCodeInACachelessCallMayCallItsOwnStore` included. + - Soul's P1 as a test: the journal's write is refused with the new message. The attach + keeps its registry and the file holds only `a`. After the attach, a cache write, a + flush and `store.PullAll()` all succeed. + - A cacheless write nested in another on the same store is refused, the outer write + fails, and no file is written. + - Through a real attach, a store holding a name-override variant flushes an unrelated + commit with a `cultcache.store.v2` header. With no codec loan, the pulled copy does not + mark the store. + - Mutant `return cacheless` changed to `return false`, which is ec94ab98's behaviour, + fails both new gate tests. In that mutant the nested write succeeds and the file exists. + +**Rationale.** + +- Attachment has one fact again. While the attach runs, the store's gate is the attach's + hold, and `Cache` is published when that hold ends. A cacheless write is a write the + store owns outright, so it must be the outermost hold of its store's gate. One more + argument to the existing owner of per-thread gate facts (`InStoreHold`) refuses it. No + field mirrors `Cache`, and `Loaded` and `_registry` are not read. Precedent: + `ConcurrentHashMap.computeIfAbsent` refuses a mapping function that updates the same + map with `IllegalStateException("Recursive update")` since Java 9. That is from + memory, not fetched this pass; the 2026-10-23 prior-art note covers the documented + restriction in its counter-positions. Non-cacheless store calls from user code still + re-enter: `UserCodeInACachelessCallMayCallItsOwnStore` stays green. +- The late publish stays. It is what bounds `Gate` at two passes (s2's finding). Publishing + early would bring back the failure-rate-bounded retry. +- The codec loan goes: it is a value nothing on the attach path reads. A pulled variant + carries no codec and counts as holding ids (the documented conservative answer), and + the cache's resolved copy is what any header decision sees. This reverses the codec + half of Defaulted ruling `late-publish-needs-attach-codec`, so it goes to Self as + `variants:question:pulled-variant-codec`. The spec is written for the recommended option. +- Scar check: `git log -S` over CultCache.cs for `reentrant`, `outermost` and `_attaching` + finds nothing. `Cache?.Codec` appears in 1468a83a (h4) and 619e089f only. No earlier + re-entry refusal was built and deleted. +- Behaviour change, stated in the CHANGELOG: a cacheless write made from user code that + another cacheless write of the same store runs used to succeed, and its `finally` took + the outer call's registry. It is now refused. + +## Store gate: Cache is the one attachment fact (store-gate-one-owner r3) + +Spec `variants:cut_spec:cut-store-gate-one-owner.r3` (base 0fa836cb), superseding r2. It +closes Soul s4's `attach-window-held-store-writes` (Medium), `cacheless-refusal-order-unpinned` +(Low) and `interop-consumer-suite-unrun` (Low). This is the third time the attach window has +beaten the rule (s3 from the cacheless side, s4 from the held side), so r3 moves the +decision instead of patching the next entry point. + +**Facts: every entry point that reads or decides attachment, at 0fa836cb.** Source reads of +CultCache.cs, DirectoryMessagePackBackingStore.cs and CultMeshSingleFileDocuments.cs. The +window column is a journal on the attaching thread, through a real attach of a store holding +`a`. Probe: scratch clone `imagination-store-gate-r3/clone`, commit b2330b6d (test R3Probes), +Yggdrasil `probe-base.log`, 2026-10-25 around 23:00 CEST. "Disagree" means that after the +attach, a commit of `later` and a flush, the attaching cache holds `a,later` and a fresh cache +over the same file reads something else. + +| Entry point (line) | Fact it reads | In the attach window at 0fa836cb | +| --- | --- | --- | +| Single-file `PullAll` (4156) | `Gate`: `Cache` guessed unlocked, then re-read under the re-entered store gate | Runs as detached; its `Loaded` re-enters the cache and is refused as a journal write. Agrees. | +| Single-file `Push` (4212) | same | Runs as detached and stages `b`. Disagree: fresh reads `a,b,later`. | +| Single-file `Delete` (4223) | same | Runs as detached and stages a removal the cache never made (dirty after the attach). | +| Single-file `PushAll` (4239), and `Dispose` (3722) through it | same, then in-body `Cache == null` (4531, 4543, 4588, 4603, 4611, 4661) | Runs as a cacheless writer. Push then PushAll writes `b` to the file inside the attach (disagree). Delete then PushAll is refused with the cacheless writer's text. On a variant store it is refused as "a writer with no CultCache" (Soul s4). | +| Single-file `CommitBatch` (4256) | same | Runs as detached and commits `b`. Disagree. | +| Single-file `JudgeChecks` (4568), `RefuseRemovalOfUndeclared` and `StageChecks` (4558, 3915) | same | Run as detached. Agree in the probe, but under the wrong gate's rules. | +| Single-file `ReadStoreSnapshot` (4687) | same | Runs as detached and reads the file. | +| `WriteRecord` (4342), `WriteDocument` (4366) | `InStoreHold`'s per-thread hold (cacheless re-entry), before `Cache` | Refused with the re-entry text (h5). Agree. | +| Directory `PullAll` (48) | `Gate`, as above | Refused as a journal write. Agree. | +| Directory `Push` (147), `Delete` (160), `CommitBatch` (172) | same | Run as detached. Disagree: fresh reads `a,b,later` after Push or CommitBatch, and `later` after Delete. | +| Directory `PushAll` (235) | same | Runs as detached. Push then PushAll and Delete then PushAll disagree. | +| Directory `JudgeChecks` (269), `RefuseRemovalOfUndeclared` (259) | same | Run as detached. Agree. | +| In-body reads: `StoredAtOver` clock (4028), refusal text and `DurableAt` (4531, 4543), declares and cacheless refusal (4588, 4603, 4611), `Wrote` (4661) | `Cache`, under the gate `Gate` chose | On the attaching thread they read null while the thread holds the cache's gate, so a held call runs as a cacheless writer. | +| `AddBackingStore` (2259-2314) | writes `Cache` under both gates, after the pull | The window's author: the store is attached for the pull (registry lent, `Loaded` set, in `_stores`) while `Cache` is null. | +| Mesh `CultMeshSingleFileDocuments` writes and reads (99, 143, 174, 226) | none of the attached store's | `StoreAt` (239) builds a new store per call, so they never touch an attached store object. A file it shares with an attached store is the file lock's and the write set's business. | +| `IsDirty`, `HasStagedChecks`, `ForeignRecords`, `LastSchemaMigrationReports`, `HeaderFor`, `HeaderForWrite` | none | Ungated values or static decisions. They read no attachment fact. | + +So during an attach, cacheless entry points decide attachment from the hold, and every held +entry point, plus every in-body read, decides it from `Cache`, which is still null. These are +two facts. + +**Probe of the chosen shape** (commit 4695390c on b2330b6d; `proto-full.log`). `AddBackingStore` +publishes `Cache` before the pull and takes it back in the same hold if the pull fails. `Gate` +becomes straight-line code: guess, then the store's gate, where `Cache` is settled, then the +settled cache's gate. Results: + +- All 21 probe cases are refused. The cacheless writes get the re-entry text and every other + case gets "A journal entered the cache it journals". After the flush, the attaching cache + and a fresh cache both read `a,later`. +- Caching: the only failures are the two tests that pin the late publish: + `AnAttachLendsTheRegistryAndPublishesTheCacheAfterThePull` (asserts no cache during the + pull) and `ACallWhoseGateChangesTwiceUnderItThrowsInsteadOfLooping` (asserts the old message + text; the tampering reader still throws). The churn, queued-reader and failed-attach tests + pass unchanged. +- Mesh and Networking are green. +- The cultcache-ts suite, which builds and drives the C# InteropPeer, passes on this shape: + 175 tests, 171 pass, 0 fail, 4 skipped. The C#, Rust and Python interop tests ran, the C# + edit-in-place one included (`proto-ts2.log`, commit f5f02053). Recipe `clone/r3ts.sh`: + image node:24-bookworm, apt python3-msgpack, dotnet-install for 10.0, rustup minimal. + +**Shapes weighed (rationale).** + +- (a) Publish `Cache` before the pull, as a616431e did. Chosen. What s2 found was not that an + early publish is unbounded. It was that `Gate` re-read `Cache` *unlocked* after a stale + guess, so a churn of failing attaches could hand it a new stale guess every pass. The fix is + where `Gate` reads on its second step. Under the store's gate, `Cache` is settled: an attach + publishes and takes it back inside one hold of that gate, and no other thread can hold that + gate during the attach. A settled cache is permanent, because there is no detach. So `Gate` + needs at most three acquisitions, with no loop and no counter: the guess's gate, the store's + gate, and then the settled cache's gate, which cannot miss. This deletes a fact rather than + reconciling two. During the attach the store *is* attached, so every entry point, cacheless + or held, and every in-body read sees one answer. A journal's call is then refused by the rule + journals already have (AddJournal: "any hold on this cache while a journal runs throws"), + exactly as it is after the attach. Prior art: `ConcurrentHashMap.computeIfAbsent` installs a + ReservationNode in the bin *before* running the mapping function, so a recursive update on + the same thread finds the owner and throws, and on failure the reservation is removed (see + `docs/research/2026-10-23-store-lock-user-code-prior-art.md` item 4 on main: the re-check + after locking, and the ReservationNode on the empty-bin path). The validate-after-acquire in + `Gate` is the same item. +- (b) Keep the late publish and make the attach's hold the one fact for every entry point. + Rejected. `InStoreHold` knows that this thread holds the store's gate, not *whose* hold it is. + Refusing every same-store re-entry kills `UserCodeInACachelessCallMayCallItsOwnStore` (r2's + R5), so (b) needs an "attaching" marker beside `Cache`. That is the second fact that s3 and + s4 rule out, plus every in-body `Cache == null` read would need to consult it as well. +- (c) Run no user code on the attaching thread under the store's hold during a pull: defer the + pull's journals to after the hold (open calls). Rejected as insufficient on its own. Getters + and constructors still run during the pull's decode, so the window stays open to any user + code that is not a journal. It also moves journal timing, which is a documented contract + ("right after an admission is applied"). +- Scar check: `git log -S` over CultCache.cs finds `store.Cache = null` and `MaxStaleGuesses` + only in ba658965 and 1468a83a. The early publish was not removed for a correctness reason of + its own: 1468a83a removed it to bound `Gate` and to lend the codec, and the codec loan is gone + (`pulled-variant-codec`). No settled read under the store's gate was built before. The + precedent recipe (no root filter) finds no same decision. +- Not a product fork. A journal's store call during an attach now throws, as it already does + after the attach. Cacheless writes on the attaching thread stay refused, with the same text. + Getters and constructors during the pull see an attached store, as they do during any held + call. It changes the late-publish clause of Defaulted ruling `variants:ruling:pulled-variant-codec`, + so Self defaults a superseding ruling (recommended label `cache-published-for-the-pull`) + before dispatch. +- Residual, not in r3. `Push`, `Delete`, `PushAll` and `CommitBatch` are public, so user code + that is not a journal (another thread, or a getter during any held call) can stage records + behind an attached store's cache. Soul s4 shows this with `other-thread-push`: file `a,b`, + cache `a`. That is true before, during and after an attach, so it is not the window's + two-fact problem. Closing it changes public API (a store's writes taken only from its + cache), so it is a follow-up and an operator question for Self, not part of this cut. + +## Body facts: claimant-one-owner r2 (Imagination, 2026-10-27, base fc74cf6f) + +Why a revision: Soul beat the same rule three times (s1 claimant choice, s2 compatible ids, s3 shape). Each time a +cacheless write lent a registry let the registry's entry for the written schema id decide ownership beside the entry +the caller passed. Two descriptions of one type reached `Owns`; each fix (h2 schema-id compare, h3 subset of compatible +ids) compared one more facet of the two and left the rest open. r2 removes the second description from the lookup. + +### Where the two descriptions met (source read, fc74cf6f) + +- `OwnsOnDisk` cacheless arm (`CultCache.cs:4725-4740`): with a lent registry it called + `lent.Owns(id, catalog, declared, written)`, whose lookup is the registry's `_indexes` and whose `entryOf` is + `descriptor.ToCatalogEntry()` (`:824-825`). The claimant for the written schema id was the registry's descriptor, + so `ReadsWhole(persisted, claimantEntry)` (`:848`) judged shape against the registry's entry, and the claimant + precedence (`:756-806`) walked the registry's compatible ids. `written` (`:4734-4736`) then compared the two entries + by schema id plus a subset of compatible ids. +- `WriteDocument` (`:4495-4524`) takes its entry from the registry (`resolvedRegistry.GetRequired().ToCatalogEntry()`), + so its two descriptions are always identical. Only `WriteRecord(record, catalogEntry, holdsIds, registry)` (`:4471`) can + pass an entry that disagrees with the registry's entry for the same id. +- `CopiedHoldIds` (`:4600-4630`) also reads `AttachedRegistry`, to decide the v3 header from copied records. It is a + positive-only header decision, not ownership, and out of this cut. + +### Consumer audit (2026-10-27) + +- `WriteRecord` and `WriteDocument` are new in the Unreleased 3.0.0 entry; `main` has neither (`git show main:src/GameCult.Caching/CultCache.cs`). +- Per-repo `rg` over every git repo directly under `F:\Projects` for `.WriteRecord(`, `.WriteDocument(`, `WriteSingleFileDocument` + (Library, obj, bin, PackageCache excluded): hits only in CultLib and in vendored CultLib copies under Heimdall and StreamPixels + (`vendor\CultLib`, older pins without these methods). No consumer outside CultLib calls either method. +- Inside CultLib at fc74cf6f: Mesh `WriteSingleFileDocument` calls `WriteDocument(key, document, storedAt, registry)`; + Mesh's raw-payload path calls `WriteRecord(..., registry: null)` (`CultMeshSingleFileDocuments.cs:129, 226`). The only + `WriteRecord` calls with a registry are tests: `CachelessSingleFileTests.cs:453, 660` (a registry `{IdDeck}` that does + not hold the written entry `One` at all, used for the copied-record header), `RestampOnWriteTests.cs:595, 599`, and + Soul's probes. Packages: no TS, Python or Rust counterpart takes a registry (cultnet-rs `write_record` is an example helper). +- Consequence: the registry's documented role in `WriteRecord` is to describe the other records in the store. A shape + that derives the written entry from the registry (or refuses an entry the registry does not hold) breaks + `CachelessSingleFileTests.cs:660` and removes that role. + +### Candidate shapes weighed + +| Shape | One description? | Public API | Kills s2/s3 | Cost | +|---|---|---|---|---| +| A. Lent registry's entry decides; refuse a passed entry that disagrees | yes, after a door check | signature kept; new refusal | yes | an equality check over a redundant parameter; breaks the registry-describes-others use (`:660`) when the registry lacks the written type unless it falls back to the passed entry, which is two descriptions again | +| B. Passed entry is the only description of its schema id; the registry supplies other types only | yes, by construction | signature kept; doc only | yes (probed) | one lookup class gains the registry; one registry overload deleted | +| C. Overloads: `WriteRecord(record, catalogEntry, holdsIds)` and `WriteRecord(record, registry, holdsIds)` | yes, by type | changes a 3.0.0 signature | yes | loses the registry-describes-others use | +| D. Delete `WriteRecord`'s registry parameter | yes | changes a 3.0.0 signature | yes | loses the copied-record header decision for raw writes | + +Picked B. It is the only shape that keeps every documented use of the registry, needs no public signature change, +and deletes the comparison instead of extending it. It is ordinary scoping (an inner binding shadows the outer one +for its name), applied to schema ids. + +### Probes (Starfire, local filtered runs, 2026-10-27) + +Scratch clone of fc74cf6f at `/imagination-claimant-r2/clone`, Soul's `SoulClaimantProbe.cs`, +`SoulClaimant2Probe.cs`, `SoulClaimant3Probe.cs` copied into `tests/GameCult.Caching.Tests`. + +Base fc74cf6f (`probe-base.log`): +- P7 (registry {N lists own, A, B}; passed entry N with the list varied; record k under O, named like N): lent R / no + registry C for rows b, b2, e, g, h, k, l, l2; lent C for a, c, d, f, i, j. Confirms `subset-refuses-own-drift`. +- P9 (registry {NW: Name, Value, Colour}; passed NW minus Colour): lent C (restamped, Colour lost), no registry R. + Confirms `lent-shape-widens-passed-entry`. +- P6 (bare X1 entry, registry {X1} listing OX, record under OX): lent R, no registry R, cache C. h3 closed the id axis. +- P4: 12 states x 8 registries, violations=0. P1d: a cache {N,L} commits N over L's listed old id (`cache-replaces-other-types-record`). + +Prototype B (scratch commit 349ea3f3 on the clone, not for landing; `probe-protoB2.log`): `CatalogEntryLookup` takes +the written entries and an optional registry and drops every registry descriptor whose schema id a written entry has; +the registry's `Owns(schemaId, catalog, declared, mayClaim)` overload is deleted; the cacheless arm always asks +`CultDocumentRegistry.Owns(id, catalog, declared, new CatalogEntryLookup(writes, AttachedRegistry), entry => entry, +claimant => writes has claimant's schema id)`. +- P7: lent == no registry on all 14 rows (all C). +- P9: lent R, no registry R, wide entry C. +- P6: unchanged (R, R, C). P4: identical table to base, violations=0. P1a-f, P2a-d: identical to base. +- `RestampOnWriteTests`, `CachelessSingleFileTests` and the probes: 139/139 passed locally. +- Yggdrasil, 349ea3f3, `ygg-protoB-full.log`: GameCult.Caching.Tests 1180 passed / 0 failed / 2 skipped; + GameCult.Mesh.Tests 342 / 0 / 4. + +Negative greps, each tested at fc74cf6f (each matches there, and none may match after the cut, src and tests): +`claimant.CompatibleSchemaIds.All(` (1, the subset), `lent.Owns(` (1), `Func mayClaim) =>` +(1, the deleted overload), `CatalogEntryLookup(writes)` (3, one-argument construction). CHANGELOG and docs: +`never widens what the passed entry declares` (1), `with no compatible id the passed entry does not list` (1). + +### Not settled by r2 + +- `cache-replaces-other-types-record` (s2, PreExisting, cache arm): r2 touches only the cacheless lookup, so it does + not settle whether a cache may replace another registered type's record at a shared key. +- Whether a registry-less `WriteDocument` should consult `CultDocumentRegistry.Shared` for claimants at all (s1 + `shared-decides-registryless-write`, closed Fixed by documentation in h2). r2 keeps today's answer. +- `variants:finding:cut-restamp-on-write.s2.cacheless-inverts-registry-claimant` is still in force although r1 was + mapped to close it; Self should close or re-triage it after r2's Soul pass. diff --git a/docs/cultlib-gaps-map.md b/docs/cultlib-gaps-map.md index 27c848d2..5fd9a412 100644 --- a/docs/cultlib-gaps-map.md +++ b/docs/cultlib-gaps-map.md @@ -31,6 +31,79 @@ copy of the solver and the warm/cold sweep, `suite` a scratch copy of `packages/ conditional commit compares that (ruling `aetheria-release:ruling:expect-fix-in-cultcache`). C# only. The release that carries it to Aetheria follows question `cultlib-gaps:question:expect-fix-release-lane`; the Aetheria pin bump is an Aetheria follow-on, not this cut. +9. `aot-record-ref-generator` (section A, AOT cut 1): a consumer with MessagePack's source generator on can declare + `CultRecordRef` members and dictionary keys. Base: the C2a branch head `a768b821`; Hands starts after C2a merges + to main or rebases onto it. Serves `record-ref-breaks-msgpack-generator`, the first cut of + `cs-aot-safe-registration` (operator 2026-10-27: "I'm not a fan of requiring reflection. ... AoT is a nice thing to + support anyway."). +10. Not mapped (AOT cut 2): the generator reaches consumers that reference only `GameCult.Caching.MessagePack` (today its + analyzer is `PrivateAssets="all"`, so they run every document on Reflection.Emit). +11. Not mapped (AOT cut 3): registration descriptors are generated instead of reflected (`BuildDescriptor`, + `DiscoverMembers`, `CultDocumentResolver`, `CultMessagePackSecurity`), and C2a's element-id walk stops reflecting + (A6), so a NativeAOT consumer round-trips a ref-keyed dictionary. + +## A. AOT cut 1: CultRecordRef under MessagePack's source generator (cut `aot-record-ref-generator`) + +Imagination pass `imagination-aot-cut1`, session `self-2026-10-27-eureka`, pinned to `origin/hands/variants-c2a` at +`a768b821` (C2a's head; merge base with main `02cf04b5`). Starting evidence: `research/cultmesh-garden-path/cultcache-aot-probe-2026-10-27.md` +(main `eb6a8f2c`). Probes: the scratch console from that note, copied to the session scratchpad +`imagination-aot-cut1/aot-probe/` and committed only as dangling commits on top of `a768b821` (`a0f06b2c`, `6d382961`, +`dc6cf9dc`, `99cae039`, `07b89982`), run on Yggdrasil in `mcr.microsoft.com/dotnet/sdk:10.0` (MessagePack 3.1.7, .NET +10.0.12). Logs `job1.log` to `job5.log` beside the probe. Variants: G = `CultRecordRef` member only, H = ref-keyed +`Dictionary, float>` only, B = both. Every variant references `MessagePack` directly (generator on). + +### Body facts + +- A1. The generator does not discover `CultRecordRefFormatter`. For a generic type it does not know, it collects + the type's definition as an object and names the formatter it would generate, `Formatter`, nested in + `GeneratedMessagePackResolver`. For a type from another assembly it then generates nothing, so the resolver names + `GeneratedMessagePackResolver.GameCult.Caching.CultRecordRefFormatter`, which does not exist (CS0426). The match + with our formatter's name is a coincidence. Evidence (job1): `[ExcludeFormatterFromSourceGeneratedResolver]` on the + formatter, and an `internal` formatter, both still fail G, H and B with the same line. Upstream issue + MessagePack-CSharp#2133 (open, no maintainer reply) reports the same failure for any open-generic type with a + formatter in another package. +- A2. Consumer MessagePack 3.1.12, the newest stable, fails G, H and B the same way (job1). Upgrading does not fix it. +- A3. `[MessagePackFormatter(typeof(CultRecordRefFormatter<>))]` on the `CultRecordRef` struct stops the + generator naming a formatter for it (the mangled line is gone), but the generator then reports `MsgPack006` (error) + in every consumer: it checks `typeof(X<>)`'s interfaces, which an unbound generic does not list. Adding the + non-generic `IMessagePackFormatter` marker to the formatter does not help (job2 `ta2`). With `MsgPack006` + suppressed by the consumer, G, H and B build and run (job2). Rejected: it needs the formatter moved into + `GameCult.Caching`, which then needs the whole `MessagePack` package instead of `MessagePack.Annotations`, and every + generator consumer still has to suppress an error. +- A4. `[assembly: MessagePackAssumedFormattable(typeof(CultRecordRef<>))]` in the consumer assembly fixes G, H and B + with no library change (job2 `base@ASSUMED`). The generator stops collecting `CultRecordRef`; the generated + document formatter asks the resolver, and `CultDocumentResolver` serves the ref. This is the attribute MessagePack + documents for "a formatter added through a custom resolver at runtime". The same attribute as an MSBuild + `AssemblyAttribute` item in the consumer project (`_Parameter1_IsLiteral`), which is what a package's + `buildTransitive` targets injects, does the same (job3 MG, MH, MB). The attribute declared in the library assembly + does not reach the consumer's generator (job4 LIBATTR: CS0426). +- A5. Generated and dynamic formatters write the same bytes. RefDoc (B) payload is `93A172A17081A170CA3F000000` from + the Reflection.Emit fallback with no generator (job3 BASE) and from the generated formatter (job3 MB). The document + formatter is `GeneratedMessagePackResolver+Probe+RefDocFormatter` (`dynamic=False`); the ref formatter is the + library's `CultRecordRefFormatter` through `CultDocumentResolver`. +- A6. NativeAOT (`PublishAot`, probe assembly rooted, linux-x64): G round-trips (payload, upsert, flush, reopen) with + payload `92A172A170`, identical to JIT (job4 G0). B fails, in C2a code, not in the generator path: upsert fails in + `CultElementIds.MapEntries`, whose `MakeGenericMethod(key, value)` has no native code for a struct key (job4 B0). With + that walk reading the map through non-generic `IDictionary` instead, upsert passes under AOT and JIT is unchanged + (job5 B1), but the payload and flush still throw `TargetInvocationException` from a second reflective call on the + serialize path. By source read the candidate is `ResolverClaim`'s `GetFormatterMethod.MakeGenericMethod(type)` + (`CultDocumentMessagePackSerialization.cs:84`), not proven. Both are runtime reflection over element-id paths, which is + AOT cut 3's ground, so cut 1 proves AOT on the member shape and leaves the key shape's AOT round trip to cut 3. +- A7. `GameCult.Geometry` already runs the generator (it references `MessagePack` directly) and carries the per-member + workaround on three members (`CultGeometryDocuments.cs:164,239,328`). Under A4 those attributes are redundant. + +### Shape the cut lands, and why + +`GameCult.Caching.MessagePack` ships `buildTransitive/GameCult.Caching.MessagePack.targets`, which adds the A4 assembly +attribute to every project that consumes the package. NuGet consumers need no line of their own, which is what a +consumer expects from a library whose type breaks the generator. Unity consumers and in-repo `ProjectReference` +consumers do not get `buildTransitive`: Unity consumers write the one attribute line (README), and in-repo consumers +import the targets file, so they exercise the file that ships. The attribute type comes from `MessagePack.Annotations`, +which `GameCult.Caching` already brings, so the injection compiles in a consumer with or without the generator. Wire +bytes do not change: no formatter changes, only which code path asks for it (A5). + +Rejected: a type-level formatter attribute (A3); excluding or hiding the formatter (A1); upgrading MessagePack (A2); +declaring the attribute in the library (A4); keeping per-member attributes, which cannot reach dictionary keys. ## B. BoundedLeastSquares stopping diff --git a/docs/cultmesh-garden-path-map.md b/docs/cultmesh-garden-path-map.md new file mode 100644 index 00000000..8374f782 --- /dev/null +++ b/docs/cultmesh-garden-path-map.md @@ -0,0 +1,88 @@ +# CultMesh garden path: map + +Campaign `cultmesh-garden-path`, stage 1 (server-authoritative garden path for the +orbs .io game). Target: `docs/cultmesh-garden-path-target.md` (typed as +`cultmesh-garden-path:target:r2`). Rulings, questions and follow-ups are documents +in the mind; this page holds the model page, body facts and the stage 1 cut lines. + +Written by Imagination (`imagination-gp-0b`, session `self-2026-10-27-eureka`) +against CultLib `d1bf21b3`. Refreshed by `imagination-gp-stage1` (session +`self-2026-10-27c-eureka`) against `51ca81a1`: every fork the page waited on is +ruled, and the cells below carry the rulings. + +## Model page + +One row per kind. "Store" says where it lives: a CultCache document in the room's +store, or transient per-tick data that never enters a store. Proposed values are +marked as such; they are the design this page offers for the cuts, not landed +behaviour. A cell that waits on an operator question names it. + +The governing split, from `state-is-cultcache` and the CultCache scope restraint: +every value that crosses a boundary is CultCache-encoded (one serialisation model, +no JSON), but only state that outlives a tick is a record in a store. Inputs, ticks +and snapshots are typed CultNet message payloads, not store records. The store +receives exactly one write set per tick (ruled `tick-state-commit`: tick-batch). + +| Kind | Store | Identity (what names it) | Lifecycle (what happens to it) | Authority (who decides; forbidden writers) | +|---|---|---|---|---| +| **Game definition** (documents, systems, authority declaration, tick rate) | Proposed: one CultCache document per server process (`gamecult.mesh.game_definition`), read-only to everyone but the server's startup. | Game id string from code (`"orbs"`), namespaced by Verse id. The id alone is not injective (two builds share it), so proposed identity is `(game id, definition hash)`, the hash derived from the lowered definition: document schema ids and versions, system ids, order and marks (`[Predicted]`, later `[Adjudicable]`), tick rate, authority declaration. Schema ids (`orbs.player`) are global strings; the game prefix is convention, not enforced (body fact F9). | Created at server startup from code; never revised at runtime; a new build supersedes it with a new hash. Clients carry their own compiled hash (C# in wasm, ruling `browser-sim-runtime`) and are refused at join on mismatch. A declaration a mode cannot honour is refused at startup (`modes-do-not-leak`). Never replayed. | The game's code, through the builder, at startup; the runtime validates. Forbidden: clients, any runtime mutation, Odin (Odin may read and index it, never write it). | +| **Game document** (Player, Pellet, Shot) | CultCache documents in the room store. | `(schema id, record key)`. Today the key is a free string the caller picks; `[CultIndex] Id` in the illustration is an index, not the key. Proposed: the runtime mints keys. Player key derives from the session principal (one Player per principal per room). Spawned documents (Shot, Pellet) key on `(spawner key, tick, spawn ordinal within that tick)`, so a client's predicted spawn and the server's authoritative spawn derive the same key and reconcile instead of duplicating. That is injective as long as spawn order within a system step is deterministic for the predicting client, which the predicted system's own code gives under one runtime (C# both sides). The developer's `Id` field becomes redundant; whether to keep it as a mirror of the key is an API call for the cut, not an operator fork. | Created by a system during a tick, or by the join system (Player). Revised at most once per tick, in that tick's commit. Deleted by a system (pellet eaten) or by the game's leave system after a session ends. Across reconnect within the grace window the Player record is unchanged; only the session's connection binding moves. Across a server restart: gone (ruled `room-durability`: ephemeral; a restart is a new room epoch at tick 0). Replayed only on the client, during reconciliation; the server never replays in stage 1. | The server tick runner on the room's primary is the one writer of committed state, and systems write only through the tick's working set. Forbidden writers: client document puts through `CultNetDatabaseServer.HandlePutAsync` (today accepted from any peer, F1); `PutPredictedAsync` and `CultMeshGameSession.PredictAsync` on any networked store; game code calling database APIs; the simulation-fact committer (F7). | +| **Player input** | Transient. A CultCache-encoded payload on a CultNet message; never a store record in stage 1. | `(session principal, input sequence)`, the sequence monotonic per session; each input also carries the tick it targets. Namespace: the room. Injective given the principal. The input type is a schema'd CultCache type, so the schema catalog publishes it like any document. | Sampled each client tick (devices read by the TS shell, handed to wasm); sent with the last few unacknowledged inputs repeated, so one loss is absorbed; buffered by the server per session against its target tick; consumed exactly once by the matching tick (proposed policy for late inputs: applied to the next tick and counted); acknowledged in the snapshot as "last processed sequence"; kept by the client until acknowledged, for replay; then discarded. Stage 4 changes this: inputs become signed, hash-chained and retained for a dispute window. | The client's principal authors the content; the tick runner decides which tick consumes it. Forbidden: server code altering input content; other clients; any input that arrives without a bound session. | +| **Tick** | Transient counter; proposed: the last committed tick is written into the room record in each tick's batch, so a snapshot or checkpoint knows it. | `(room id, room epoch, tick number)`, the number starting at 0 when the room epoch starts. Injective with the epoch. | Advanced only by the server tick runner at the one declared rate (ruled `tick-model`: one-fixed-rate); never revised. The client estimates the server tick from snapshots and runs its predicted tick ahead by its round trip plus a buffer; on reconnect it re-syncs from the next full snapshot. | Server tick runner. Forbidden: client clocks, transport arrival time, wall-clock reads inside systems (systems receive the tick, not the time). Today no tick scheduler exists anywhere in the C# body (F6). | +| **Predicted state and reconciliation** | Client-side only. In the browser they live in the C# wasm CultCache store; the TS renderer reads a per-frame view (ruled `browser-client-store`: wasm-owns-store). | Same `(schema id, record key)` as the authoritative document, plus the client tick it was predicted for. | Each client tick: predicted systems run over owned documents and the client's own spawns. On a snapshot for tick T acknowledging input sequence S: predicted documents are replaced by the authoritative ones at T, then inputs after S are replayed through the predicted systems up to the current client tick; predicted spawns are matched by derived key, and an unmatched one is removed once the acknowledgement passes its tick. Today `Reconciled` means "the authoritative put replaced the predicted copy" with no replay (F3); the replay loop does not exist. | Predicted state owns nothing outside the client. It is derived from the last authoritative snapshot plus unacknowledged inputs plus the predicted systems. The snapshot owns truth. Forbidden: sending predicted documents to the server; `CultNetClientAuthorityScope` in server mode (F4); the renderer writing. | +| **Session and the player's identity** | Proposed: session membership as a small CultCache document in the room store (`gamecult.mesh.game_session`, low churn, readable by systems and present in snapshots). The connection handle is transient process state. | Principal: the fingerprint of a browser-held P-256 key, proved by signing a join challenge (ruled `player-identity`: browser-keypair). Connection: a server-minted id per transport connection (ephemeral). Logical session: `(principal, room)`, bound across reconnects. Today the client's identity is a self-asserted `runtimeId` string (browser client options; `SourceRuntimeId` on operation envelopes); CultMesh's session identity proves the server to the client and not the reverse (F5). The body still has no authenticated client principal (F5, F13); the session cut builds it. | Connect, then join (definition hash check, principal proof), then the session record is created and the join system spawns the Player. Disconnect suspends the session for a grace window; resume binds a new connection to the same session; expiry ends the session, systems see the leave, and the game's leave system removes the Player. On server restart sessions end (ruled `room-durability`). | The server's join and leave handler on the room primary decides membership; the principal proves itself by signing the join challenge. Forbidden: a client-asserted `runtimeId` as identity; transport peer address; peer catalogs. | +| **Authority declaration and the lease it lowers to** | Declaration: inside the game definition. Lease: proposed as a CultCache document (`gamecult.mesh.authority_lease`); today a plain class in an in-memory dictionary (F2). | Declaration: per document type, `(writer, checker, arbiter)`. Lease: `LeaseId`, carrying Verse, peer, roles, shard ids, issuer, validity window, epoch and signature (`CultMeshAuthorityLease`). Namespace: the Verse. | Declaration fixed per build. At startup `Authority.Server` lowers to: the server holds a `ShardPrimary` lease on the room shard, and clients hold no write scope at all. Renewed before expiry; revocation or expiry stops the tick runner from committing; a new primary takes a new epoch. Proposed issuer in stage 1: the server's own deployment key, which clients pin through the browser trust policy, so an indie deployment needs no Odin. Odin-issued leases stay possible. | The game definition decides the declaration; the issuer issues leases; `CultMeshAuthorityResolver` decides whether a peer holds a role on a shard. Today the database's write gate (`CultNetShardDescriptor.IsPrimary`, `CultNetClientAuthorityScope`, both set from options) never consults the resolver: two authority structures (F2, F4). Proposed: the write gate is derived from lease resolution. First step (cut `remote-write-authority`): a peer's put or delete reaches the database only when `CultNetDatabaseServerOptions.RemoteWriteAuthority` grants it; none configured refuses every remote write (F1, GP1). The lease-backed grant needs an authenticated principal bound to the connection, so it follows the session cut. `CultNetClientAuthorityScope` is no longer an owner; it is derived from the declaration, and in stage 1 it is empty. | +| **Shard / room** (stage 1: one) | `CultNetShardDescriptor` (configuration); the room's state is its CultCache store. | Room id `(Verse, game id, room name)`, for example `orbs/main`, carried as the shard id plus the shard epoch. | Created at server start (epoch 1; on restart a new epoch, ruled `room-durability`); never moved in stage 1; ended at shutdown. Join means "join the one room"; matchmaking is out of scope. Interest management is stage 5 per the target, so in stage 1 every client receives the whole room. | The runtime holding the `ShardPrimary` lease. Forbidden: a second primary; clients. | +| **Snapshot sent to clients** | Transient. A CultNet message whose payload is CultCache-encoded documents; never stored. The server keeps per-client baselines in memory for deltas. | `(room, epoch, tick, recipient session, baseline tick)`. | Derived after a tick commit at the send rate; a delta against the recipient's last acknowledged snapshot, or a full snapshot on join and resume; carries the last processed input sequence for that session. Discarded once sent; a baseline is dropped when a newer one is acknowledged. Today the nearest things are per-record change pushes from `CultNetDatabaseSubscriptionServer` (no tick, no acknowledgement, no input sequence) and shard catch-up snapshots in `CultMeshSnapshots.cs` (F8). | The snapshot builder decides nothing; it is derived from the committed tick. Forbidden: the per-record subscription stream acting as a second game replication path. That stream stays for non-game observers. | +| **Simulation fact / witness observation** (found) | `CultMeshSimulationFact` is a CultCache document; observations are hub memory. | `(shard, epoch, frame, subject, kind)`. | Stage 1: not used. The target replaces it at stage 4. | Today `CultMeshGameSession` serves observations by default (`ServeSimulationObservations = true`) and commits quorum facts through the primary (F7). In server mode there is no checker, so the observation server and the committer must not be wired. Forbidden writer in stage 1. | +| **Shard mutation log entry** (found) | `CultNetDatabase` in-memory `SortedList` per shard, plus an optional durable store. | `(shard id, sequence)`. | Today every primary write appends one entry, and the in-memory list is never trimmed (F10). Per-record writes at tick rate would grow it without bound. In stage 1 the tick's write set either bypasses the per-record log or lands as one bounded entry per tick. Ruled: the tick's write set lands as one batch per tick (`tick-state-commit`), in memory (`room-durability`); the bounded per-tick entry is the tick-runner cut's to specify. | `CultNetDatabase` on the primary. Forbidden: per-system-write appends from the tick runner. | + +### Cells filled by rulings (2026-10-27) + +- Session principal: `player-identity` (browser-keypair). Not yet in the body (F5, F13). +- Browser documents and the renderer's read: `browser-client-store` (wasm-owns-store). +- Room state across restarts: `room-durability` (ephemeral). +- Tick rate model: `tick-model` (one-fixed-rate). +- How a tick reaches the store: `tick-state-commit` (tick-batch; the tick-runner cut times `CultCacheBatch` at tick rate first). +- Stage 1 transport: `browser-transport` (WebSocket behind a port carrying only inputs and snapshots; a WebTransport server in parallel, `webtransport-server-layer`: managed C# over the MsQuic bridge). +- Still unprobed: `CultCacheBatch` commit cost at tick rate for about 50 players plus pellets (wasm measuring probe and tick-runner cut). + +## Body facts + +All are source reads at CultLib `d1bf21b3` on 2026-10-27 by `imagination-gp-0b`. No +runtime probe was run in this pass. Each fact names the read that would falsify it. + +- **F1. The primary applies any peer's document put.** `CultNetDatabaseServer.HandlePutAsync(message, peer)` (`src/GameCult.Networking/CultNetDatabaseServer.cs:307`) calls `ApplyPutAsync(message)` (`CultNetDatabase.cs:1022`), which checks only that this runtime is primary for the shard and the epoch matches (`EnsurePrimary`, `:2003`). `peer` is used only to send errors. Any connected client can overwrite any game document on the server. +- **F2. Leases are not typed state and do not gate writes.** `CultMeshAuthorityLease` is a plain class (no `[CultDocument]`); `CultMeshAuthorityLeaseCatalog` is a `Dictionary` (`CultMeshAuthority.cs:242`). The only `[CultDocument]` types in `GameCult.Mesh` are body publication, CDN chunk and manifest, content transfer state, discovery state, entity prefab package and simulation fact. `GameCult.Networking` (the database) references no lease type; the one Networking file naming `Lease` is `CultNetSchemaMessages.cs`. +- **F3. Reconciliation is replacement, not replay.** `CultNetDatabase.cs:1247-1252`: a predicted put records a prediction key; the next non-predicting write to that key turns the change into `Reconciled`. No code re-applies inputs after an authoritative state. +- **F4. Client write scope is a runtime-id string match from options.** `CultNetClientAuthorityScope.Matches` compares `OwnerRuntimeId` with the database's own `_runtimeId` (`CultNetDatabase.cs:111-153`, `EnsureClientAuthority` `:1934`). It gates local prediction only and has no relation to leases. +- **F5. No authenticated client principal.** `CultMeshSessionIdentity.cs` verifies transports for `(verse, authorityRuntimeId, protocol, routeGeneration)`, which proves the server to the client. The browser client's `runtimeId` (`packages/cultmesh-browser/src/index.ts:182`) and the operation envelope's `SourceRuntimeId` (`CultNetOperationServer.cs:39`) are self-asserted. +- **F6. No tick scheduler.** A grep of `GameCult.Networking` and `GameCult.Mesh` for tick or timestep names finds none. `Frame` exists only as a witness-asserted number in `CultNetSimulationConsensus` and `CultMeshSimulationFact`. +- **F7. The game session serves the witness path by default.** `CultMeshGameSession` constructs `CultNetSimulationObservationServer` when `ServeSimulationObservations` (default `true`) and always constructs a `CultMeshSimulationFactCommitter` (`CultMeshGameSession.cs:70-104`). `PredictAsync` is `PutPredictedAsync` (`:161`). +- **F8. The browser can watch and invoke, not put.** The `cultmesh-browser` README says it "does not expose direct document writes". The client offers `leaseRawDocument` (`index.ts:349`) and `invoke` of typed operations (`:370`), over one WebSocket. On C#: `CultNetDatabaseSubscriptionServer` and `CultNetOperationServer` take `ICultNetSchemaServer`, so they run over `CultNetWebSocketSchemaServer`; `CultNetDatabaseServer` (puts) takes the concrete UDP `Server` (`CultNetDatabaseServer.cs:36`). Operations are request and response with receipts and idempotency keys, which is a poor fit for an input stream at tick rate. +- **F9. Schema-id namespacing is convention.** `[CultDocument("orbs.player", ...)]` ids are global strings; nothing ties a schema id to a game id. +- **F10. The in-memory mutation log is never trimmed.** `RecordMutationLogEntry` (`CultNetDatabase.cs:1610`) inserts into a per-shard `SortedList`; no `Remove` or trim on that list exists in the file. Compaction applies to the durable store and to burned sequences only. +- **F11. CultCache has a write-set batch; CultNetDatabase does not expose one.** `CultCacheBatch` with `Commit` and `TryCommit` (`GameCult.Caching/CultCache.cs:1796`, `:2212`) is the 2.0.0 write set (`variants:ruling:write-set-content-version-declared-ownership`). `CultNetDatabase` has no batch method: every write is a per-record door. +- **F12. A reflection site on the mesh put path.** `CultMesh.cs:3502` `PutDatabaseDocumentAsync` picks `PutAsync` or `PutPredictedAsync` by `GetMethods` and `MakeGenericMethod`, which matters to the AOT prerequisite (`cultlib-gaps:follow_up:cs-aot-safe-registration`; probe in `research/cultmesh-garden-path/cultcache-aot-probe-2026-10-27.md`). + +- **GP1 (runtime probe, 2026-10-28, `imagination-gp-stage1`).** Scratch NUnit test at `51ca81a1`, run on Yggdrasil (`ygg-verify.sh`, `mcr.microsoft.com/dotnet/sdk:10.0`, filter `ImagGpProbeTests`, never pushed to origin). The server writes `gp:player` = "server-owned" through `CultNetDatabase.PutAsync` on a primary shard. `CultNetDatabaseServer._putHandler`, invoked with an uninitialized `CultNetServerPeer` (no field set: no endpoint, no transport, no identity) and a put of "client-overwrote", leaves the record "client-overwrote", and `Server.CanProcessBeforeVerification(put)` returns `true`. Output: `GP1 after=client-overwrote canProcessBeforeVerification=True`, Passed 1. This confirms F1 at runtime: nothing about the peer is consulted, and puts and deletes pass before the legacy login verification (`Server.cs:562-575`). +- **F13. A server peer carries no identity.** `CultNetServerPeer` (`Server.cs:829`) holds the LiteNetLib `NetPeer`, its endpoint and transport stats. The only principal on `Server` is the legacy username and password login (`PlayerData.PlayerId`, `IsVerified` `:697`), which the database door never reads. `ServerSecurityOptions` is one shared `ConnectionKey`: transport admission, not authority. +- **F14. A new refusal code is a coordinated change.** The `cultnet.error.v0` `code` is a closed enum (`contracts/cultnet/cultnet.error.schema.json:28-31`, with a byte-identical copy under `packages/cultnet-rs/contracts/`). Rust `CultNetErrorCode` (`packages/cultnet-rs/src/contracts.rs:242-273`) refuses unknown codes, `packages/cultnet-rs/tests/error_message.rs` decodes captured C# bytes per code, and TypeScript imports the root schema (`packages/cultnet-ts/src/contracts.ts:10`). `unowned_schema` was added this way and is the pattern. +- **F15. Forwarding re-sends as a new client.** `TryForwardPutAsync` (`CultNetDatabaseServer.cs:654`) forwards a `not_primary` put through `ICultNetShardWriteForwarder`, and `CultNetSchemaWriteForwarder` opens its own client connection (`CultNetShardWriteForwarding.cs:122-128`). The primary therefore sees the forwarding runtime, never the original peer. +- **F16. Consumers of the remote write door.** No C# consumer outside CultLib constructs `CultNetDatabaseServer` (grep of the Aetheria, Epiphany, Ghostlight, huginn and gamecult-ops `*.cs` files finds only vendored CultLib copies). In CultLib, tests reach the put and delete handlers directly in `CultNetJournalTests.cs:738`, `:935-936` and `SoulP4cProbeTests.cs:57`. `CultNetDatabaseServer.ApplyPutAsync` and `ApplyDeleteAsync` (`:73`, `:81`) have no caller in `src` or `tests` besides the handlers. TypeScript puts (the `cultnet-ts` Idunn presence and signed health publishers) target Rust servers. +- **F17. The Rust document server admits a put by size only.** In `packages/cultmesh-rs/src/rudp_document_server.rs:682-720`, a `DocumentPutRaw` is refused only when its served response would not fit; no authority is asked. This is F1's gap in the Rust runtime (a follow-up, not stage 1). + +## Cut sequence (stage 1) + +One line per cut not yet mapped. The model page has no empty cell as of 2026-10-28. Remote write authority (`cultmesh-garden-path:cut_spec:cut-remote-write-authority.r1`, mapped 2026-10-28, so no longer a line here) goes first: it waits on nothing, every later server cut stands on a closed write door, and GP1 shows the door open today. The wasm measuring probe is the sibling that may run beside it, since it touches no file this cut does; ruling `browser-sim-runtime` names it the first cut of the browser runtime track, not of stage 1 as a whole. + +- **Wasm runtime measuring probe.** Download size, startup and 60 Hz tick cost of a stub orbs tick under Mono browser-wasm and NetWasm, including `CultCacheBatch` commit cost at tick rate. This is the first cut named by ruling `browser-sim-runtime`, which it can overturn. Its NetWasm and AOT variant waits on `cultlib-gaps:follow_up:cs-aot-safe-registration`. +- **Witness services off in server mode.** `CultMeshGameSession` stops constructing the observation server and fact committer when the declaration has no checker (F7). Waits on nothing; can run beside remote write authority. +- **Leases as typed state and the lease-derived grant.** `CultMeshAuthorityLease` becomes a CultCache document, the lease catalog a store view, and the Mesh host's `RemoteWriteAuthority` resolves the connection's principal through `CultMeshAuthorityResolver`. Waits on remote write authority and on session and principal. +- **Demote `CultNetClientAuthorityScope`.** It becomes derived from the authority declaration (empty in server mode). Waits on game definition. +- **Changelog entry for the remote write refusal.** `unity/org.gamecult.cultlib/CHANGELOG.md` is also written by `variants:cut_spec:cut-write-ownership-cs.r1`, so Self adds this cut's bullet at merge, after that cut lands, and the two specs share no file. +- **Game definition and server tick runner.** Builder, document and system registration, definition document and hash, fixed tick, tick-boundary commit. Rests on rulings `tick-model`, `tick-state-commit` and `room-durability`; waits on nothing mapped. +- **Session and principal.** Join, resume and leave; principal proof; session document. Rests on ruling `player-identity`; waits on game definition (the join checks the definition hash). +- **Input and snapshot path.** Input messages with redundancy, a server input buffer, acknowledgements, delta snapshots. Rests on ruling `browser-transport`; waits on session and principal. +- **Browser client runtime.** Wasm simulation host, TypeScript shell for transport and rendering, prediction with replay reconciliation. Waits on the measuring probe and `browser-client-store`. +- **Orbs reference game and garden-path proof.** The game written with no networking code (`garden-path-needs-no-networking`), and a browser client that writes (`browser-is-first-class`). Waits on all of the above. diff --git a/docs/cultmesh-garden-path-target.md b/docs/cultmesh-garden-path-target.md new file mode 100644 index 00000000..9daa0f77 --- /dev/null +++ b/docs/cultmesh-garden-path-target.md @@ -0,0 +1,153 @@ +# CultMesh garden path: target + +Status: draft target for campaign `cultmesh-garden-path`, 2026-10-27. It holds the +ends, not the means. The code below is illustrative: it shows what a developer +should have to write, and no name in it is decided. + +## The problem + +Operator, 2026-10-27: "the value is in solving the multiplayer scaling problem +for indie developers in an ergonomic fashion. Having one authoritative server is +fine, up to a point, but there's no real open source playbook on how to ship an +MMO, and existing solutions like SpacetimeDB have a huge ergonomics gap. I want +CultMesh to feel like magic: as a garden path, you annotate your domain POCOs and +simulation code and declare your authority structure ... and it'll all just work +without the developer needing to understand networking deeply." + +Prior art: `research/cultmesh-garden-path/prior-art-2026-10-27.md` (frameworks, +ergonomics, scale-out) and `research/witness-authoritative-networking/` (consensus +and P2P anti-cheat). What the field shows, in short: + +- One API over several authority modes ships today (Photon Fusion: Server, Host, + Shared; Unity NGO: client-server and distributed authority), and in both the + modes leak into the developer's code (`IsServer` against `HasAuthority`, + features missing in one mode). +- SpacetimeDB gives one machine per database. Scaling past it, client + prediction, and the reducer-shaped rewrite of game code are left to the + developer. +- Determinism, where it is required at all, is required of the whole simulation + (Croquet/Multisynq, Photon Quantum), and that tax is the main ergonomic cost. +- Shipped world-scale designs treat a spatial unit as indivisible and move + players between units (EVE, Pax Dei, Folia); nobody publishes an open, + end-to-end playbook (Astron, the nearest, is discontinued). + +## What a developer writes (illustrative) + +The reference game is a small browser .io game (operator, 2026-10-27: "the .io +game, Aetheria later"): players steer orbs, eat pellets, and shoot. + +```csharp +[CultDocument("orbs.player", "orbs.player.v1")] +public sealed class Player +{ + [CultIndex] public string Id { get; set; } = ""; + public Fix2 Position { get; set; } + public Fix2 Velocity { get; set; } + public int Mass { get; set; } +} + +[CultDocument("orbs.shot", "orbs.shot.v1")] +public sealed class Shot +{ + [CultIndex] public string Id { get; set; } = ""; + public string Shooter { get; set; } = ""; + public Fix2 Origin { get; set; } + public Fix2 Direction { get; set; } +} + +// Runs every tick. Predicted on the owning client, corrected by authority. +public sealed class Movement : CultSystem +{ + [Predicted] + public void Step(Player player, PlayerInput input, Tick tick) { /* ... */ } +} + +// A non-owner may re-run this to settle a dispute, so it must be +// deterministic. The analyzer enforces that on this system only. +[Adjudicable] +public sealed class Hits : CultSystem +{ + public HitResult Resolve(Shot shot, Rewind target) { /* ... */ } +} + +var game = CultMesh.Game("orbs") + .Documents() + .Systems() + .Authority(Authority.Server); +// .Authority(Authority.TrustedCoop); +// .Authority(Authority.Witnessed(w => w.Challenger(Party.Victim).Quorum(3).Arbiter(Party.Server))); +// .Authority(Authority.Regions(r => r.Grid(512).Handoff())); // later: past one server +``` + +Changing the authority line is the whole migration between modes, unless the new +mode re-executes a system on a non-owner and that system is not yet +deterministic. Then the analyzer names exactly those systems. + +## Invariants + +- `one-model`: server-authoritative, trusted co-op, witnessed and regioned + authority are configurations of one authority declaration, answering per + document: who writes, who checks, who arbitrates. No mode has its own API for + state, systems or queries. +- `modes-do-not-leak`: game code does not branch on the authority mode or ask + "am I the server"; a system states what it does and the runtime decides where + it runs. Where a mode cannot offer a capability, the declaration is refused at + startup with a reason, never degraded silently at runtime. +- `determinism-is-per-system`: only code a non-owner may re-execute must be + deterministic. That requirement is marked on the system and checked by an + analyzer at build time, not by documentation or by desync in production. +- `state-is-cultcache`: game state is ordinary CultCache documents; the + framework adds no second state model and no JSON on the load-bearing path. +- `authority-is-typed-state`: who holds authority over what is CultMesh state + (leases, shard primaries), inspectable and revocable, never inferred from + transport success or peer catalogs. +- `garden-path-needs-no-networking`: the reference game is written without the + developer touching sockets, serialisation, tick scheduling, prediction or + reconciliation code. +- `honest-guarantees`: each mode states its fault model (trusted server, trusted + peers, honest-majority sampled witnesses with server arbitration) and its + documentation and tests claim nothing past it. +- `browser-is-first-class`: the reference game's client runs in a browser and can + write (submit inputs, own predicted state), not only watch. +- `built-for-load`: what the garden path ships that is database-shaped (the + tick commit, the batch door, logs, subscriptions) is architected for + unreasonable load from the start (operator 2026-10-27, + `cultlib-gaps:ruling:database-shaped-built-for-load`): "if what we're shipping + is database shaped, people will use it as such". + +## Sequence (each a campaign or stage of its own; one foundation at a time) + +1. Server-authoritative garden path for the .io game: documents, systems, + ticked scheduling, client prediction and reconciliation, browser client that + writes. This is the proof of ergonomics. +2. Trusted co-op: authority moves to the owning peer; same game code. +3. Deterministic dispute kernel (CultMath fixed-point hit tests, cross-runtime + vectors) and the `[Adjudicable]` analyzer. +4. Witnessed authority: signed, hash-chained inputs; victim challenge; sampled + network-near witnesses; server arbitration; browser-to-browser channels. + Demonstration: a cheating client caught and corrected. +5. Regions: spatial authority handoff and interest management past one server. +6. Aetheria on the same declaration. + +## Not in scope + +- Aetheria until stage 6. Eve and Gjallar (deprecated). +- A new CultCache storage engine (the paged single-file store stays postponed, + `variants:ruling:page-tree-store-postponed`). The garden path fills gaps in + their owners, built for load. +- Asynchronous Byzantine total ordering, zero-knowledge proofs, and chains of + any kind. +- The existing witness aggregator (`CultNetSimulationConsensus`, unsigned and + weighted by observed votes) as a base for stage 4: it is the trusted co-op + first stage by design and will be replaced, not extended. + +## Open forks (to the Fork Desk, each with prior art first) + +1. Where simulation code runs in the browser: C# compiled to WebAssembly (one + implementation everywhere, which also shrinks the determinism problem to + fixed-point maths, at the cost of runtime download size), C# on the server + with TypeScript systems in the browser (two implementations of each predicted + system), or another arrangement. +2. Server transport for the browser: WebSocket as today, or WebTransport + (QUIC datagrams) on the provider side. +3. Tick model: fixed tick with a declared rate, or per-system rates. diff --git a/docs/cultmesh-ts-content-map.md b/docs/cultmesh-ts-content-map.md index 9aae6eba..de6b6d2f 100644 --- a/docs/cultmesh-ts-content-map.md +++ b/docs/cultmesh-ts-content-map.md @@ -34,7 +34,7 @@ ran under the session scratchpad (`imagination/`), not in the repo. | F13 | Cross-process precedent for C# against a Node provider exists. `packages/cultnet-ts/test/interop/cultnet-interop.test.ts:536-560` spawns the TS QUIC provider, then runs `dotnet test --filter NativeConnectorReceivesFromExternalProvider` with the endpoint in `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`. The C# test skips without the variable (`tests/GameCult.Mesh.Quic.Native.Tests`, line 21). | source | | F14 | `cultnet-ts` is CommonJS. Its `./wire`, `./contracts` and `./authority` subpaths import only `@msgpack/msgpack`, `ajv` and `cultcache-ts/document`, so they are browser-safe. Its root `index` pulls `node:` modules (`peer`, `rudp`, `transport`, `security` and others). `cultmesh-browser` (ESM) already imports `cultnet-ts/wire`, `/contracts` and `/authority`. `cultmesh-ts` loads `koffi` lazily, inside a function (`realtime-quic-native.ts:139`), so a new subpath of it pays no native load. The root has `esbuild` ^0.28.1 as a devDependency. | source, grep | | F15 | TS packages build with `tsc` and test with `node --test dist-test/test/*.test.js` per workspace. `scripts/run-typescript-workspaces.mjs` runs them in order `cultcache-ts, cultnet-ts, cultmesh-ts, cultmesh-browser`, after `check-typescript-tests-wired.mjs`. No StrykerJS config exists in CultLib (`docs/mutation-testing.md:156-159`). | source | -| F16 | **No CultLib TS package except `cultcache-ts` has a publish job.** `publish-packages.yml` triggers on `cultcache-ts-v*`, `cultcache-py-v*`, `cultnet-py-v*` and `cultmesh-py-v*` only. Tags `cultnet-ts-v0.1.1` and `cultmesh-ts-v0.1.1` exist, but no workflow consumes them. `npm view` returns 404 anonymously for `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser` and even `@gamecult/cultcache-ts` (`@gamecult/cultcache-ts` may be private; unverified). The doctrine's rule of at most three tags per push stands (SKILL.md:674). | source; `npm view` | +| F16 | **No CultLib TS package except `cultcache-ts` has a publish job.** `publish-packages.yml` triggered on `cultcache-ts-v*`, `cultcache-py-v*`, `cultnet-py-v*` and `cultmesh-py-v*` when this was written; it now triggers on the three `-py-v*` patterns only, because npm publishing is deliberately off until ruled on (a tag run uses the workflow at the tagged commit, so `cultcache-ts-v0.15.0` on `7d292085`, before that change, still ran the old one). Tags `cultnet-ts-v0.1.1` and `cultmesh-ts-v0.1.1` exist, but no workflow consumes them. `npm view` returns 404 anonymously for `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser` and even `@gamecult/cultcache-ts` (`@gamecult/cultcache-ts` may be private; unverified). The doctrine's rule of at most three tags per push stands (SKILL.md:674). | source; `npm view` | | F17 | StreamPixels consumes CultLib as the git submodule `vendor/CultLib` (gitlink `30ee8b97`) with `file:` deps and `pnpm.overrides`. Its CI and Idunn recipes build the vendored workspaces (`npm ci --ignore-scripts`, then build `cultcache-ts`, `cultnet-ts` and `cultmesh-ts`). `apps/web` depends only on `cultnet-ts` today, not on `cultmesh-browser`. | StreamPixels `.gitmodules`, `apps/*/package.json`, CI workflow lines 62-95 | | F18 | The Rust reader sets two guards that the TS reader should match. A caller cap `max_bytes` is checked before any request. And a zero-size chunk is refused unless it is the only one, because a manifest of zero-size chunks would be a request flood. | source `cultnet-rs/src/content.rs:364-385` | | F19 | `BuildChunkUri` on more endpoint shapes, as `AbsoluteUri` (what `HttpClient` sends). `https://h` and `https://h/` both give `https://h/?chunkHash=…&recordKey=k`. `https://h:443/x` drops the default port. `https://H.Example/X/` lowercases the host and keeps the path's case. `https://h/x#frag` keeps the fragment after the query. `https://h/x?a=%20b+c` keeps the existing query verbatim and joins with `&`. **`https://h/x?` (an empty query) gives `https://h/x?&chunkHash=…`**, because `UriBuilder.Query` is `?`, which is not blank. `https://[::1]:8443/c` keeps the bracketed host. `Uri.EscapeDataString("!'()*~-._ +é�")` is `%21%27%28%29%2A~-._%20%2B%C3%A9%F0%90%90%80`: unlike JS `encodeURIComponent`, it escapes `!'()*`. | probe: `dotnet fsi` on .NET 10, the three lines of `BuildChunkUri` (`CultMeshHttpsContentTransport.cs:114-122`) copied verbatim | diff --git a/docs/document-variants-cut.md b/docs/document-variants-cut.md index 854d6cbb..edf86d6a 100644 --- a/docs/document-variants-cut.md +++ b/docs/document-variants-cut.md @@ -91,6 +91,121 @@ Probes run 2026-10-12 from `F:\Projects\CultLib-c2afix` with git only (no builds `unstored-same-tick-unpinned`) caught at head. `git grep RawBytes de401056 -- src tests`: no match. Those findings were never closed in the mind. +### Body at 2026-10-15: element ids and the MessagePack walk (Modeling, from the Imagination and Soul passes) + +Probes ran on Yggdrasil (`mcr.microsoft.com/dotnet/sdk:10.0`, MessagePack 3.1.7 in every csproj) as probe +tests that were never committed: at `6afa8ed9` for I1 to I4, at `494ad9ab` for r4 and r5, at `6e70626b` for r6. +Logs and sources are under `F:\Projects\eureka-handoff-aux-2026-10-15\scratchpad-self-2026-10-14b-eureka\` +(`imag\`, `imag-r4\`, `imag-r5\`; Soul's `soul-c2a-id-holes-s5\` is in that session's scratchpad). The typed +record of each fact is in the mind: `variants:cut_spec:cut-c2a-element-id-holes.r5` and `.r6` (`first`), +verdicts `cut-c2a-element-id-holes.s5` and `.s6`, spec `cut-c2a-ids-header.r1` (`first`). This section keeps +what was run and what it showed. + +**What an id is on disk and in memory.** +- An id is an ordinary keyed string in the payload. A list element whose `[CultElementId]` member holds + `a1b2c3d4e5f6` and one whose plain `Tag` holds the same value encode to identical bytes + (`92A1649192AC61316232633364346535663607`; `c2a-ids-header` r1 probe, base `2774a7fe`). No reader can tell + from a record's bytes that it holds ids; only the store header says so. +- `StubArm`, a union arm that is not an object type, is never minted (`imag\imag1.log`, I1): for a document of + `StubArm` and `Unit8`, `Plan` counts 1, `StubArm.Id` stays `''`, `Unit8.Id` is minted, `Inspect` reports 0 + problems. +- Resolver over-mark (I2): a consumer `[assembly: CultCacheFormatterResolver]` claims a keyed arm with a + formatter that writes only `N`. The cache mints an id in memory, `Holds` is true and the header is stamped + `v3`, but the payload (`92A263319192009104`) carries no id, and a reopen mints a different one (`8f5e929888ca` + in memory, `a1ea2b683d98` after reload). The header claims ids the disk lacks. +- `[MessagePackFormatter]` on an element type is honoured (I3): the attribute formatter wrote + `92A26131919104`, no id, and `Inspect` reported 0 problems at that base. Registration refuses it from r5 on + (`declares [MessagePackFormatter]`). +- Python store (I4): `py-store/leaf-record.cc` (header `v1`, ids `000000000021/22`) loads in C# with + `HoldsIds = true`, `IdsInMemoryOnly = false`. A C# write that adds an unrelated record copies it forward, + the header stays `v1`, and the ids survive reload. cultcache-py, -ts and -rs have no declaration of which + member is an id, so they keep a `v3` they found and never decide one. +- Header narrowing (`c2a-ids-header` r1): per-record `StoredHoldsIds`, set from the type that decoded a + record, is falsified by the identical-bytes probe above. The durable header owns "these stored bytes hold + ids" at store granularity. A write that copies any record forward keeps a marked header; only a write that + encodes every record decides by content. `StoredHoldsIds` and `KnownIdFree` are deleted. +- Object list versus value list (r4, `imag-r4\probe1.log`, `probe2.log`): ids sit on object-list elements only + (ruling `q7-object-list-elements-only`). `Dictionary`, `ConcurrentDictionary`, `SortedDictionary`, + `IReadOnlyDictionary`, and `ILookup` of keyed arms round-trip with the id at + the element's own slot (e.g. `ConcDict` `81A16B92AC30313233343536373839616200`: key, then `[id, 0]`). A list + or map of scalars has no element to carry one and never enters the id path. + +**How MessagePack decides what it writes** (r5 `imag-r5\probe1.log`, `probe3.log`; Soul s5 `job2.log`). +- Member discovery, `MessagePack.Internal.ObjectSerializationInfo.CreateOrNull(Type, bool forceStringKey, + bool contractless, bool allowPrivate)`, is internal, and so is `StandardResolver.Resolvers`. The public + surface allows one test: `StandardResolver`'s formatter for `T` is reference-equal to the formatter one + public singleton hands out for `T`, each caching one instance per type. `DynamicObjectResolver.Instance` + matches keyed classes and structs, `DynamicUnionResolver.Instance` `[Union]` types, + `DynamicGenericResolver.Instance` List, arrays, maps, KeyValuePair, tuples, Lazy, Memory, ArraySegment and + Nullable, and `ImmutableCollectionResolver.Instance` (namespace `MessagePack.ImmutableCollection`) + `ImmutableArray` and `ImmutableList`. Asking `DynamicObjectResolver` for a collection type throws + `TypeInitializationException`, so a family probe swallows exceptions. +- Kind facts (`probe3`): a `[MessagePackObject]` class implementing only `IEnumerable` or + `IReadOnlyList` (`KBag`, `KNode`, `KRo`) is written by key (object resolver). One that is also + `ICollection`, a `List` subclass or a `Dictionary` subclass (`KC2`, `KColl`, `KList2`, `KDict`) is + written as a collection (generic resolver) and its `[Key]` members are dropped. +- Member facts (`probe3`; `ap` is `AllowPrivate`, `R` written, `W` read back). A member is written when it + has an integer `[Key]`, no `[IgnoreMember]`, and is a public field or a property with a public getter. It is + read back when it is a public non-readonly field, a property with a public setter (`init` counts), or a + `[SerializationConstructor]` parameter. At `ap=False`: `PG` (private getter) `R=False W=True`, `PS` + (private setter) `R=True W=False`, `GetOnly` `R=True W=False`, `InitOnly` both. `IdPriv` + (`Id { get; private set; }`) is written and not read back (wire `91AC62...`, back ``), and `IdCtor` is + `W=False`. At `ap=True` every one of these, `priv` included, is `R=True W=True`, except `GetOnly` + (`W=False`). +- AllowPrivate attribute form (Soul s5 `SoulS5Probe.C`, `ApTrueArm`): on a type declared + `[MessagePackObject(AllowPrivate = true)]`, `StandardResolver` writes private `[Key]` members and a + private-setter id round-trips. The oracle test calls `CreateOrNull(type, false, false, false)` and reports + none of them, so the walk's member set misses that form. +- Collection written as object (r6 `probe2.log`): a `[DataContract]` class implementing only `IEnumerable` + over an id-carrying arm (`DcSeq`) is a collection in the walk, yet `StandardResolver`'s formatter is + `DynamicObjectResolver`'s (wire `91 91 91 92 AC..`, written as an object). A `[DataContract]` `List` + subclass (`DcColl`) is written by the generic resolver, and the object resolver hands out a different + instance for it. For `DcBad` (two members on one `Order`) `StandardResolver` uses the generic resolver, and + Soul s5 measured both `DcColl` and `DcBad` passing the door. +- Geometry (r6 `probe2.log`, `probe3.log`): Geometry has a direct MessagePack 3.1.7 `PackageReference`, so the + source generator runs, and its assembly carries `[GeneratedAssemblyMessagePackResolverAttribute( + typeof(GeneratedMessagePackResolver), 3, 1)]`. For `CultGeometryDomainNode`, `DomainDocument` and + `FeatureClaim`, `StandardResolver` returns `GeneratedMessagePackResolver`'s formatter and every public + singleton returns a different instance or null. For `CultGeometryDomainNode[]` and `FeatureClaim[]` it is + the generated resolver's `ArrayFormatter`, not `DynamicGenericResolver`'s. No Geometry id-path type matches + a family singleton. Caching.Tests has no generator (analyzers do not flow through a `ProjectReference`), so + its keyed types match `DynamicObjectResolver`. +- Generated assemblies (Soul s5, fresh generator-enabled assembly `SoulGen`, `job2.log` GEN rows): a keyed + `List` or `Dictionary` subclass (`GKColl`, `GKDict`) is written by key, wire `["c",[7]]`, with its elements + dropped by MessagePack; the walk reads it by key too (Plan 0). A generated-assembly `[DataContract]` + enumerable (`GDcSeq`) is still written by `DynamicObjectResolver`, because the generator emits no + DataContract formatters. +- Null standard (Soul s6 finding `null-standard-untyped`): for a non-generic interface or abstract class that + enumerates arms, `StandardResolver` returns null instead of throwing, and the door neither refuses nor + types it. +- Wire pin (Soul s5, s6 `SoulS5Pin`): a deterministic document with 12 minted ids, encoded by the codec and + decoded independently with `MessagePackSerializer.ConvertToJson`, carries every id. The ids are + byte-identical at `494ad9ab` and `57fdf70c`, `0f8964b58a08` and `7eb4fb13d530` among them. +- Not measured: Unity/IL2CPP, where the package bundles its own `MessagePack.dll` (version not read) and the + dynamic resolvers cannot emit, and MessagePack versions other than 3.1.7. + +**Whole-list holders at `57fdf70c`** (Imagination r2 for `c2a-id-closed-world`, 2026-10-16; probe tests in a +scratch commit `dc629459` that was never pushed, Yggdrasil `sdk:10.0`; logs `job1.log` and `job2.log` in the +Imagination scratchpad `imagination-c2a-closed-world-r2`). +- Shapes the walk does not own already behave as whole values. A keyed document holding + `ImmutableArray`, `(List, int)`, `KeyValuePair>`, a non-enumerable + `[DataContract]` holder, or a string-keyed holder (`[Key("items")]`) gives `Inspect` problems 0, an empty + IdPath, `CanHoldIds` false, `Plan` 0 (random and deterministic), and `Holds` false with ids set. +- The marker and the copy check do not count them. Committed with ids `aaaaaaaaaaaa`/`bbbbbbbbbbbb`, the store + header stays `cultcache.store.v1`. A fresh cache that writes another record over that file (the copy-forward + check) keeps `v1`. Reload returns the ids exactly as written, and unset ids stay null after write and reload. +- Variants replace them whole, and that is the C2a resolver's behaviour for every list. A variant `Set` of the + `ImmutableArray` slot resolved to `[9:eeeeeeeeeeee]` with the base untouched, header `v2`, `HoldsIds` false. + A path step `[1:aaaaaaaaaaaa]` is refused: "this runtime resolves top-level Set overrides only". No C2a gap. + The gap belongs to C2b, in the cut sequence below. +- AllowPrivate is read from the most derived `[MessagePackObject]`. `ApDerived` (AllowPrivate over a base + without it) is written `[[],[],4,[]]`: base-chain privates and a private-getter property, with + `ObjectSerializationInfo` at `allowPrivate: true` = `[baseStash, own, N, Pg]`. `NoApDerived` (plain over an + AllowPrivate base) is written `[nil, 5]`. The walk read only `[N]` for both. +- A `[DataContract]` `List` subclass is written by `NonGenericListFormatter`, and its record does not read + back ("System.Object[] is not of type PElem"), whatever the walk does. That is a MessagePack limit, not an + id fact. + ## 2. Model page (step 0b) | Kind | Named by | Over time | Decided by | @@ -99,16 +214,16 @@ Probes run 2026-10-12 from `F:\Projects\CultLib-c2afix` with git only (no builds | Base reference | the base's key, in the record's variant slot | changed only by an explicit rebase; a base cannot be deleted while it has variants, unless the same batch rebases or removes them | author; the cache refuses a cycle, a missing base, a different concrete type, a global type | | Override entry | `(op, path, id, value)`; op is Set, Insert or Remove | added by editing; removed by clearing; survives base edits while its path resolves | author; the cache refuses entries a base edit strands (Q2); an entry whose member left the type soft-drifts with a warning (Q4 c) | | Member path | a sequence of `(slot, elementId)` steps; `slot` is the MessagePack `[Key]`; `elementId` is `""` unless the step enters an object-list element | a type change can invalidate it | the type's owner (slot authority); ids by the element's creator | -| Element id | a `[CultElementId]` string member on an object-list element type; `""` means unset | minted by the cache on write when unset; copied unchanged into variants; a moved element is a new element | cache mints it; it is never retargeted | +| Element id | a `[CultElementId]` string member on the element type of an object list in a shape the walk owns (arrays, `IEnumerable`, `IDictionary`/`IReadOnlyDictionary` values, `[MessagePackObject]`/`[Union]` types with integer keys), one MessagePack writes and reads back (integer `[Key]`, no `[IgnoreMember]`; public, or any visibility under the most derived `AllowPrivate`); `""` means unset. **Exception (q3 amended 2026-10-16):** a shape the walk does not own is one whole value: `ImmutableArray`, `ArraySegment`, `Memory`, `KeyValuePair`, tuples, `Lazy`, unkeyed or `[DataContract]` classes, string-keyed types, a type or member with `[MessagePackFormatter]`, and a non-public member without `AllowPrivate`. Its elements carry no ids: a `[CultElementId]` inside is a plain string | minted by the cache on write when unset; copied unchanged into variants; a moved element is a new element. Inside a whole value: never minted, never checked, never counted for `v3` | cache mints it; it is never retargeted. `CultElementIds.Classify` alone decides owned versus whole | | Resolved view | `CultStoredDocument.Document` for a variant key | recomputed inside the hold that admits the variant, or any base on its chain | the cache only; never persisted, never written back (Q1) | | Index and name entries | as today, from the resolved view | a dependent's entries are dropped and re-added in the same `Apply` as its base change | the cache; a variant must override the `[CultName]` member (rule R6) | | Watches | as today; each re-resolved dependent emits `Updated` with its own `Sequence` | published in the same hold as the base change | the cache | | Compare-exchange | `(schemaId, storedAt)` of the stored delta (ruling 2) | a base change does not stamp its variants | the cache; the resolved view is not an identity | -| Persisted format version | store header `formatVersion` | `cultcache.store.v2` when the store holds at least one variant; `v1` otherwise (Q5) | the writing store, from content | +| Persisted format version | store header `formatVersion` | `cultcache.store.v2` when the store holds at least one variant; `v1` otherwise (Q5); a marked `v3` (`v5` directory) stays while a write copies any record forward, and only a write that encodes every record drops it by content | the writing store, from content; the durable header alone says the bytes hold element ids | | Single-file store file | the path; one MessagePack array `[header, catalog, records]` | replaced whole and atomically; refused when unreadable; see "Store authorities" | each runtime's one reader decides what is a store; the writer derives the header from it | | Store lock and staging files | `.lock`; a unique temp beside the store | the lock is taken by C# and Rust writers (CultMesh included, through `ReplaceDurable`); staging is removed on a failed write | the store, never CultMesh or a consumer | | Variant slot on the wire (CultNet) | **empty cell**: no CultNet message carries a delta today | C5 | ruling 4 decides deltas; the message shape is C5's design | -| Dictionary entries, scalar lists, arrays | **empty cell**: no stable identity exists | whole-member Set only (Q7) | Q7 | +| Dictionary entries, scalar lists, arrays, whole-list holders | **empty cell**: no stable identity exists | whole-member Set only (Q7; whole-list holders by q3 amended) | Q7, q3 amended | | Template (non-item) bases | **empty cell**: parked by ruling 6 | none | not in these cuts | ## 3. Where the Body contradicts the target @@ -220,20 +335,21 @@ Dictionary entries, scalar lists and arrays are replaced whole. - **Recommend yes.** Aetheria's cases (`Hardpoints` whole; `Behaviors[id]...Max`) fit. Keyed dictionary addressing can be added later without a format change. Affects C2. -## Cut sequence (2026-10-12) +## Cut sequence (2026-10-15) -Mapped now (specs in the mind): `c2a-declared-apply` r2, the C# commit-time fix batch on -`hands/variants-c2a` (the stash, finished, with tests and contract text), and its sibling -`main-into-c2a` r2, main merged into C2a on `hands/variants-c2a-main` in its own worktree. Later -cuts, one line each, mapped only when Self is about to dispatch them: +Landed since 2026-10-12: `c2a-declared-apply` (r3, Soul s5) and `main-into-c2a` (r2, Soul s2); the +reconcile step is done, `hands/variants-c2a` is at the reconciled head `6afa8ed9`. Element ids part 1, +`c2a-element-id-holes` (r6), is held by Soul s5/s6 on `hands/variants-c2a-element-id-holes`. Mapped +now: part 2, `c2a-id-closed-world` r2 (2026-10-16, under the amended q3), not blocked. Its open fork +`variants:question:c2a-whole-list-opt-in` holds under every option. Later cuts, one line each, mapped +only when Self is about to dispatch them: -1. **reconcile**: merge `hands/variants-c2a` into `hands/variants-c2a-main` after both Soul passes - hold; Self moves `hands/variants-c2a` there. Waits on both mapped cuts. +1. **reconcile**: done at `6afa8ed9`. 2. **parity-ts**: TypeScript write set, one record per key, undeclared records carried and refused, - unheld removals judged at commit. Waits on reconcile. + unheld removals judged at commit. Waits on element ids parts 1 and 2 merged into C2a. 3. **parity-rs**: Rust `push_all` and `put_prepared_batch` apply a write set, ownership by - declaration, the same commit-time checks. Waits on reconcile; can run beside parity-ts. -4. **parity-py**: Python write-set apply. Waits on reconcile and on `cultlib-gaps` cut + declaration, the same commit-time checks. Waits as parity-ts; can run beside it. +4. **parity-py**: Python write-set apply. Waits as parity-ts, and on `cultlib-gaps` cut `py-msgpack-default` landing on main (then a main merge into C2a first). 5. **write-set-vectors**: one cross-runtime manifest, read and acted on by all four runtimes; carries follow-ups `c0-bad-canonical-in-manifest`, `legacy-vectors-all-runtimes`, @@ -242,6 +358,11 @@ cuts, one line each, mapped only when Self is about to dispatch them: with an empty diff against the verified head; no tag. Waits on write-set-vectors, Soul holding, and `asura:cut_spec:cut-semver-measured` landed. Asura Cut 3 bases on its merge commit. +Element-id part 2: `c2a-id-closed-world` r2 decides deferred findings `s5.member-set-misses-allowprivate`, `s5.enumerating-rule-unpinned`, `s6.null-standard-untyped`, `s6.two-recognisers-one-rule`, `s4.unrecognised-holders-fail-open` and follow-up `c2a-datacontract-holder-unwalked` (see its `first`). + +- **marker-for-whole-lists** (only if `c2a-whole-list-opt-in` is ruled `marker-when-ids-declared` or `marker-for-any-keyed-object`): a CarriesElements predicate, a refusal unless the member is marked, and a public attribute. It is additive after part 2. +- **C2b constraint** (owed to `variants:follow_up:remaining-variant-cuts`): nested path resolution must refuse a step `[slot:elementId]` that enters a whole value, naming the holder. A whole value is any shape `CultElementIds.Classify` calls Leaf, so C2b asks Classify and keeps no recogniser of its own. Today's C2a resolver refuses every non-top-level step, so no store can hold such an override yet. + Rationale. The 2026-10-02 order (parity, vectors, then merge main) was right when main was close. Main has since moved 453 commits and rebuilt exactly what the parity cuts change: cultcache-ts payload ownership, cultmesh-ts, cultcache-rs. Parity mapped before the merge would be mapped @@ -1484,7 +1605,7 @@ The one door per runtime, and who walks through it: - **Inputs.** The bytes at the path. **Outputs.** The decoded store with header and catalog; or a typed refusal (`CultStoreUnreadableException` in C#; `CultCacheStoreUnreadable{path, kind}` in Rust, kind `Undecodable` or `UnsupportedFormat`; `StoreUnreadableError` in TS and Python). A refusal names a header only as `cultcache.store.v`. - **Derived state.** The write header is never decided by the writer: it is the reader's verdict (header read, whole-store flag, whether ids or variants are held). A rewrite of a file that cannot be read cannot happen, because the reader throws first. - **Forbidden writers.** A second header classifier. Deleted: Rust `leading_header`, `read_array_header`, `read_string`; cultnet-ts's own decode of the store header; the TS async `readStore`; Rust `StoreUnreadableError` (negative grep `StoreUnreadableError` in `packages/cultcache-rs`: no match); Python `_decode_v1_snapshot`. -- **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 declares cultcache-ts `^0.15.0`, the first release that exports 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. - **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). - **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). diff --git a/docs/document-variants-id-sweep.md b/docs/document-variants-id-sweep.md new file mode 100644 index 00000000..03fad9cb --- /dev/null +++ b/docs/document-variants-id-sweep.md @@ -0,0 +1,272 @@ +# Element-id sweep list (C2a) + +Data for the per-consumer sweep in docs/document-variants-cut.md, C2. Every object type below sits in a list (`List`, `T[]`, `IEnumerable`, and the like) reachable from a registered document type, and needs one string member with `[Key(n)]` and `[CultElementId]` before that consumer can take a CultLib that requires ids. A union base takes the member on the base; its subclasses inherit it, so put the slot above the subclasses' highest key. + +Method: a static scan of `F:\Projects\` 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. + +Lines: `Type file:line (via Owner.Member)`, paths relative to the consumer root. + + +## Aetheria (13 element types) + +- BehaviorData Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:190 (via ConsumableItemData.Behaviors) +- EntityPack Assets/Scripts/ServerShared/EntitySerializer.cs:171 (via ZonePack.Entities) +- ProductRole Assets/Scripts/ServerShared/FactionProduct.cs:38 (via FactionProductData.Roles) +- InputLayoutRow Assets/Scripts/ServerShared/InputLayout.cs:32 (via InputLayout.Rows) +- InputLayoutColumn Assets/Scripts/ServerShared/InputLayout.cs:52 (via InputLayoutKeyRow.Columns) +- ItemRole Assets/Scripts/ServerShared/ItemData.cs:325 (via CraftedItemData.Roles) +- AudioStat Assets/Scripts/ServerShared/ItemData.cs:441 (via EquippableItemData.AudioStats) +- HardpointData Assets/Scripts/ServerShared/ItemData.cs:553 (via HullData.Hardpoints) +- StatTerm Assets/Scripts/ServerShared/ItemData.cs:630 (via PerformanceStat.Terms) +- LoadoutSlot Assets/Scripts/ServerShared/Loadout.cs:23 (via Loadout.Slots) +- RoleFill Assets/Scripts/ServerShared/Provenance.cs:84 (via Lot.Roles) +- SavedActionBarBinding Assets/Scripts/ServerShared/SavedGame.cs:168 (via SavedGame.ActionBarBindings) +- Asteroid Assets/Scripts/ServerShared/ZoneData.cs:133 (via AsteroidBeltData.Asteroids) + +## AetheriaEve (115 element types) + +- AetheriaCorporationAllegiance Aetheria.State/Documents/AetheriaCorporation.cs:67 (via AetheriaCorporation.Allegiances) +- AetheriaShapeCell Aetheria.State/Documents/AetheriaItemDefinition.cs:178 (via AetheriaItemDefinition.ShapeCells) +- AetheriaItemHardpoint Aetheria.State/Documents/AetheriaItemDefinition.cs:188 (via AetheriaItemDefinition.Hardpoints) +- AetheriaCurveKey Aetheria.State/Documents/AetheriaItemDefinition.cs:222 (via AetheriaItemDefinition.ThermalPerformanceCurveKeys) +- AetheriaItemAudioStat Aetheria.State/Documents/AetheriaItemDefinition.cs:238 (via AetheriaItemDefinition.AudioStats) +- AetheriaBehaviorPayload Aetheria.State/Documents/AetheriaItemDefinition.cs:267 (via AetheriaItemDefinition.BehaviorPayloads) +- AetheriaBehaviorField Aetheria.State/Documents/AetheriaItemDefinition.cs:286 (via AetheriaBehaviorPayload.Fields) +- AetheriaBehaviorMapEntry Aetheria.State/Documents/AetheriaItemDefinition.cs:296 (via AetheriaBehaviorValue.MapEntries) +- AetheriaBehaviorValue Aetheria.State/Documents/AetheriaItemDefinition.cs:306 (via AetheriaBehaviorValue.Children) +- AetheriaLegacyCatalogFile Aetheria.State/Documents/AetheriaLegacyCatalogQuarantine.cs:41 (via AetheriaLegacyCatalogQuarantine.NameFiles) +- AetheriaMigrationCount Aetheria.State/Documents/AetheriaMigrationLedger.cs:35 (via AetheriaMigrationLedger.Counts) +- AetheriaLegacyCatalogEntrySummary Aetheria.State/Documents/AetheriaMigrationLedger.cs:45 (via AetheriaMigrationLedger.LegacyCatalogEntries) +- AetheriaStoryFileHash Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:42 (via AetheriaPlayerSettings.StoryFileHashes) +- AetheriaInputBindingOverride Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:85 (via AetheriaPlayerInputSettings.BindingOverrides) +- AetheriaEntityLoadout Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:119 (via AetheriaEntityLoadout.Children) +- AetheriaLoadoutItemSlot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:186 (via AetheriaEntityLoadout.Equipment) +- AetheriaCargoBayLoadout Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:199 (via AetheriaEntityLoadout.CargoContents) +- AetheriaFactionZoneState Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:268 (via AetheriaRunState.HomeZones) +- AetheriaAgentTaskState Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:275 (via AetheriaRunState.AgentTasks) +- AetheriaDroppedPickupSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:346 (via AetheriaZoneState.DroppedPickups) +- AetheriaOrbitSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:362 (via AetheriaZoneState.Orbits) +- AetheriaBodySnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:384 (via AetheriaZoneState.Bodies) +- AetheriaBodyResource Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:449 (via AetheriaBodySnapshot.Resources) +- AetheriaAsteroidSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:459 (via AetheriaBodySnapshot.Asteroids) +- AetheriaAsteroidMiningAccumulatorSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:484 (via AetheriaAsteroidSnapshot.MiningAccumulators) +- AetheriaColor Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:545 (via AetheriaGasGiantVisualState.Colors) +- AetheriaFogFieldEmitterSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:691 (via AetheriaEntitySnapshot.FogFieldEmitters) +- AetheriaLoadoutGenerationSelection Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:723 (via AetheriaLoadoutGenerationReceipt.Selections) +- AetheriaEntityContactSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:734 (via AetheriaEntitySnapshot.Contacts) +- AetheriaEntityItemSlot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:751 (via AetheriaEntitySnapshot.Equipment) +- AetheriaWeaponGroupSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:782 (via AetheriaEntitySnapshot.WeaponGroups) +- AetheriaEntityStatGrid Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:789 (via AetheriaEntitySnapshot.StatGrids) +- AetheriaActiveConsumableSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:805 (via AetheriaEntitySnapshot.ActiveConsumables) +- AetheriaConsumableBehaviorStateSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:828 (via AetheriaActiveConsumableSnapshot.BehaviorStates) +- AetheriaBehaviorProgressSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:844 (via AetheriaEntitySnapshot.BehaviorProgress) +- AetheriaWeaponStateSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:863 (via AetheriaEntitySnapshot.WeaponStates) +- AetheriaBehaviorStateSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:924 (via AetheriaEntitySnapshot.BehaviorStates) +- AetheriaFactionRelationshipState Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:1093 (via AetheriaRunState.FactionRelationships) +- AetheriaItemRarityTier Aetheria.State/Documents/AetheriaTradeValuePolicy.cs:29 (via AetheriaTradeValuePolicy.Tiers) +- AetheriaRuntimeAgentTaskCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAgentTasks.cs:35 (via AetheriaRuntimeRunCheckpointCommit.AgentTasks) +- AetheriaRuntimeAssetManifestEntry Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssetDocuments.cs:102 (via AetheriaRuntimeAssetManifestDocument.Assets) +- AetheriaRuntimeCurveKey Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs:446 (via AetheriaRuntimeShotReceiptCommit.GuidanceCurve) +- AetheriaRuntimeCorporation Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs:620 (via AetheriaRuntimeCatalogSnapshot.Corporations) +- AetheriaRuntimeNameFile Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs:669 (via AetheriaRuntimeCatalogSnapshot.NameFiles) +- AetheriaRuntimeLoadoutTemplateSnapshot Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs:796 (via AetheriaRuntimeLoadoutTemplatesDocument.Templates) +- AetheriaRuntimeDaemonCommandBoundaryEntry Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs:462 (via AetheriaRuntimeDaemonCommandBoundaryDocument.Commands) +- AetheriaRuntimeDaemonSoaIdentityDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:143 (via AetheriaRuntimeDaemonSoaViewDocument.Identities) +- AetheriaRuntimeDaemonRenderGroupDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:156 (via AetheriaRuntimeDaemonSoaViewDocument.RenderGroups) +- AetheriaRuntimeDaemonSoaBufferDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:222 (via AetheriaRuntimeDaemonSoaViewDocument.Buffers) +- AetheriaRuntimeDaemonSoaColumnDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:253 (via AetheriaRuntimeDaemonSoaViewDocument.Columns) +- AetheriaRuntimeDaemonSoaDirtyRangeDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:284 (via AetheriaRuntimeDaemonSoaViewDocument.DirtyRanges) +- AetheriaRuntimeEveSurfaceAdvertisement Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs:11 (via AetheriaRuntimeDaemonProviderAdvertisementDocument.EveSurfaces) +- AetheriaRuntimeEvePluginRequirement Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs:58 (via AetheriaRuntimeEveSurfaceAdvertisement.RequiresPlugins) +- AetheriaRuntimeZoneTargetRow Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:410 (via AetheriaRuntimeZoneContactsDocument.Targets) +- AetheriaRuntimeZoneContactRow Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:441 (via AetheriaRuntimeZoneContactsDocument.Contacts) +- AetheriaRuntimeStationStockItem Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:551 (via AetheriaRuntimeStationRefitEntityOption.CargoItems) +- AetheriaRuntimeStationRefitEntityOption Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:585 (via AetheriaRuntimeStationRefitDocument.AvailableEntities) +- AetheriaRuntimeStationDockingBayRow Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:620 (via AetheriaRuntimeStationRefitDocument.DockingBays) +- AetheriaRuntimeStationLoadoutRestoreOption Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:655 (via AetheriaRuntimeStationRefitDocument.LoadoutRestoreOptions) +- AetheriaRuntimeStationCargoTargetRow Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:674 (via AetheriaRuntimeStationRefitDocument.CargoTargets) +- AetheriaRuntimeSectorMapZone Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:765 (via AetheriaRuntimeSectorMapDocument.Zones) +- AetheriaRuntimeSectorMapLink Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:802 (via AetheriaRuntimeSectorMapDocument.Links) +- AetheriaRuntimeZoneRenderAdjacentZone Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1197 (via AetheriaRuntimeZoneRenderDocument.AdjacentZones) +- AetheriaRuntimeZoneRenderWormholeExit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1210 (via AetheriaRuntimeZoneRenderDocument.WormholeExits) +- AetheriaRuntimeZoneRenderBodyPose Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1229 (via AetheriaRuntimeZoneRenderDocument.BodyPoses) +- AetheriaRuntimeZoneRenderAsteroidBeltPose Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1260 (via AetheriaRuntimeZoneRenderDocument.AsteroidBeltPoses) +- AetheriaRuntimeZoneRenderAsteroidInstancePose Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1286 (via AetheriaRuntimeZoneRenderAsteroidBeltPose.InstancePoses) +- AetheriaRuntimeViewportObject Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1382 (via AetheriaRuntimeGameViewportDocument.Objects) +- AetheriaRuntimeInventoryItem Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1458 (via AetheriaRuntimeViewportObject.Inventory) +- AetheriaRuntimeGravityInfluence Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1493 (via AetheriaRuntimeGameViewportDocument.GravityInfluences) +- AetheriaRuntimeBodyView Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1530 (via AetheriaRuntimeGameViewportDocument.Bodies) +- AetheriaHangarShip Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs:119 (via AetheriaHangarState.Ships) +- AetheriaHangarItemStack Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs:129 (via AetheriaHangarState.Inventory) +- AetheriaHangarCurrency Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs:136 (via AetheriaHangarState.Currencies) +- AetheriaDeploymentReceipt Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs:155 (via AetheriaHangarState.Deployments) +- AetheriaProgressionVerseOption Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProgressionSourceDocuments.cs:45 (via AetheriaProgressionSourceDocument.AvailableVerses) +- AetheriaRuntimeStoryFileHashDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSettingsDocuments.cs:87 (via AetheriaRuntimePlayerSettingsDocument.StoryFileHashes) +- AetheriaRuntimeInputBindingOverrideDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSettingsDocuments.cs:108 (via AetheriaRuntimePlayerSettingsDocument.BindingOverrides) +- AetheriaRuntimeEntityLoadoutCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:78 (via AetheriaRuntimeEntityLoadoutCommit.Children) +- AetheriaRuntimeLoadoutItemSlotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:143 (via AetheriaRuntimeEntitySnapshotCommit.Equipment) +- AetheriaRuntimeCargoBayLoadoutCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:159 (via AetheriaRuntimeEntitySnapshotCommit.CargoContents) +- AetheriaRuntimeGameEventCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:490 (via AetheriaRuntimeRunCheckpointCommit.GameEvents) +- AetheriaRuntimeFactionZoneCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:509 (via AetheriaRuntimeRunCheckpointCommit.HomeZones) +- AetheriaRuntimeShotReceiptCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:539 (via AetheriaRuntimeRunCheckpointCommit.ShotReceipts) +- AetheriaRuntimePickupContactReceiptCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:591 (via AetheriaRuntimeRunCheckpointCommit.PickupContactReceipts) +- AetheriaRuntimeDamageCellCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:603 (via AetheriaRuntimeShotReceiptCommit.DamageCells) +- AetheriaRuntimeCorporationSurveyCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:614 (via AetheriaRuntimeRunCheckpointCommit.CorporationSurveys) +- AetheriaRuntimeFactionRelationshipCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:623 (via AetheriaRuntimeRunCheckpointCommit.FactionRelationships) +- AetheriaRuntimeZoneSnapshotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:636 (via AetheriaRuntimeRunCheckpointCommit.Zones) +- AetheriaRuntimePhysicalPayloadCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:699 (via AetheriaRuntimeZoneSnapshotCommit.PhysicalPayloads) +- AetheriaRuntimeDroppedPickupCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:771 (via AetheriaRuntimeZoneSnapshotCommit.DroppedPickups) +- AetheriaRuntimeOrbitSnapshotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:805 (via AetheriaRuntimeZoneSnapshotCommit.Orbits) +- AetheriaRuntimeBodySnapshotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:830 (via AetheriaRuntimeZoneSnapshotCommit.Bodies) +- AetheriaRuntimeBodyResourceCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:901 (via AetheriaRuntimeBodySnapshotCommit.Resources) +- AetheriaRuntimeAsteroidCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:911 (via AetheriaRuntimeBodySnapshotCommit.Asteroids) +- AetheriaRuntimeAsteroidMiningAccumulatorCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:936 (via AetheriaRuntimeAsteroidCommit.MiningAccumulators) +- AetheriaRuntimeColorCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1008 (via AetheriaRuntimeGasGiantVisualCommit.Colors) +- AetheriaRuntimeEntitySnapshotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1024 (via AetheriaRuntimeZoneSnapshotCommit.Entities) +- AetheriaRuntimeFogFieldEmitterCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1198 (via AetheriaRuntimeEntitySnapshotCommit.FogFieldEmitters) +- AetheriaRuntimeEquipmentStateCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1240 (via AetheriaRuntimeEntitySnapshotCommit.EquipmentStates) +- AetheriaRuntimeLoadoutGenerationSelectionCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1264 (via AetheriaRuntimeLoadoutGenerationReceiptCommit.Selections) +- AetheriaRuntimeEntityContactCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1275 (via AetheriaRuntimeEntitySnapshotCommit.Contacts) +- AetheriaRuntimeEntityStatGridCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1291 (via AetheriaRuntimeEntitySnapshotCommit.StatGrids) +- AetheriaRuntimeActiveConsumableCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1307 (via AetheriaRuntimeEntitySnapshotCommit.ActiveConsumables) +- AetheriaRuntimeConsumableBehaviorStateCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1330 (via AetheriaRuntimeActiveConsumableCommit.BehaviorStates) +- AetheriaRuntimeBehaviorProgressCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1355 (via AetheriaRuntimeEntitySnapshotCommit.BehaviorProgress) +- AetheriaRuntimeWeaponStateCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1374 (via AetheriaRuntimeEntitySnapshotCommit.WeaponStates) +- AetheriaRuntimeBehaviorStateCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1444 (via AetheriaRuntimeEntitySnapshotCommit.BehaviorStates) +- AetheriaRuntimeStarbridgeStationStockItem Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStarbridgeDocuments.cs:71 (via AetheriaRuntimeStarbridgeScenarioDocument.StationStock) +- AetheriaRuntimeStarbridgeWaveDefinition Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStarbridgeDocuments.cs:83 (via AetheriaRuntimeStarbridgeScenarioDocument.Waves) +- AetheriaRuntimeStarbridgeWaveForecast Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStarbridgeDocuments.cs:94 (via AetheriaRuntimeStarbridgeSessionSummaryDocument.WaveForecast) +- AetheriaRuntimeStarbridgeRuntimeRole Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStarbridgeDocuments.cs:104 (via AetheriaRuntimeStarbridgeScenarioDocument.RuntimeRoles) +- AetheriaRuntimeAuthorityRuntimeRole Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseAuthorityPolicy.cs:194 (via AetheriaRuntimeVerseAuthorityPolicyDocument.RuntimeRoles) +- AetheriaRuntimeAuthorityRule Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseAuthorityPolicy.cs:201 (via AetheriaRuntimeVerseAuthorityPolicyDocument.Rules) +- AetheriaRuntimeArenaSeat Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseAuthorityPolicy.cs:255 (via AetheriaRuntimeArenaRosterDocument.Seats) + +## AquaSynth (0 element types) + +None. + + +## Aquarium (1 element types) + +- AquariumFaceVoiceEndpointState src/Aquarium.Epiphany/State/AquariumLiveState.cs:61 (via AquariumLiveState.FaceVoiceEndpoints) + +## Brokkr (4 element types) + +- BrokkrGameObjectSnapshot surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs:42 (via BrokkrHostSnapshot.sceneObjects) +- BrokkrComponentSnapshot surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs:62 (via BrokkrGameObjectSnapshot.components) +- BrokkrSerializedPropertySnapshot surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs:73 (via BrokkrComponentSnapshot.properties) +- BrokkrAssetSnapshot surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs:84 (via BrokkrHostSnapshot.assets) + +## Delvehold (0 element types) + +None. + + +## Eve (17 element types) + +- EveAssetCatalogEntry packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs:52 (via EveAssetCatalogDocument.Assets) +- EveAssetVariant packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs:89 (via EveAssetCatalogEntry.Variants) +- EveEntitySoaBuffer packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:33 (via EveEntitySoaViewDocument.Buffers) +- EveEntitySoaColumn packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:41 (via EveEntitySoaViewDocument.Columns) +- EveEntitySoaDirtyRange packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:55 (via EveEntitySoaViewDocument.DirtyRanges) +- EveEntityIdentity packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:64 (via EveEntitySoaViewDocument.Identities) +- EveEntityRenderGroup packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:77 (via EveEntitySoaViewDocument.RenderGroups) +- EveInputActionDocument packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:22 (via EveInputCapabilityDocument.Actions) +- EveInputProfileDocument packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:51 (via EveInputCapabilityDocument.DefaultProfiles) +- EveInputBindingDocument packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:59 (via EveInputProfileDocument.Bindings) +- EveProviderWitness packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:249 (via EveProviderAdvertisementDocument.Witnesses) +- EveAdvertisedSurface packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:265 (via EveProviderAdvertisementDocument.Surfaces) +- EveAdvertisedCommand packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:333 (via EveProviderAdvertisementDocument.Commands) +- EveSurfaceComponent packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:106 (via EveSurfaceComponent.Children) +- EveEmbeddedDocumentSlot packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:230 (via EveSurfaceComponent.EmbeddedDocuments) +- EveStyleToken packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:277 (via EveSurfaceTree.Styles) +- EveCommandTemplate packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:295 (via EveSurfaceDocument.Commands) + +## EvePlugins (1 element types) + +- EveFieldsSplatLayer plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields/Runtime/EveFieldsDocuments.cs:38 (via EveFieldsSplatsDocument.Layers) + +## Ghostlight (17 element types) + +These types are a vendored copy of Eve (`vendor/eve`); they change when Eve does and the pin bumps. + +- EveAssetCatalogEntry vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs:52 (via EveAssetCatalogDocument.Assets) +- EveAssetVariant vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs:89 (via EveAssetCatalogEntry.Variants) +- EveEntitySoaBuffer vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:33 (via EveEntitySoaViewDocument.Buffers) +- EveEntitySoaColumn vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:41 (via EveEntitySoaViewDocument.Columns) +- EveEntitySoaDirtyRange vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:55 (via EveEntitySoaViewDocument.DirtyRanges) +- EveEntityIdentity vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:64 (via EveEntitySoaViewDocument.Identities) +- EveEntityRenderGroup vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:77 (via EveEntitySoaViewDocument.RenderGroups) +- EveInputActionDocument vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:22 (via EveInputCapabilityDocument.Actions) +- EveInputProfileDocument vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:51 (via EveInputCapabilityDocument.DefaultProfiles) +- EveInputBindingDocument vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:59 (via EveInputProfileDocument.Bindings) +- EveProviderWitness vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:249 (via EveProviderAdvertisementDocument.Witnesses) +- EveAdvertisedSurface vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:265 (via EveProviderAdvertisementDocument.Surfaces) +- EveAdvertisedCommand vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:333 (via EveProviderAdvertisementDocument.Commands) +- EveSurfaceComponent vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:106 (via EveSurfaceComponent.Children) +- EveEmbeddedDocumentSlot vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:230 (via EveSurfaceComponent.EmbeddedDocuments) +- EveStyleToken vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:277 (via EveSurfaceTree.Styles) +- EveCommandTemplate vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:295 (via EveSurfaceDocument.Commands) + +## Gjallar (3 element types) + +- GjallarAdvertisedSchema src/Gjallar/VerseState.cs:462 (via GjallarProviderAdvertisementRecord.Schemas) +- GjallarAdvertisedWitness src/Gjallar/VerseState.cs:470 (via GjallarProviderAdvertisementRecord.Witnesses) +- GjallarAdvertisedSurface src/Gjallar/VerseState.cs:480 (via GjallarProviderAdvertisementRecord.Surfaces) + +## Heimdall (12 element types) + +These types are a vendored copy of CultLib (`vendor/CultLib`); C2a already adds the ids in CultLib, so they resolve when the pin bumps. The witness bundle types are the fork reported with C2a. + +- CultGeometryDomainNode vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:61 (via CultGeometryDomainNode.Children) +- CultGeometryFeatureClaim vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:106 (via CultGeometryDomainNode.Claims) +- CultGeometryContributionRow vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:262 (via CultGeometrySelectedCutManifest.Diagnostics) +- CultMeshBodyDescriptor vendor/CultLib/src/GameCult.Mesh/CultMeshBodies.cs:35 (via CultMeshBodyPublicationDocument.Representations) +- CultMeshCdnChunkRef vendor/CultLib/src/GameCult.Mesh/CultMeshCdn.cs:73 (via CultMeshCdnArtifactManifest.Chunks) +- CultMeshDiscoveryCandidateDocument vendor/CultLib/src/GameCult.Mesh/CultMeshDiscoveryService.cs:418 (via CultMeshDiscoveryStateDocument.Candidates) +- CultMeshEntityPrefabComponent vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:81 (via CultMeshEntityPrefabNode.Components) +- CultMeshEntityPrefabAssetRef vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:96 (via CultMeshEntityPrefabPackage.Assets) +- CultMeshEntityPrefabNode vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:127 (via CultMeshEntityPrefabPackage.Nodes) +- CultWitnessContractPin vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:105 (via CultWitnessArtifactBundle.Contracts) +- CultWitnessArtifactEntry vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:136 (via CultWitnessArtifactBundle.Artifacts) +- CultWitnessTimingEntry vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:179 (via CultWitnessArtifactBundle.TimingWitnesses) + +## Mimir (0 element types) + +None. + + +## StreamPixels (20 element types) + +`CultGeometryDomainNode` is the vendored CultLib copy; C2a already adds its id. + +- ActivationCondition apps/overlay-unity/Assets/Scripts/Assembly-CSharp/ActivationCondition.cs:18 (via CharacterConditions.Conditions) +- BattleEffect apps/overlay-unity/Assets/Scripts/Assembly-CSharp/BattleEffect.cs:8 (via BattleAction.Effects) +- GearStatEffect apps/overlay-unity/Assets/Scripts/Assembly-CSharp/GearStatEffect.cs:7 (via GearData.StatEffects) +- OverlaySceneIntent apps/overlay-unity/Assets/Scripts/Assembly-CSharp/ServiceLink/OverlayFrameDocuments.cs:33 (via OverlaySceneIntentWindow.Intents) +- PlayedIntentRecord apps/overlay-unity/Assets/Scripts/Assembly-CSharp/ServiceLink/PlayedIntentLedger.cs:25 (via OverlayPlayedIntents.Entries) +- StatModifier apps/overlay-unity/Assets/Scripts/Assembly-CSharp/StatModifier.cs:7 (via GearStatEffect.Modifiers) +- StatusEffect apps/overlay-unity/Assets/Scripts/Assembly-CSharp/StatusEffect.cs:10 (via StatusData.Effects) +- WeaponDamageTypeMultiplier apps/overlay-unity/Assets/Scripts/Assembly-CSharp/WeaponDamageTypeMultiplier.cs:7 (via WeaponData.DamageMultipliers) +- CultGeometryDomainNode vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:61 (via CultGeometryDomainNode.Children) +- CultGeometryFeatureClaim vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:106 (via CultGeometryDomainNode.Claims) +- CultGeometryContributionRow vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:262 (via CultGeometrySelectedCutManifest.Diagnostics) +- CultMeshBodyDescriptor vendor/CultLib/src/GameCult.Mesh/CultMeshBodies.cs:35 (via CultMeshBodyPublicationDocument.Representations) +- CultMeshCdnChunkRef vendor/CultLib/src/GameCult.Mesh/CultMeshCdn.cs:73 (via CultMeshCdnArtifactManifest.Chunks) +- CultMeshDiscoveryCandidateDocument vendor/CultLib/src/GameCult.Mesh/CultMeshDiscoveryService.cs:418 (via CultMeshDiscoveryStateDocument.Candidates) +- CultMeshEntityPrefabComponent vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:81 (via CultMeshEntityPrefabNode.Components) +- CultMeshEntityPrefabAssetRef vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:96 (via CultMeshEntityPrefabPackage.Assets) +- CultMeshEntityPrefabNode vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:127 (via CultMeshEntityPrefabPackage.Nodes) +- CultWitnessContractPin vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:105 (via CultWitnessArtifactBundle.Contracts) +- CultWitnessArtifactEntry vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:136 (via CultWitnessArtifactBundle.Artifacts) +- CultWitnessTimingEntry vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:179 (via CultWitnessArtifactBundle.TimingWitnesses) + +## Ymir (1 element types) + +- YmirTransportLoweringDocument src/Ymir.Core/YmirServicePublicationDocument.cs:63 (via YmirProviderAdvertisementDocument.CommandLowerings) + +## CultLib itself + +C2a adds ids to: `CultGeometryDomainNode`, `CultGeometryFeatureClaim`, `CultGeometryContributionRow` (`src/GameCult.Geometry/CultGeometryDocuments.cs`); `CultMeshBodyDescriptor` (`src/GameCult.Mesh/CultMeshBodies.cs:35`); `CultMeshDiscoveryCandidateDocument` (`CultMeshDiscoveryService.cs:418`); `CultMeshEntityPrefabAssetRef`, `CultMeshEntityPrefabComponent`, `CultMeshEntityPrefabNode` (`CultMeshEntityPrefabs.cs`); `CultWitnessContractPin`, `CultWitnessArtifactEntry`, `CultWitnessTimingEntry` (`src/GameCult.Networking/CultWitnessArtifactBundle.cs`). + +`CultMeshCdnChunkRef` (`src/GameCult.Mesh/CultMeshCdn.cs:73`) has a derived id, `[CultElementId(nameof(Offset))]`: the decimal text of its offset, never random, so packing the same body encodes the same manifest bytes. `contracts/cultmesh/content-vectors.*.json` and the Rust runtime carry it. A consumer with content-addressed elements declares the same way. diff --git a/docs/nuget-packaging.md b/docs/nuget-packaging.md index ed8746bb..301e54eb 100644 --- a/docs/nuget-packaging.md +++ b/docs/nuget-packaging.md @@ -25,5 +25,5 @@ closure through UPM rather than NuGet restore. Install the Unity package from the immutable release tag: ```text -https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.57 +https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0 ``` diff --git a/docs/research/2026-10-22-consumer-pinning-at-breaking-land.md b/docs/research/2026-10-22-consumer-pinning-at-breaking-land.md new file mode 100644 index 00000000..f4c4d1cc --- /dev/null +++ b/docs/research/2026-10-22-consumer-pinning-at-breaking-land.md @@ -0,0 +1,59 @@ +# Consumer pinning at a breaking land: prior art and internal state + +Serves fork `variants:question:unpinned-consumers-at-land` (campaign `variants`, session `self-2026-10-22-eureka-c2a`). +Eyes pass, 2026-10-22. Facts and evidence pointers only; no recommendation. + +Question as admitted: Eve.Surface and Brokkr build against an unpinned sibling `..\CultLib` source checkout. C2a (refuse element types with no id) lands on main. Options: pin-then-land, sweep-then-land, land-and-break. + +## Sources + +| # | Source | URL | Status | +|---|---|---|---| +| S1 | Abseil, About / philosophy | https://abseil.io/about/philosophy | fetched | +| S2 | Abseil, compatibility guidelines | https://abseil.io/about/compatibility | fetched | +| S3 | Software Engineering at Google, ch. 21 Dependency Management | https://abseil.io/resources/swe-book/html/ch21.html | fetched | +| S4 | Software Engineering at Google, ch. 16 Version Control and Branch Management | https://abseil.io/resources/swe-book/html/ch16.html | fetched (does not cover live at head) | +| S5 | Russ Cox, Minimal Version Selection | https://research.swtch.com/vgo-mvs | fetched | +| S6 | Cargo book, Cargo.toml vs Cargo.lock | https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html | fetched | +| S7 | npm docs, package-lock.json | https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json | fetched | +| S8 | Chromium src, dependencies doc (DEPS, gclient, roll-dep) | https://chromium.googlesource.com/chromium/src/+/main/docs/dependencies.md | fetched | +| S9 | Pro Git, Submodules | https://git-scm.com/book/en/v2/Git-Tools-Submodules | fetched | +| S10 | chromium infra dependencies.md | https://chromium.googlesource.com/infra/infra/+/main/docs/dependencies.md | 404, not retrieved | +| L1 | `F:\Projects\Aetheria\Directory.Build.targets`, `Directory.Build.props`, git history | local files | read | +| L2 | `F:\Projects\Aetheria\docs\cultcache-migration-cut.md` (Q6 table, lines ~565-580; pin guard notes ~1900-1925) | local file | read | +| L3 | `F:\Projects\Eve` and `F:\Projects\Brokkr` csprojs and git file lists | local files | read | +| L4 | voidbot `search_history` (two queries), `search_sources` (one query) | MCP | queried | + +Anything not in a "fetched" row above is not claimed from the web. + +## 1. Established practice: who pays for a breaking upstream change, and when + +- **Abseil / live at head (S1, S2).** Recommends users track the latest code. Says it will provide a tool to perform an API refactor for well-behaved code (S1). Requires of downstream: show how you call it, stay within the compatibility guidelines; otherwise fork or use periodic supported branches (about every 6 months, patched 2 years) (S1). Says every change is potentially breaking for someone; breaks are announced in commit messages and LTS notes, and users are asked to report undetected breaks on GitHub (S2). Users should be able to temporarily pin to an earlier commit if a newer change is a problem (S2). S1/S2 do not mention Rosie, the Beyonce rule, or caller-fixing; the fetched pages describe tooling and a feedback channel only. +- **Live at Head as described in SWE at Google ch. 21 (S3).** Providers are expected to test changes against downstream dependents before committing; breaking changes wait until dependents are updated or an automated migration tool exists. Consumers must keep meaningful tests. Stated preconditions: unit tests across dependencies run in CI, a visible reverse-dependency graph, substantial compute, no pinning of dependencies, provider acceptance of the downstream-testing duty. Called "costly but effective" inside Google and "largely unproven at scale." Notes that under SemVer a major bump costs maintainers little and many indirect users a lot. Ch. 16 (S4) does not address live at head; it states large-scale changes across trunk are costly and tooling-supported. +- **Go MVS (S5).** Selects the oldest version satisfying all declared minimums. Publishing a new version changes nobody's build; a consumer gets it only when a requirement names it, directly or through another module. Upgrades are explicit edits to `go.mod` by the top-level module. Compatibility rule: newer versions inside one major version are expected to work where older did; a major version is a different module identity. Cost of a breaking change falls on whoever opts into the new version, at the time they opt in. +- **Cargo (S6).** Git dependency without `rev` takes the default-branch tip at first build and fixes that commit in `Cargo.lock`; changes arrive only on `cargo update`. A `rev` in `Cargo.toml` pins explicitly; the Cargo book calls hand-managed SHAs "tedious and error prone" and prefers the lockfile. Consumer pays at `cargo update` time. +- **npm (S7).** `package-lock.json` records the exact tree; installs stay on it until the lockfile changes and is committed. It is not published with a package. The page gives no timeline for when updates reach a consumer. +- **Chromium DEPS (S8).** `DEPS` stores dependency URLs and commit hashes (often in `vars`). Developers roll with `gclient setdep`, `roll-dep`, or manual `git update-index`; some projects (Skia) are autorolled. The page says manual rolling after an upstream change is good practice to confirm the roll compiles and tests pass. It does not describe revert procedure. Cost is paid by the roller at roll time. +- **Git submodules (S9).** The superproject records one commit SHA (gitlink, mode 160000). Upstream commits never move the pin; it moves when someone updates the checkout and commits the new SHA in the superproject. Collaborators receive the pin via pull and must run `git submodule update`. + +## 2. Internal precedent + +- **Aetheria pin (L1).** `Directory.Build.props` holds `CultLibRevision` (currently `45c2f4006f5f7dab357426cabd3197e8d0a17f66`) and `CultMathRevision` (`9c1b06cd703bfa44352050e819b0d0659cf5978a`). `Directory.Build.targets` target `VerifyCultLibRevision` (BeforeTargets `ResolveProjectReferences`) errors if the sibling checkout's `git rev-parse HEAD` differs from the pin, or if `git status --porcelain` is non-empty; escape hatch `SkipCultLibRevisionCheck=true`. File is 39 lines; the pin is one pair of properties per pinned package. +- **Why added (L1 git).** Commit `4f4beb0f` (2026-09-14, "Pin CultLib for headless builds"): "Directory.Build.targets carries Delvehold's VerifyCultLibRevision guard, checking for CultMath.csproj: HEAD must equal the pin and the worktree must be clean." Commit `e91ba369` (2026-09-24, "Pin CultLib per package, as Unity's manifest does"): headless build must compile the revisions Unity consumes; a single pin would have compiled six commits of newer CultCache under headless tests while Unity stays on its tag. The Aetheria cut doc (L2, ~line 1905) calls the guard "the only working pin for a sibling checkout." Aetheria's `Packages/manifest.json` pins Unity packages by git tag. +- **Pin guard already in Delvehold (L4/search_sources hit, `Delvehold:Directory.Build.targets`)** and a variant in AetheriaEve (archived, `Aetheria.State.Dependencies.targets`, also checks a Unity package tree hash). Delvehold's guard checks `GameCult.Mesh.csproj` exists, HEAD equals `CultLibRevision`, tree clean. Date of Delvehold's first guard: not determined. +- **Consumer table (L2, Q6 in Aetheria `docs/cultcache-migration-cut.md`).** Aquarium, Ymir, AquaSynth, Mimir, Gjallar: ".NET sibling, unpinned", compile against main at once. Brokkr, Eve, EveUnity, EvePlugins: "Unity package / sibling", pin "various". Delvehold: sibling with revision guard at `334e60f`, 52 behind, "Not until it re-pins." Heimdall: submodule `b6b1d9c`. Rust consumers (Idunn, Epiphany, Odin, Ghostlight, Muninn, Ratatoskr, CodexConnector): Cargo by commit. +- **Operator discussion (L4).** voidbot `search_history` ("pin CultLib revision vs track main for consumers"; "Brokkr Eve sibling CultLib checkout build pin") returned no relevant operator statement on pinning versus tracking; hits were unrelated Discord chatter. Nearest operator words found in the Discord index: Metacrat, 2026-05-09, "Oh, anyone using CultLib will still be paying the blood price, you can count on that" (message 1502671320406687894; context not fetched, so its subject is not confirmed). No operator quote on pinning versus tracking was found in memory files grepped (`C:\Users\Meta\.claude\projects\F--Projects\memory`, pattern pin/track main/unpinned/sibling) beyond a retired Idunn pin line and Odin/Muninn pin-branch coordination notes. +- Aetheria cut doc operator mentions near pins are about content (Schematic-shape drawer, 2026-09-14; Studio grouping, 2026-09-17), not pinning policy. + +## 3. Current state of the two consumers (L3) + +- **Eve.** `packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj:16` `CultLibRoot` defaults to `$(MSBuildThisFileDirectory)..\..\..\CultLib`; lines 20-21 ProjectReference `$(CultLibRoot)\src\GameCult.Caching\...` and `...\GameCult.Mesh\...`. `tests/GameCult.Eve.Surface.Serialization.Tests/*.csproj:19` references `$(CultLibRoot)\src\GameCult.Mesh\...` (and Eve.Surface by relative path). `git ls-files` shows only `packages/org.gamecult.eve.surface/Directory.Build.props` (sets only BaseOutputPath/BaseIntermediateOutputPath/MSBuildProjectExtensionsPath, no revision). No `Directory.Build.targets`, no `global.json`, no `.gitmodules`, no revision property or check. Eve also holds Cargo.toml/Cargo.lock (Rust side, not examined). +- **Brokkr.** `brokkr-command/Brokkr.Command.csproj:7`, `brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj:7`, `brokkr-contracts/Brokkr.Contracts.csproj:8`: `CultLibRoot` defaults to `..\..\CultLib`. ProjectReferences: Caching, Caching.MessagePack, Mesh, Networking (by project). No `Directory.Build.*`, no `.gitmodules` in `git ls-files`; no revision property or check. +- Neither repo contains `CultLibRevision`, a `VerifyCultLibRevision` target, or any HEAD check. Both build whatever `F:\Projects\CultLib` currently holds (including dirty or other-branch states). +- Fork text claims (not re-verified here): Eve.Surface has 0 `[CultElementId]`; Brokkr `[CultDocument]` files show no object lists by grep. + +## Gaps + +- S10 (Chromium infra) 404; Chromium autoroller behavior beyond S8 not fetched. +- Rosie / Beyonce rule not found in fetched pages; not cited from memory. +- No operator statement on pin-versus-track found in voidbot or the memory grep. diff --git a/docs/research/2026-10-23-schema-drift-write-back-prior-art.md b/docs/research/2026-10-23-schema-drift-write-back-prior-art.md new file mode 100644 index 00000000..b92ddc92 --- /dev/null +++ b/docs/research/2026-10-23-schema-drift-write-back-prior-art.md @@ -0,0 +1,74 @@ +Serves the C2a fork on finding variants:finding:cut-land-c2a-gate.s1.reflist-record-read-only-at-head (campaign variants), 2026-10-23. + +# Prior art: read with an old writer schema, then write back + +Facts only. Pages were fetched 2026-10-23 through a summarising fetcher; "not stated" means the fetched page did not say it, not that no source does. Items marked (memory) are my background knowledge, not verified from a fetched page. + +## 1. Apache Avro +- Source: https://avro.apache.org/docs/current/specification/ (Schema Resolution; summarised via apache/avro doc source), Java `DataFileWriter` javadoc (1.11.1). +- Model: the writer schema travels with the data. The reader resolves it against its own reader schema. Resolution is a read-time projection; the spec defines no write-back. +- Data loss on projection: "the writer's value for that field is ignored" when the reader schema lacks the field. A reader-only field takes its default, or the read errors if it has none. +- Write-back: the spec states no rule. Object container files store one schema in the header ("Each datum in a file is of the same schema"). `appendTo` is documented only as "Open a writer appending to an existing file"; it does not say which schema the append uses. (memory: Java `appendTo` reuses the file's header schema, so an append never restamps.) +- Stated reason for the read-time design: readers and writers evolve separately (fields added or removed in a different software version). + +## 2. Protocol Buffers (proto3) +- Source: https://protobuf.dev/programming-guides/proto3/ (Unknown Fields). +- On write: unknown fields are "included during parsing and in the serialized output" (matches proto2). Round-trip keeps fields the current type does not know. +- Loss cases the page lists: serializing to JSON, and copying a message field by field into a new message (it recommends `CopyFrom`/`MergeFrom`). +- No schema id or version is written. The wire format carries only field numbers, so "restamp" has no meaning here. +- Not stated: behaviour in versions before 3.5 (memory: proto3 dropped unknown fields before 3.5, then reversed). The Java reference page says nothing on preservation. +- Stated reason: preserve data across a mixed-version fleet. + +## 3. Confluent Schema Registry +- Source: https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html +- BACKWARD (default): consumers on the new schema read data written with the new or the previous version. FORWARD: data written with the new schema is readable by consumers on the old one. FULL: both. +- Write: "Each schema version gets a unique ID and an incremented version number." A producer writes under the schema it holds (its own current version). The id is stamped per message at serialisation, from the producer's schema. +- Upgrade order is how the rules prevent loss. BACKWARD means upgrade consumers before producers. FORWARD means upgrade producers first, and keep old-schema data away from consumers until they are upgraded. +- Not stated on the page: what a consume-modify-produce app writes. (memory: it re-serialises under its own latest schema, so fields it does not model are lost unless a compatibility rule kept them.) +- The registry gates registration. It does not gate individual rewrites. + +## 4. Document DBs and ORMs with stored versions +### MongoDB schema-versioning pattern +- Sources: https://www.mongodb.com/blog/post/building-with-patterns-the-schema-versioning-pattern ; https://www.mongodb.com/docs/manual/data-modeling/design-patterns/data-versioning/schema-versioning/ +- Documents carry `schema_version` (absent means 1). The app keeps a handler per version and writes new documents at the current version. +- Migration strategy is the app's choice: migrate all, migrate "when accessed" (lazy), or never. +- Not stated: whether an update of an old document restamps it. The manual's update example branches on `schemaVersion` and writes into each version's own shape. It notes its `else: null` branch adds stray null fields to the other shape. +- Stated reasons: no downtime, failed migrations easier to handle, timing flexibility. Cost: handler code per live version. + +### Realm (Atlas Device SDKs, now deprecated) +- Source: https://www.mongodb.com/docs/atlas/device-sdks/sdk/swift/model-data/change-an-object-model.md +- Per-file schema version. A changed schema needs an incremented `schemaVersion`, plus a `migrationBlock` for non-trivial changes. The file is migrated whole at open, before any read or write, and Realm "will throw an error" if the version is lower or the same but the schema differs. +- No lazy per-object restamp. Writes happen only on a migrated file. +- Data loss: the manual says "Deleting a property will not impact existing objects", i.e. removal is automatic and needs no developer step. Required new properties and renames need a manual block. (memory: removed-property values are dropped from the file.) + +### Core Data +- Source: web search results only (Apple forum threads 745114 and 785427, WWDC22-10120, "staged migrations" doc). The Apple docs URL I tried returned 404. +- A store whose model hash differs from the current model fails to open with an incompatible-version error. Opening migrates the whole store when `NSMigratePersistentStoresAutomaticallyOption` and `NSInferMappingModelAutomaticallyOption` are set. Lightweight migration covers only obvious patterns (add entity, remove relationship, required to optional, rename). Anything else needs a staged or custom migration. +- Migration is store-wide at open. It does not happen per record on write. + +### EF Core +- Not fetched. (memory: no per-row schema stamp. Migrations are explicit, ordered DDL, tracked in a `__EFMigrationsHistory` table.) + +## 5. Old-schema data held read-only until explicit migration +### Elasticsearch archive indices +- Sources: https://www.elastic.co/docs/deploy-manage/upgrade/deployment-or-cluster/reading-indices-from-older-elasticsearch-versions ; https://www.elastic.co/guide/en/elasticsearch/reference/current/archive-indices.html ; 8.0 migration notes (7.0+ indices readable, 6.x or earlier must be reindexed). +- Indices from an older major are readable through search and field-caps. "They do not support the Get API or any write APIs." +- The route to a writable copy is reindex into a new index that is "fully compatible with the current Elasticsearch version". `_source` is readable, so no data is lost on reindex. +- Stated reason: the page does not say why they are read-only. It describes archive access as "slower read-only access", with limited query capability. Some legacy mappings cannot be imported (the original is kept in `_meta`), and that is the one place the page shows a lossy translation. +- Third-party (Search Guard blog, unverified against Elastic): in 9.x a plain `index.blocks.write` does not satisfy the boot check; `index.verified_read_only` is needed. +- This is the closest match to option (b) in the fetched set: an old format, readable but not writable, left until the owner reindexes it. + +## Where restamp-on-write loses data (from the sources above) +- Avro: a field present in the writer schema and absent from the reader schema is dropped at read, "ignored". Any rewrite from the resolved value would omit it. +- Protobuf: unknown fields survive a binary round trip. They are lost via JSON, or when copied field by field. +- Realm: removed properties are deleted by the automatic migration. +- MongoDB: a handler that writes only the current shape can lose or relocate old-shape fields (the manual's example adds nulls). +- Confluent: loss is prevented by compatibility rules at registration time, not at each write. + +## Pattern across the set +- No fetched system restamps a single record on write when the record's writer schema differs from the application's type, on an app-visible, per-record basis. + - Avro, protobuf and Confluent carry the schema per file or message. + - Realm, Core Data and Elasticsearch move or block the whole store or index at open. + - MongoDB leaves it to the app. +- Whole-store migration at open is the usual explicit step (Realm, Core Data). Elasticsearch is the one case of read-only-until-reindex. +- Not found: a system that declares per-schema-id compatibility, as in option (b). Avro schema resolution and Confluent compatibility rules are the nearest analogues. Both are read-side or registration-side declarations. diff --git a/docs/research/2026-10-23-store-lock-user-code-prior-art.md b/docs/research/2026-10-23-store-lock-user-code-prior-art.md new file mode 100644 index 00000000..3c1f98df --- /dev/null +++ b/docs/research/2026-10-23-store-lock-user-code-prior-art.md @@ -0,0 +1,53 @@ +Serves question variants:question:store-hold-user-code (campaign variants), 2026-10-23. + +# Prior-art check: user code under a lock + +Facts and pointers only. Each source was fetched or searched this session; what was not reachable is marked. + +## 1. Goetz et al., Java Concurrency in Practice (Addison-Wesley, 2006), ch. 10 "Avoiding Liveness Hazards" +Status: EXISTS, SECTION NUMBER CORRECTED. +- "Open Calls" is **10.1.4**, not 10.1.3. 10.1.3 is "Deadlocks Between Cooperating Objects"; 10.1.2 is "Dynamic Lock Order Deadlocks"; 10.1.5 is "Resource Deadlocks". Source: text extracted from a PDF copy of the book (its TOC lists Listing 10.6 "Using Open Calls to Avoiding Deadlock Between Cooperating Objects", p. 133). The copy is an unofficial one at library.megu.edu.ua; a publisher link was not checked. +- (a) 10.1.3 gives the warning sign: an "alien method" (defined p. 40 as a method whose behavior the calling class does not fully specify) called while holding a lock. Paraphrase: such a call may take other locks (deadlock risk) or block a long time, stalling threads that need the lock you hold. 10.1.4 defines an open call as a call made with no locks held (credited to Lea, CPJ 2.4.1.3) and says programs restricted to open calls are far easier to analyze for deadlock-freedom than ones that allow alien calls under lock. +- Cost it names: moving a call out of a synchronized block makes an atomic operation non-atomic (its getImage example stops being a snapshot). Where atomicity is needed it suggests structuring so only one thread can run the code path after the open call (service shutdown example). +- (b), (c): not addressed in this section. +- Pointer: JCIP 10.1.3 and 10.1.4, pp. ~132-134 (chapter 10 starts p. 128). + +## 2. Sutter, "Effective Concurrency: Avoid Calling Unknown Code While Inside a Critical Section" +Status: VERIFIED, with date caveat. +- Dr. Dobb's column in the "Effective Concurrency" series. Sutter's blog teaser is dated 2007-11-06: https://herbsutter.com/2007/11/06/effective-concurrency-avoid-calling-unknown-code-while-inside-a-critical-section/ . Original link ddj.com/architect/202802983; Dr. Dobb's mirror https://drdobbs.com/cpp/avoid-calling-unknown-code-while-inside/202802983 (4 pages). The Dr. Dobb's issue date was not shown in anything fetched; the blog date is the only date confirmed. www.drdobbs.com did not resolve from this machine, so the column body comes from search-result summaries and the blog teaser, not a full first-hand read. +- (a) Core claim: locks do not compose; two separately correct modules can deadlock when combined. Trigger: caller inside a critical section, callee enters another critical section or blocks, another thread can take the same two in the opposite order. "Unknown code" listed as: library functions, plug-ins, callbacks/function pointers, virtual methods, generic types. +- Mitigations listed: move the call outside the critical section; pass copies instead of shared data; narrow the critical section (finer locks, reader-writer); require callees not to block (documentation/discipline only). +- (b), (c): not addressed. + +## 3. Linux lockdep +Status: VERIFIED, with a nuance. +- Source: kernel docs "Runtime locking correctness validator", https://docs.kernel.org/locking/lockdep-design.html . +- (b) Lockdep tracks lock classes, not instances, and records dependencies between classes as sequences are observed. It reports a circular order as a potential deadlock even if the deadlock never happened. Its "Proof of 100% correctness" section says that once each simple single-task locking sequence has occurred at least once, no timing of them can deadlock. Each new lock chain is validated once and hashed; repeats skip validation. +- Nuance: it flags the dependency edge that closes a cycle, so both orders must have been exercised at some point (possibly by different, non-concurrent runs). A lone nesting is flagged only if it breaks a single-lock rule (IRQ-safe vs unsafe, same-class recursion). "Refuses on first sight" fits the closing edge or a single-lock rule violation only. It warns; it does not block the acquisition. + +## 4. ConcurrentHashMap re-check after locking +Status: VERIFIED. +- Source: OpenJDK java/util/concurrent/ConcurrentHashMap.java, `putVal` and `computeIfAbsent`: head node `f` is read unlocked, `synchronized (f)` is taken, then `tabAt(tab, i) == f` is re-checked; on failure nothing is done and the loop retries. The overview comment gives the reason: the locked node can be replaced, so first-in-bin must be confirmed after locking. (Only the first 100k of 269k chars were read; both methods were reported found there.) Empty-bin `computeIfAbsent` uses a ReservationNode plus CAS instead. +- (c) This is the pattern: choose the lock from an unlocked read, validate the read after acquiring. + +## 5. LMDB +Status: VERIFIED (paraphrase). Not about alien code or lock order. +- Source: lmdb.h, `mdb_txn_begin` and `mdb_env_open` (https://github.com/LMDB/lmdb, libraries/liblmdb/lmdb.h). A thread may hold one transaction at a time (plus children); a transaction and its cursors belong to one thread; one write transaction at a time, so writers cannot deadlock each other; MDB_NOTLS ties reader slots to the txn object instead of the thread, and the application must then serialize writers itself because write locking is unaware of user threads. +- Relevance: "one hold per thread" as a deliberate rule, parallel to CultCache's one-cache-gate-per-thread. + +## 6. SQLite btmutex.c +Status: VERIFIED (summary of trunk source). +- Source: https://github.com/sqlite/sqlite/blob/master/src/btmutex.c (read via raw.githubusercontent.com; sqlite.org served a bot check). +- (b) Prevention by ordering, not detection: sharable B-trees on a connection sit in a list sorted by BtShared address and are always locked ascending (`sqlite3BtreeEnterAll`). `sqlite3BtreeEnter` try-locks first; on failure it releases later-ordered locks, waits, then reacquires in order. `wantToLock` is a count that makes a non-recursive mutex behave recursively. +- No alien-code or callback discussion in these comments. + +## Counter-positions and how they stay safe +- Callbacks under lock, documented: Java `ConcurrentHashMap.compute`/`computeIfAbsent`. Javadoc (Java 21): the function should be short and simple because other threads' updates may block meanwhile, and must not modify the map. The javadoc does not say "internal lock"; that it runs under the bin lock is inferred from the source in item 4. Safety rests on documented restrictions, not enforcement. +- Opposite choice: .NET `ConcurrentDictionary.GetOrAdd(key, valueFactory)`. Writes use fine-grained locks but valueFactory runs outside them, "to avoid the problems that can arise from executing unknown code under a lock" (doc remarks). Price: not atomic; the factory may run more than once; a recheck decides the winner (another thread's insert wins). https://learn.microsoft.com/en-us/dotnet/api/system.collections.concurrent.concurrentdictionary-2.getoradd . Same shape as option `encode-first` (user code outside the lock, then recheck and commit) and as item 4. +- JCIP and Sutter both name the cost of leaving the lock: lost atomicity. Sutter also offers "pass copies" and "callee must not block" as alternatives to leaving. +- Not researched (no source fetched): Linux notifier chains, Java `synchronized` event listeners, other systems that call back under lock by design. No verified citation for them. + +## Unverified / not done +- JCIP: section numbers and text come from one PDF copy, not the publisher edition. +- Sutter: full column body not read first-hand. +- No Discord or voidbot content read. diff --git a/docs/research/2026-10-27-default-registry-write-ownership-prior-art.md b/docs/research/2026-10-27-default-registry-write-ownership-prior-art.md new file mode 100644 index 00000000..9faeb6b4 --- /dev/null +++ b/docs/research/2026-10-27-default-registry-write-ownership-prior-art.md @@ -0,0 +1,90 @@ +# Prior art: a process-global default registry deciding write ownership + +Serves `variants:question:registryless-write-reads-shared` (campaign `variants`). Eyes pass, session `self-2026-10-27b-eureka`, 2026-10-27. Facts only; items marked "summary" rest on search summaries not opened. + +# Prior art: process-global type registries and write/overwrite ownership + +Fork: `variants:question:registryless-write-reads-shared` (viewed in full via eureka-state, status InForce, options `shared-decides` / `written-type-only`, recommended `shared-decides` at ~65%). +Facts only. No conclusion. Date 2026-10-27. + +Method note: web pages were read through WebFetch (a small model summarises the page) and WebSearch summaries. Quotes below are what those tools returned; where only a summary was available it is marked "summary". Nothing was run. + +## A. Local mechanics (CultLib, read-only, `C:\cl-wt\c2a-land\src\GameCult.Caching\CultCache.cs`) + +- `CultDocumentRegistry.Shared` is `Lazy` (line 639-645). Its public constructor calls `Refresh()` (647-650), which scans `ReflectionExtensions.GetAttributedDocumentTypes()` (688; defined in `ReflectionExtensions.cs:10`) and rebuilds the indexes under `_registrationGate`. So Shared's initial content is whatever `[CultDocument]` types are in loaded assemblies at first touch of `Shared`. +- `GetRequired(Type)` (697-711) registers an unregistered `[CultDocument]` type on demand. So Shared also grows whenever any caller asks for a type. `Refresh()` is public and rebuilds the index from currently loaded assemblies. +- `ForTypes(IEnumerable)` (658-669) builds a registry with `discoverLoadedDocuments:false`, containing only the given types (plus those `GetRequired` pulls in). +- `CultCache(registry = null, ...)` sets `_registry = registry ?? CultDocumentRegistry.Shared` (2202-2205). +- `WriteDocument(key, doc, storedAt, registry = null)` (4515-4545): `resolvedRegistry = registry ?? Shared`; `catalogEntry = resolvedRegistry.GetRequired().ToCatalogEntry()`; then `Cacheless(resolvedRegistry, ...)` lends that registry to the store for the call. XML doc (4510) says Shared "can only refuse more". +- `WriteRecord(..., registry = null)` (4491-4497): XML doc (4489) says that with registry omitted "it decides nothing for copied records and the one entry is the only claimant". +- `OwnsOnDisk(upserts)` (4741-4759): when the store is cacheless and upserts are given, ownership of an on-disk record is decided by `CultDocumentRegistry.Owns(id, catalog, declared, new CatalogEntryLookup(writes, AttachedRegistry), ..., claimant => writes.Any(entry => entry.SchemaId == claimant.SchemaId))`. The comment (4731-4735, 4738-4740) says written entries shadow the lent registry's type for their ids, the registry adds other claimants, and the record is owned only when the claimant is a written entry, "a registry can only refuse more". `CatalogEntryLookup` ctor at 604 takes `(entries, CultDocumentRegistry? others)`. +- Net: with `registry == null` the lent registry's claimant set is Shared's current contents, which depend on assemblies loaded and on every prior `GetRequired` call in the process. + +## B. Q1. Process-global, lazily filled registries in established libraries + +### MongoDB C# driver, `BsonClassMap` +- Global: yes. `BsonClassMap.RegisterClassMap` is process-wide static; unregistered types are auto-mapped on first use. Docs: "You must register a class map _before_ it's needed in your code." and "We recommend registering class maps before initializing a connection with MongoDB." https://www.mongodb.com/docs/drivers/csharp/current/serialization/class-mapping/ (WebFetch; the page itself says nothing on freezing or late-registration errors). +- Behaviour depends on what has been touched: EF Core provider issue EF-88 reports "Unknown discriminator value 'List`1'" when the first operation after startup is a read; reporter links it to a class map that is created only on write. Status Closed/Fixed in 8.0.0; page has no maintainer comment. https://jira.mongodb.org/browse/EF-88 (WebFetch). Reporter says explicit `RegisterClassMap` at startup did not fix it for them. +- A MongoDB community answer (via search summary, not opened): discriminator mappings persist once registered, so a read after an insert works; fix offered is `[BsonKnownTypes]`. https://mongodb.com/community/forums/t/c-driver-why-is-bsonknowntypes-attribute-needed-for-polymorphism/2183 (summary only). +- Register-once contract: CSHARP-4424 states the three `RegisterClassMap` methods are "meant to only be called once for a given type"; requested because several libraries share data models and do not control init order. Closed Done, fix version 2.19.0; the page does not say which API shipped. https://jira.mongodb.org/browse/CSHARP-4424 (WebFetch). Community workaround named in search summary: `BsonClassMap.IsClassMapRegistered`. +- CSHARP-1879 (search summary): `RegisterClassMap` accepted an unfrozen map and failed later on use; fixed 2.4.2. https://jira.mongodb.com/browse/CSHARP-1879 (not opened). +- CSHARP-5597 (search summary): `BsonSerializer.RegisterDiscriminator` has no effect; discriminator dictionary populated at class-map registration. https://jira.mongodb.org/browse/CSHARP-5597 (not opened). +- Polymorphic docs: "You must specify hierarchical discriminators in root-to-leaf order." and the relationship of all classes in a hierarchy must be documented before deserialization. https://www.mongodb.com/docs/drivers/csharp/current/serialization/polymorphic-objects/ (WebFetch). +- Particular docs report an order-dependent GuidSerializer failure ("incorrect order of class mappings"). https://docs.particular.net/persistence/upgrades/mongodb-4to5 (search summary, not opened). +- Did they move away: not found. Class maps remain global static; mitigation is documentation plus the IfNotRegistered API. + +### protobuf-net `RuntimeTypeModel.Default` +- Global: yes. README: "The Serializer.* methods are basically just shortcuts to RuntimeTypeModel.Default.*". https://cdn.jsdelivr.net/gh/protobuf-net/protobuf-net@main/README.md (WebFetch). +- Lazy fill, freezing, order-dependent bugs: NOT verified. README is silent; a direct docs fetch (docs.protobuf-net.dev/runtime) was redirected and not followed. No issue found. + +### MessagePack-CSharp +- Global mutable default exists: `MessagePackSerializer.DefaultOptions`. README (WebFetch): labelled "Global state; Not recommended when writing library code." Safe if you control all MessagePack code; libraries should pass options explicitly because others "may change this static property." Libraries "must not use StaticCompositeResolver - as it is global state". https://github.com/MessagePack-CSharp/MessagePack-CSharp/blob/master/README.md +- Resolvers are an ordered list (`CompositeResolver`): order matters by construction. +- Whether it moved away from the global: not found. + +### System.Text.Json +- `JsonSerializerOptions.Default`: "Gets a read-only, singleton instance"; remarks give a performance reason: shared instance avoids "redundant recomputation of converters". https://learn.microsoft.com/dotnet/api/system.text.json.jsonserializeroptions.default (WebFetch). The page states no rationale about cross-component interference; any such reading is inference. Design-discussion issue in dotnet/runtime: searched, not found. Whether Default was ever mutable: not verified. +- Type-info resolvers: not researched beyond the above. + +### Json.NET +- `TypeNameHandling` is a security concern, not an order concern: CA2326 says "An attacker could modify the serialized data to include unexpected types"; mitigation is a custom `ISerializationBinder` restricting types. https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2326 (WebFetch). +- `JsonConvert.DefaultSettings` global behaviour/guidance: the Newtonsoft page fetched says nothing about global effects. Not verified. + +### Orleans +- Serializer discovery is build-time source generation (`[GenerateSerializer]`, `[Id]`), not a runtime scan; the docs' older code-generation section lists scan of assemblies referencing the core library plus `KnownType`/`KnownAssembly` attributes. Aliases: "Type aliases are globally scoped, and you cannot have two aliases with the same value in an application." https://learn.microsoft.com/en-us/dotnet/orleans/host/configuration-guide/serialization (saved locally: `C:\Users\Meta\.claude\projects\F--Projects-CultLib\0840c7c7-0e31-410e-9012-afb79c3c0742\tool-results\toolu_0148m7no1AbrJzWmNLicn6N9.txt`, lines 134, 839-841). +- Type manifest / codec registry runtime behaviour, issues: not researched. + +### EF Core +- Model is built per `DbContext` type and cached (IMemoryCache; "the built model has a cache size of 100"); initialised at first operation: "just creating a DbContext instance does not cause the EF model to be initialized." https://learn.microsoft.com/en-us/ef/core/performance/advanced-performance-topics +- Assembly scan: `ApplyConfigurationsFromAssembly` — "The order in which the configurations will be applied is undefined, therefore this method should only be used when the order doesn't matter." https://learn.microsoft.com/en-us/ef/core/modeling/ ; and "`EntityTypeConfigurationAttribute` types will not be automatically discovered in an assembly." +- So EF's registry is per context type, not process-global; assembly scan is opt-in and documented as order-undefined. + +### Marten +- Storage is a table and upsert function per document type, alias `mt_doc_`; alias collisions are resolved with `DocumentAlias`. https://martendb.io/schema/storage (WebFetch). Hierarchies share one table via `AddSubClass()`; subclass docs are "stored as the parent type" in the identity map. https://www.martendb.io/documents/hierarchies (WebFetch). Schema is configured in `StoreOptions` per store, not a process-global. + +### Java ServiceLoader +- "Service providers are loaded and instantiated lazily, that is, on demand." Providers on the class path load in the order `getResources` finds config files; "The ordering of modules in a module layer is not defined." "Instances of this class are not safe for use by multiple concurrent threads." Caches cleared by `reload`. https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ServiceLoader.html (WebFetch). Visibility is class-loader dependent ("must be visible to the class loader"). + +## C. Q2. Write/overwrite decisions against an existing record of another type + +- RavenDB: REST put docs, request body section: updates must "include its collection name in the metadata" and "A document's collection cannot be modified." Server error code for mismatch: the first fetch summary said HTTP 500; not confirmed on a second fetch. https://docs.ravendb.net/7.1/client-api/rest-api/document-commands/put-documents (WebFetch twice). Ayende, RavenDB 4.0 storage: document key namespace is global, same key cannot exist in two collections (summary). https://ayende.com/blog/173954/the-design-of-ravendb-4-0-physically-segregating-collections The check is against the collection named in the write (derived from the writer's type by the client), not a registry; the "client maps entity type to collection" part is inference from the search summary, not a quoted source. `PutDocumentCommand` page: a missing collection means `@empty`; concurrency via `changeVector`, null "to skip the check". https://docs.ravendb.net/client-api/commands/documents/put +- Marten: per-type tables, so the same id under two types are two rows in two tables; no cross-type check. Same id across subclasses of one hierarchy table: not covered by docs fetched; unverified. Marten docs: upsert is used so users "don't have to check whether a document is new or existing" (search summary of https://www.martendb.io/documents/storing, not opened). +- MongoDB: the server has no type; `_id` unique per collection; the C# driver writes a `_t` discriminator from the written object's class map. Whether the driver checks an existing document's `_t` on replace: not researched; nothing found stating it does. +- Couchbase: key unique within a collection; upsert replaces; no server-side type field enforcement; guidance is one type per collection. https://docs.couchbase.com/server/7.0/learn/data/scopes-and-collections.html (search summary, not opened). +- Orleans: grain identity is type plus key ("type/key"), so same key under two grain classes is two identities, not a conflict. https://learn.microsoft.com/en-ca/%20dotnet/orleans/grains/grain-identity (search summary, not opened). How storage providers key state: not found. +- Result of this pass: no store found in which a process-global type registry decides whether a write may replace an existing record of another type. Found: collection/type fixed by metadata of the writer (RavenDB), type as part of the key (Orleans, Marten tables), or no check (Couchbase upsert). This is a negative search result, not proof of absence. Not searched: Cosmos DB, DynamoDB, EF Core TPH update of an existing row with another discriminator, Realm, LiteDB, Akka.Persistence. + +## D. Q3. Guidance on ambient/global registries and determinism + +- Mark Seemann (ploeh, 2019): after discussion with co-author van Deursen, "Ambient Context really is an anti-pattern." Reasons given: he has never used it and superior alternatives exist; he says the claim is unproven. https://blog.ploeh.dk/2019/01/21/some-thoughts-on-anti-patterns/ (WebFetch). Examples he cites as found in the BCL (search summary, not verified on page): `Thread.CurrentPrincipal`, `CultureInfo.CurrentCulture`, `HttpContext.Current`. DI book 2nd ed. chapter 5 lists it among DI anti-patterns: https://www.oreilly.com/library/view/dependency-injection-principles/9781617294730/OEBPS/Text/c05.xhtml (not opened). +- MessagePack README (above) is itself a library-author statement on global mutable defaults. MongoDB CSHARP-4424 (above) states the shared-library init-order problem. EF Core docs (above) state assembly-scan order is undefined. +- No .NET guidance specifically about AppDomain-wide/assembly-scan registries and write decisions found. + +## E. Not verified / gaps +- protobuf-net lazy registration, freezing and bug history. +- Why System.Text.Json made Default immutable; no maintainer statement found. +- Json.NET DefaultSettings guidance. +- RavenDB exact error text and HTTP status; whether the stored document is unchanged after refusal. +- Marten same-id-across-subclasses behaviour. +- MongoDB driver behaviour on replacing a document whose `_t` differs. +- Several items above rest on WebSearch summaries I did not open (marked "summary"). diff --git a/docs/research/2026-10-27-structural-schema-ids-prior-art.md b/docs/research/2026-10-27-structural-schema-ids-prior-art.md new file mode 100644 index 00000000..478a8e83 --- /dev/null +++ b/docs/research/2026-10-27-structural-schema-ids-prior-art.md @@ -0,0 +1,90 @@ +# Prior art: structural versus name-inclusive schema ids + +Serves the store-identity question raised 2026-10-27 in campaign `variants` (operator: member and declared names vary with runtime naming conventions). Eyes pass, session `self-2026-10-27b-eureka`. Facts only; items marked "summary" rest on search summaries not opened. + +# Prior art: structural vs name-inclusive schema identity (campaign variants) + +Eyes pass, 2026-10-27. Facts and evidence pointers only. "(fetched)" = page opened via WebFetch (the answer is a small-model extraction of the page, not raw text). "(summary)" = WebSearch result summary, page not opened. + +## 0. Local facts (read-only) + +- `F:\Projects\CultLib\src\GameCult.Caching\CultCache.cs:717-718` schemaId = Sha256(BuildSemanticFingerprint(...)). `:735-761` fingerprint = `schemaName|schemaVersion` then per member ordered by Slot: `slot:MemberName:TypeName:ref|value:TargetSchemaName:one|many`. `contentHash` (`:716`) is a separate Sha256 of canonical JSON (`:763+`). +- `TypeName` = `CultSchemaTypeNames.FromType`: CLR `Type.FullName` (namespace-qualified), generics as `Name`, arrays `T[]` (`src\GameCult.Caching\CultSchemaTypeNames.cs:9-31`). So the fingerprint contains .NET type names as well as member names. +- Contract `Contracts\cultcache-persistence-format.md:257-284` lists what canonicalization must preserve: slots, types, required/default, nullability, collection semantics, enum constraints, encoding shape. Member names and declared schema name are not in that list. `:171-179` says the id is not derived from C#/Rust/TS type names and the same shape should give the same id "regardless of origin language". `:180-190` defines schemaId = "same semantic persistence shape?" vs contentHash = "exact same canonical schema document?". Example catalog entry (`:140-170`) carries optional `supersedesSchemaIds`, `migrationFamily`; its example canonicalSchema fields do carry `"name": "Name"` (PascalCase). Soft-migration reader flow `:286-345` (exact_match / soft_migrate / reject). +- Runtimes do not derive ids; they emulate C# names: + - Python: `packages\cultcache-py\src\cultcache_py\documents.py:326` `member_name=_pascal_case(field.name)`; `:516-517` splits on `_` and capitalises. `schema_id` defaults to `self.type` (`:218`); `compatible_schema_ids` field (`:188,242`). + - TypeScript: `packages\cultcache-ts\src\codec.ts:226-232` `pascalCase` (splits on non-alphanumerics), `:267` `memberName: pascalCase(field.name)`; throws if a codec has no C# type name (`:~262`). + - Rust: `packages\cultcache-rs\src\lib.rs:295,308` `serde(rename_all = "camelCase")` on envelope structs; `schema_id: Some(T::TYPE.to_string())` at `:2856`. I did not trace how Rust document types obtain a sha256 id. + - Python test pins a literal id from shared vectors: `packages\cultcache-py\tests\test_cultcache.py:808` (`tests\vectors\document-variants-c0`). +- C# also has `CompatibleSchemaIds` (`CultCache.cs:28,313,570`): a record whose persisted id is in the list is accepted (an alias-list mechanism; I saw only the grep lines, not the logic). +- Ruling `variants:ruling:wire-id-is-version` (eureka-state, InForce, ruled 2026-09-30, operator quote "Agreed on schema identity"): wire schema id = declared version string `.vN`; every runtime stamps/resolves it exactly; a wire binding without `.v` is refused; "Store ids are unchanged." Cited source: document-variants-cut.md:552-555 (not opened). + +## 1. How established formats define identity/compatibility, and whether names participate + +Protocol Buffers (fetched https://protobuf.dev/programming-guides/proto3/ and /json/) +- Field number identifies the field in the binary wire; changing it = delete + add. Field names appear only in TextProto/JSON encodings; reserve deleted numbers and, for JSON/text, names. The proto3 page has no explicit safe/unsafe entry for renaming; names matter only for JSON/text. +- JSON mapping: names become lowerCamelCase unless `json_name` is set; "Parsers accept both the lowerCamelCase name" and the original proto name (/programming-guides/json/). +- Message type identity in `Any` is by name: default type URL `type.googleapis.com/.`, "a globally unique identifier" (proto3 page, #any). Type identity is nominal; compatibility is by field numbers. + +Apache Avro (fetched https://avro.apache.org/docs/1.11.1/specification/) +- Parsing Canonical Form (PCF): STRIP keeps only attributes relevant to parsing (type, name, fields, symbols, items, values, size); doc and aliases dropped; FULLNAMES replaces short names with fullnames and drops namespace attributes. Textually equal PCF means "the same" for any reader. Names ARE in the canonical form (record and field names), as is field declaration order. Defaults are absent from the kept list (extractor's inference; spec text not confirmed). +- Fingerprints: CRC-64-AVRO in single-object encoding (marker C3 01 + 8-byte LE fingerprint); spec recommends at least a 64-bit Rabin, with SHA-256 / MD5 options for larger sets. +- Name rules: `[A-Za-z_][A-Za-z0-9_]*`, dot-separated namespaces, equality on case-sensitive fullname. +- Resolution: records match on same unqualified name; "Named types and fields may have aliases"; implementations "may optionally" use aliases to map writer to reader. So in Avro names participate in identity (fingerprint) and resolution; aliases are the rename mechanism and are excluded from the fingerprint. + +Cap'n Proto (fetched https://capnproto.org/language.html) +- Files and types carry 64-bit IDs; `capnp id` generates them randomly. Default ID for a declaration without one = first 8 bytes of MD5 of (parent scope ID, declaration name). "You cannot change the name of a type that doesn't have an explicit ID"; symbolic names can change if ID/ordinals stay. Stated rationale for IDs: short, unambiguous, full names waste wire space. The page does not say why structural hashes were rejected; I found no Varda statement (search pointed at a mailing-list thread "Schema Refactoring and Unique IDs", Aug 2023, unopened). +- Encoding page (https://capnproto.org/encoding.html, fetched): adding fields is backward compatible because field positions depend only on lower-numbered fields; nothing on type identity. + +FlatBuffers (fetched https://flatbuffers.dev/evolution/ and /schema/) +- Field positions by declaration order or explicit `id: n` (contiguous from 0, on all fields if used). Renaming "generally OK" because names are not in the binary. `file_identifier` is exactly 4 chars at bytes 4-7; docs frame it as a file-level check and recommend unions for distinguishing messages. + +Thrift (fetched https://raw.githubusercontent.com/apache/thrift/master/doc/specs/thrift-binary-protocol.md) +- Binary protocol: field header = type + field id; "the field name is not encoded". (IDL page gave nothing on omitted ids.) + +MessagePack-CSharp (fetched https://github.com/MessagePack-CSharp/MessagePack-CSharp, first 100k of 105k chars) +- `[Key(int)]` on all members => array encoding; string keys => map. Keys should be sequential from 0 (gaps cost nulls); do not reuse indexes of removed members; keep obsolete members until clients update. Union keys are ints, unique, serialized as [key, object]. No type identity in the payload. + +Microsoft Bond (fetched https://microsoft.github.io/bond/manual/bond_cpp.html) +- Changing field ordinals breaks wire compat; no explicit versioning; Simple JSON matches by field name so a rename breaks it. No schema hash/GUID documented there. Bebop: wiki page empty (points to docs.bebop.sh, not opened). + +Unison (fetched https://www.unison-lang.org/docs/the-big-idea/ and /docs/language-reference/unique-types; hash size from search summary) +- Definitions identified by hash of syntax tree; names are "separately stored metadata"; hash excludes names (512-bit SHA3 per search summary, unopened). +- Structural types: "Unison does not uniquely identify a type by its name"; same-shape types are the same type (Suit/Direction, four nullary constructors, interchangeable). `unique` types: compiler generates a UUID that feeds the hash, so identity becomes nominal. Which is the default keyword was not confirmed. + +IPLD (fetched https://ipld.io/docs/schemas/) +- Matching is structural: "the names of types aren't expected to be found in the serialized data"; match is by structural outline. The page does not say schemas are themselves content-addressed. Versioning is left to developers; "Migrations" subpage not opened. + +## 2. Failure modes of purely structural identity + +- Unison documents it directly: structurally identical enums are one type unless declared `unique` (unison-lang.org/docs/language-reference/unique-types, fetched). Its mitigation is a per-declaration UUID mixed into the hash (identity re-nominalised). +- Language literature (summary, pages not opened): structural typing is prone to accidental conformance/type equivalence; nominal typing prevents it at the cost of rigidity (Wikipedia "Nominal type system"; arXiv 1007.2123 lecture notes; Erlang nominals doc https://www.erlang.org/doc/system/nominals.html says nominal types exist to prevent misuse of same-structure types). TypeScript "brand" = phantom discriminator field. No serialization-specific source found. +- Formats that avoid the collision by carrying a name or tag outside the shape: protobuf Any type URL (full name), Avro fullname in PCF, Cap'n Proto explicit IDs, Unison `unique`, MessagePack-CSharp union key ints, FlatBuffers file_identifier/unions. +- Not found: a documented incident of two same-shape serialized types colliding under a structural id (one search, nothing). + +## 3. Failure modes of name-inclusive identity across languages + +- Protobuf: default camelCase conversion is "always valid but not necessarily unique"; proto3 compiler rejects JSON-name conflicts; serialized duplicate keys parse differently by runtime (Edition Zero design doc, summary: https://flutter.googlesource.com/third_party/protobuf/+/HEAD/docs/design/editions/edition-zero-json-handling.md). Snake_case vs camelCase JSON mismatches in practice: https://github.com/fullstorydev/grpcurl/issues/105 (summary); https://github.com/open-telemetry/opentelemetry-proto/issues/476 (summary: spec contradicted itself on camelCase vs original names). Protobuf's mitigation: parsers accept both forms. +- Serde (fetched https://serde.rs/container-attrs.html): `rename_all` accepts lowercase, UPPERCASE, PascalCase, camelCase, snake_case; per-field `rename`. +- Avro restricts names to `[A-Za-z_][A-Za-z0-9_]*` with case-sensitive fullname equality, so snake_case and PascalCase spellings are distinct names; renames go through aliases, which PCF drops. +- Cap'n Proto: auto-derived IDs depend on the declaration name, so a rename without an explicit ID changes the ID. +- Bond Simple JSON: rename breaks compatibility (name matching). +- Local: Python/TS already convert to PascalCase to reproduce C#'s fingerprint (section 0); the fingerprint's TypeName is the CLR full name (`CultSchemaTypeNames.cs:30`) and TS throws when a codec lacks a C# type name. +- Registry normalisation (summary): Confluent Schema Registry `normalize.schemas=true` maps semantically-equal schemas to one id (https://www.confluent.io/blog/best-practices-for-confluent-schema-registry/); sources disagree on whether JSON Schema property order is normalised. + +## 4. Migration precedents for changing identity derivation + +- Git SHA-1 -> SHA-256 (fetched https://git-scm.com/docs/hash-function-transition): per-repo format marker (`extensions.objectFormat`, `compatObjectFormat`, `repositoryFormatVersion=1` makes old git refuse); bidirectional SHA-256<->SHA-1 mapping (pack idx v3, `loose-object-idx`); four transition modes (dark launch, early, late, post); conversion at the network boundary. It is a design doc, not a statement of what has shipped. +- IPFS CIDv0 -> CIDv1 (fetched https://docs.ipfs.tech/concepts/content-addressing/): v0 (46 chars, "Qm") and v1 (self-describing version/codec/multihash) coexist; `ipfs cid format -v 1` converts; v1->v0 only for dag-pb. +- Docker manifest schema 1 -> 2 (summary, mirror pages unopened): digests differ per manifest format; pull-by-digest gets no translation, pull-by-tag may be translated; repush converts. https://pulp.plan.io/issues/2099. +- Warpcast API hash v1 -> v2 (summary, https://warpcast.notion.site/Warpcast-API-Hash-Migration-Public-ccdd6de8ac604783b9596cfd623ddbac): two-week dual-hash period, then only v2. +- Cap'n Proto explicit-ID escape hatch for renamed/moved declarations; Avro aliases as the rename bridge in resolution; MessagePack-CSharp keep-obsolete-members (all fetched, above). +- Local: contract `supersedesSchemaIds` / `migrationFamily` (`cultcache-persistence-format.md:~140-170`) and C#/Py `CompatibleSchemaIds` exist as alias lists. +- Not found: Unison changing its hash derivation (one search, no docs); any serialization store that switched from name-inclusive to structural ids. + +## Could not verify / not done +- Cap'n Proto designer's rationale against structural hashing (not in docs; mailing list unopened). +- Avro: whether PCF drops defaults (spec text) and any JIRA on it; spec page was summarised by the fetch model. +- Bebop, Thrift IDL id-omission rule, Unison default struct/unique keyword, IPLD Migrations page, Edition Zero doc (summary only), Confluent blog (summary only), nominal/structural papers (titles only). +- Rust runtime's sha256 derivation path; `document-variants-cut.md:552-555` not opened; `CompatibleSchemaIds` logic not read. +- eureka-state worked (whoami ok); only the one ruling was queried. diff --git a/docs/research/2026-10-29-presence-provider-library-split-prior-art.md b/docs/research/2026-10-29-presence-provider-library-split-prior-art.md new file mode 100644 index 00000000..8acf0479 --- /dev/null +++ b/docs/research/2026-10-29-presence-provider-library-split-prior-art.md @@ -0,0 +1,28 @@ +# Prior art: presence provider as a library, launcher as a consumer + +Served: `cultlib-gaps:cut_spec:cut-cultnet-rs-presence-provider-a.r1` and the +Defaulted rulings `presence-provider-network-free-core`, +`presence-credentials-both-idunn-drivers` and `presence-provider-consumers-adopt`, +applying the operator's `gamecult-chat:ruling:presence-one-owner-library-launcher-consumer`. +Imagination pass, 2026-10-29, from recall of the cited manuals (not re-fetched). + +- libsystemd `sd_notify(3)`: a service links a small library and reports its + own state (READY, STATUS, WATCHDOG); programs that cannot link it use + `systemd-notify(1)` or a wrapper. Same split as cultnet-rs provider plus + idunn-launch as the sidecar consumer. +- `sd_listen_fds_with_names(3)`: checks LISTEN_PID against the process, sets + close-on-exec on passed descriptors, finds them by name, and can unset the + LISTEN_* environment. This set is the descriptor rule the provider adopts. +- systemd watchdog (`WATCHDOG=1` within `WatchdogSec`): the model for the + warming heartbeat. +- Consul: TTL checks updated by the service through the client API, versus + script/HTTP checks the agent runs for services that cannot. In-process + versus sidecar again. +- Kubernetes: kubelet-run probes observe from outside; readiness gates are + reported by the app. Idunn's route challenge is the outside observer, the + signed presence the app's own report. + +Existing copies compared: CodexConnector `src/idunn_health.rs` (two +descriptors, strict PID check), Idunn launcher (two descriptors, no PID check), +cultnet-ts `idunn-runtime-authority.ts` (descriptors by name, systemd private +PID case). The provider takes the strictest union. diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index 2c6f2211..e001bb1d 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -15,6 +15,9 @@ Cross-runtime parity has three layers: without translation folklore. Every store reader refuses, by name, a header or record slot it does not understand (vectors: `tests/vectors/document-variants-c0`). + 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 + (vectors: `tests/vectors/document-variants-c2a`). 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 (vector written by C#: `tests/vectors/document-variants-c1`). @@ -41,8 +44,8 @@ to speak CultNet or participate in CultMesh. | C# | Reference implementation and production game/server substrate | CultCache typed documents, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption | | Python | Package runtime, local daemon endpoint, raw interop peer, typed state participant | CultCache/CultNet/CultMesh package surface, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup, shard catalogs/logs, schema-transport factory for raw client request/subscription flows, local TCP and RUDP CultMesh server listeners, Python interop RUDP serve/dial path, session/prediction/simulation fact helpers, RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, benchmark evidence against C# cache paths | Universal C# throughput, full C# daemon/server stack, and RUDP as the only public service pipe | | TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, advertised schema/shard catalog wire contracts plus request/sync helpers, transport profiles, TCP-framed transport port and peer helper, shared RUDP socket transport, RUDP interop serve/dial path, RUDP reconnect policy/controller, RUDP reconnect-loop helper, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup | Full C# game session/server stack | -| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, typed same-schema alias hydration from raw snapshots, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade or production game-session server. `packages/cultmesh-rs` now holds a Rust stream and session surface -- node, document sets, RUDP document publication, stream descriptors, negotiation, stream catalog, and shared-memory frame rings -- but no interop lane exercises it, so it carries no cross-runtime parity claim | -| Kotlin | JVM/Android client substrate | Typed MessagePack cache, one-call typed snapshot document sync through local same-schema aliases, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store (parked at `parked/cultcache-soa`), full C# server daemon, or Unity hot-loop memory layout | +| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, typed hydration of raw snapshots into the receiver's own registered type for each schema id, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade or production game-session server. `packages/cultmesh-rs` now holds a Rust stream and session surface -- node, document sets, RUDP document publication, stream descriptors, negotiation, stream catalog, and shared-memory frame rings -- but no interop lane exercises it, so it carries no cross-runtime parity claim | +| Kotlin | JVM/Android client substrate | Typed MessagePack cache, one-call typed snapshot document sync into the receiver's own registered type for the schema, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store (parked at `parked/cultcache-soa`), full C# server daemon, or Unity hot-loop memory layout | ## Evidence Gates diff --git a/docs/semver-policy.md b/docs/semver-policy.md index 45da987b..f1c2c170 100644 --- a/docs/semver-policy.md +++ b/docs/semver-policy.md @@ -111,7 +111,7 @@ swap under "Changed" rather than "Breaking". ## Pre-1.0 packages -A package at `0.y.z` (currently `org.gamecult.cultmath`, at `0.4.x`) uses the +A package at `0.y.z` (currently `org.gamecult.cultmath`, at `0.5.x`) uses the standard pre-1.0 convention: `MAJOR` stays `0` during initial development, so `MINOR` carries what `MAJOR` means once the package reaches `1.0.0`. Concretely: @@ -352,10 +352,21 @@ own path into the DLL. No `global.json` pins the .NET SDK, so the same refusal at unchanged source and CRLF checkout means this SDK or host differs from the one that built the tracked DLL. +### Before pushing a tag + +A tag run uses the workflow files at the tagged commit, not the ones on main, and +the forge's GitHub mirror forwards the tag. Read `.github/workflows/publish-packages.yml` +(and any workflow that triggers on tags) at the tagged sha before pushing any tag: +a guard merged after that commit does not protect a tag that points before it. +`cultcache-ts-v0.15.0` sits on `7d292085`, which predates the guard, and its push +started a publish run (it failed; nothing reached npm). + ### Coverage gaps -`publish-packages.yml` only triggers on `cultcache-ts-v*`, `cultcache-py-v*`, -`cultnet-py-v*`, and `cultmesh-py-v*` tag pushes — it does not build or gate +`publish-packages.yml` only triggers on `cultcache-py-v*`, `cultnet-py-v*`, and +`cultmesh-py-v*` tag pushes (`cultcache-ts-v*` is deliberately not a trigger: npm +publishing is off until ruled on, and the cultcache-ts job runs from +`workflow_dispatch` only, publishing nothing) — it does not build or gate the Unity UPM packages at all; those are consumed directly by git tag (`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`, `src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags diff --git a/docs/start/README.md b/docs/start/README.md new file mode 100644 index 00000000..e033c935 --- /dev/null +++ b/docs/start/README.md @@ -0,0 +1,466 @@ +# Start Here + +CultLib is one stack in several languages: **CultCache** (typed `.cc` persistence), +**CultNet** (schema-v0 wire messages and transports) on top of it, and **CultMesh** (Verse +discovery, replication, authority) on top of that. **CultMath** and **Geometry** sit beside the +stack as portable numerics and engine-neutral geometry. The C# runtime in `src/` is the +reference; the other runtimes keep wire parity with it. + +If you want typed state on disk, start with CultCache. Everything else assumes it. + +## Language x package + +A cell links a snippet that a test in this repo runs, or says **not yet**. "Package" links +the package's own README or source, which is real but carries no tested first-call snippet from +this page. + +| | CultCache | CultMath | Geometry | CultNet | CultMesh | +| --- | --- | --- | --- | --- | --- | +| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | +| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | +| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet | +| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet | + +For the whole +picture of what each piece is for, see the +[package chooser in the top-level README](../../README.md#which-package-do-i-want). + +## C# first call + +Define a typed document, put it in a cache backed by a `.cc` file, read it back through a second +cache. The source is +[`tests/GameCult.Caching.Tests/FirstCallTests.cs`](../../tests/GameCult.Caching.Tests/FirstCallTests.cs), +which is what `dotnet test tests/GameCult.Caching.Tests --filter FirstCallTests` runs, so the snippet +cannot drift without the test failing. + +```csharp +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; + +[CultDocument("gamecult.first_call_item", "gamecult.first_call_item.v1")] +[MessagePackObject] +public sealed class Item +{ + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] [CultIndex] public string Category = string.Empty; + [Key(2)] public int Value; +} + +public static class FirstCall +{ + public static async Task Run(string path) + { + var store = new SingleFileMessagePackBackingStore(path); + var cache = new CultCache(); + cache.AddBackingStore(store); + var handle = await cache.AddAsync(new Item { Name = "Potion", Category = "Consumable", Value = 50 }); + store.PushAll(); // write the .cc file + + var reopened = new CultCache(); + reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path)); + await reopened.PullAllBackingStoresAsync(); // read it back + var potion = reopened.Get(handle.Key); // by key + var byName = reopened.GetByName("Potion"); // by [CultName] + return byName ?? potion; + } +} +``` + +`FirstCall.Run` is the call; a second test in that file fails if the block above stops matching it. More: `src/GameCult.Caching/README.md`. + +## TypeScript first call + +Same shape in TypeScript: the document is a name, a schema id and a `parse`-able schema (zod here), +and the `.cc` file is wire-compatible with the C# one. The source is +[`packages/cultcache-ts/test/first-call.test.ts`](../../packages/cultcache-ts/test/first-call.test.ts), +run by `npm test` in `packages/cultcache-ts` (or `node --test dist-test/test/first-call.test.js` +after `tsc`). + +```ts +import { z } from "zod"; +import { + CultCache, + SingleFileMessagePackBackingStore, + defineDocumentRegistry, + defineDocumentType, +} from "@gamecult/cultcache-ts"; + +const itemDocument = defineDocumentType({ + type: "item", + schemaId: "gamecult.first_call_item.v1", + schemaName: "gamecult.first_call_item", + schemaVersion: "gamecult.first_call_item.v1", + schema: z.object({ name: z.string(), category: z.string(), value: z.number().int() }), + name: "name", + indexes: { category: "category" }, +}); +const registry = defineDocumentRegistry(itemDocument); + +async function firstCall(path: string) { + const cache = CultCache.builder() + .withRegistry(registry) + .withGenericStore(new SingleFileMessagePackBackingStore(path)) + .build(); + await cache.pullAllBackingStores(); + await cache.put(itemDocument, "item:potion", { name: "Potion", category: "Consumable", value: 50 }); + + const reopened = CultCache.builder() + .withRegistry(registry) + .withGenericStore(new SingleFileMessagePackBackingStore(path)) + .build(); + await reopened.pullAllBackingStores(); + const potion = reopened.get(itemDocument, "item:potion"); + const byName = reopened.getByName(itemDocument, "Potion"); + return { potion, byName }; +} +``` + +The test imports from `../src/index` where the block reads `@gamecult/cultcache-ts`; a second test in that file fails if the block stops matching the rest. More: +`packages/cultcache-ts/README.md`. + +## Rust first call + +Same shape with `derive(DatabaseEntry)`: slots are `#[cultcache(key = N)]`, so the `.cc` payload is the +slot-indexed array the C# runtime writes. The source is +[`packages/cultcache-rs/tests/first_call.rs`](../../packages/cultcache-rs/tests/first_call.rs), run by +`cargo test --test first_call` in `packages/cultcache-rs`; a second test in that file fails if the block +below stops matching the file. The Rust runtime has no name lookup, so the read-back is by key. More: +`packages/cultcache-rs/README.md`. + +```rust +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "item")] +struct Item { + #[cultcache(key = 0)] + name: String, + #[cultcache(key = 1)] + category: String, + #[cultcache(key = 2, default)] + value: u32, +} + +fn first_call() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("first-call.cc"); + + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)); + cache.pull_all_backing_stores()?; + cache.put("item:potion", &Item { + name: "Potion".to_string(), + category: "Consumable".to_string(), + value: 50, + })?; // written to the .cc file before this returns + + let mut reopened = CultCache::new(); + reopened.register_entry_type::()?; + reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)); + reopened.pull_all_backing_stores()?; // read it back + let potion = reopened.get_required::("item:potion")?; + assert_eq!(potion.name, "Potion"); + assert_eq!(potion.value, 50); + Ok(()) +} +``` + +## Python first call + +A document is a definition with an encoder and decoder; `name=` and `indexes=` register the name lookup +and indexes. The source is +[`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` +and `msgpack` installed (the store needs it). A second test fails if the block below stops matching the +file. More: `packages/cultcache-py/README.md`. + +```python +import tempfile +from dataclasses import asdict, dataclass +from pathlib import Path + +from cultcache_py import CultCache, SingleFileMessagePackBackingStore, define_document_type + + +@dataclass +class Item: + name: str + category: str + value: int + + +item_doc = define_document_type( + "item", + encode=lambda item: asdict(item), + decode=lambda raw: Item(**raw), + name="name", + indexes={"category": "category"}, +) + + +def first_call() -> Item: + path = Path(tempfile.mkdtemp()) / "first-call.cc" + + cache = ( + CultCache.builder() + .register_document_type(item_doc) + .add_generic_store(SingleFileMessagePackBackingStore(path)) + .build() + ) + cache.pull_all_backing_stores() + cache.put(item_doc, "item:potion", Item("Potion", "Consumable", 50)) # written to the .cc file + + reopened = ( + CultCache.builder() + .register_document_type(item_doc) + .add_generic_store(SingleFileMessagePackBackingStore(path)) + .build() + ) + reopened.pull_all_backing_stores() # read it back + assert reopened.get_by_name(item_doc, "Potion") == reopened.get_required(item_doc, "item:potion") + return reopened.get_required(item_doc, "item:potion") +``` + +## CultNet first calls + +CultNet is schema-v0 messages in length-prefixed frames. The first call in each runtime is the same +shape: define a typed document, build a `cultnet.document_put_raw.v0` message carrying its MessagePack +payload, send one frame over a loopback pipe, and apply the received message to a second cache. Each +block is verbatim from the test that runs it; a second test in the same file fails if the block stops +matching. + +### C# CultNet first call + +Source: [`tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs`](../../tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs), +run by `dotnet test tests/GameCult.Networking.Tests --filter CultNetFirstCallTests`. More: `src/GameCult.Networking/README.md`. + +```csharp +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Networking; +using MessagePack; + +[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")] +[MessagePackObject] +public sealed class Note +{ + [Key(0)] [CultName] public string Title = string.Empty; + [Key(1)] public string Body = string.Empty; +} + +public static class CultNetFirstCall +{ + public static async Task Run() + { + var inbox = new CultCache(); // the receiver's cache + var documents = new CultNetDocumentRegistry(); + var received = new TaskCompletionSource(); + + // Receive: a loopback listener that applies each raw document put to the inbox. + using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0)); + server.OnCultNet(async (put, peer) => + { + await documents.ApplyRawDocumentPutMessageAsync(inbox, put); + received.TrySetResult(true); + }); + + // Send: a typed document as a cultnet.document_put_raw.v0 message. + using var client = new TcpFramedCultNetSchemaClient(); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + var handle = new CultRecordHandle(new CultRecordKey("note:hello")); + client.SendCultNet(documents.CreateRawDocumentPutMessage( + "put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" })); + + await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); + return inbox.Get(handle.Key); + } +} +``` + +### TypeScript CultNet first call + +Source: [`packages/cultnet-ts/test/first-call.test.ts`](../../packages/cultnet-ts/test/first-call.test.ts), run by +`npm test` in `packages/cultnet-ts` (build `packages/cultcache-ts` first: `npm run build` there). The test imports +from `../src` where the block reads `@gamecult/cultnet-ts`. More: `packages/cultnet-ts/README.md`. + +```ts +import { createServer, connect } from "node:net"; +import { z } from "zod"; +import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts"; +import { + CultNetDocumentRegistry, + createTcpFramedCultNetPeer, + defineCultNetDocumentBinding, +} from "@gamecult/cultnet-ts"; + +const noteDocument = defineDocumentType({ + type: "note", + schemaId: "gamecult.first_call_note.v1", + schemaName: "gamecult.first_call_note", + schemaVersion: "gamecult.first_call_note.v1", + schema: z.object({ title: z.string(), body: z.string() }), + name: "title", +}); +const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]); + +async function firstCall() { + const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const; + const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache + + // Receiver: a loopback listener whose peer applies each document put to the inbox. + const received = new Promise((resolve, reject) => { + const server = createServer((socket) => { + const peer = createTcpFramedCultNetPeer(socket, options); + peer.on("invalidMessage", reject); + peer.on("message", async (message) => { + if (message.schemaVersion !== "cultnet.document_put_raw.v0") return; + await documents.applyRawDocumentPutMessage(inbox, message); + peer.close(); + server.close(); + resolve(); + }); + }).listen(0, "127.0.0.1", () => { + // Sender: connect, build a typed put message, send it. + const { port } = server.address() as { port: number }; + const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options); + sender.send( + documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", { + title: "Hello", + body: "sent over CultNet", + }), + ); + received.finally(() => sender.close()); + }); + }); + await received; + return inbox.get(noteDocument, "note:hello"); +} +``` + +### Rust CultNet first call + +Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by +`cargo test --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`. + +```rust +use std::net::{TcpListener, TcpStream}; +use std::thread; + +use cultnet_rs::{ + CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract, + TcpFramedTransportConnection, create_tcp_framed_transport_profile, + decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, +}; + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "note")] +struct Note { + #[cultcache(key = 0)] + title: String, + #[cultcache(key = 1)] + body: String, +} + +fn first_call() -> anyhow::Result { + let mut documents = CultNetDocumentRegistry::new(); + documents.register(CultNetDocumentBinding::for_entry_with_schema_id::( + "gamecult.first_call_note.v1".to_string(), + "gamecult.first_call_note.v1".to_string(), + ))?; + let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default()); + + // Receiver: a loopback listener that applies the document put it receives to its own cache. + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + let receiver = thread::spawn({ + let (documents, profile) = (documents.clone(), profile("receiver")); + move || -> anyhow::Result { + let (stream, _) = listener.accept()?; + let frame = TcpFramedTransportConnection::new(stream, profile).receive()?; + let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?; + let mut inbox = CultCache::new(); + inbox.register_entry_type::()?; + documents.apply_raw_document_put_message::(&mut inbox, &message) + } + }); + + // Sender: build a typed put message, encode it for the wire, send one frame. + let put = documents.create_raw_document_put_message( + "put-1", + "note:hello", + &Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() }, + CultNetDocumentPutOptions::default(), + )?; + let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?; + TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender")) + .send("schema", &bytes)?; + + receiver.join().expect("receiver thread") +} +``` + +### Python CultNet first call + +Source: [`packages/cultnet-py/tests/test_first_call.py`](../../packages/cultnet-py/tests/test_first_call.py), run by +`python -m unittest tests.test_first_call` in `packages/cultnet-py` with both `src` and `../cultcache-py/src` on +`PYTHONPATH` and `msgpack` installed. The socket pair stands in for a TCP connection. More: `packages/cultnet-py/README.md`. + +```python +import socket +from dataclasses import asdict, dataclass + +from cultcache_py import CultCache, define_document_type +from cultnet_py import ( + apply_raw_document_record, + document_put_raw, + encode_frame, + parse_message, + read_frame, + schema_document_map, +) + + +@dataclass +class Note: + title: str + body: str + + +note_doc = define_document_type( + "note", + encode=lambda note: asdict(note), + decode=lambda raw: Note(**raw), + name="title", +) + + +def first_call() -> Note: + sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP + + # Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame. + put = document_put_raw( + message_id="put-1", + key="note:hello", + schema_id=note_doc.catalog_entry().schema_id, + stored_at="2026-01-01T00:00:00Z", + payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")), + ) + sender.sendall(encode_frame(put.to_bytes())) + + # Receive: read the frame, parse the message, apply its record to a second cache. + message = parse_message(read_frame(receiver.makefile("rb"))) + assert message.schema_version == "cultnet.document_put_raw.v0" + inbox = CultCache.builder().register_document_type(note_doc).build() + apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"]) + sender.close() + receiver.close() + return inbox.get_required(note_doc, "note:hello") +``` + +## Where next + +- Smallest durable mesh path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`. +- Application path, four chapters: `src/GameCult.Mesh/docs/getting-started/README.md`. diff --git a/docs/start/io-game.md b/docs/start/io-game.md new file mode 100644 index 00000000..e7f701a7 --- /dev/null +++ b/docs/start/io-game.md @@ -0,0 +1,196 @@ +# Recipe: a two-client .io game over CultCache + CultMesh (TypeScript) + +A tiny arena. One process is the authority: it holds a single `io.arena_state` document in a CultCache +and serves it over CultMesh. Each client discovers the authority through the Odin catalog, leases the +document, and invokes `join`, `move` and `leave` operations. Two clients see each other join, move and +leave because both lease the same document. + +## What ran, what did not + +| Piece | Status | +| --- | --- | +| Two clients, real WebSockets, real `@gamecult/cultmesh-browser` client, one arena: join, see each other, move, leave | **tested** (`arena.test.ts`) | +| Edge clamp, and the source below matching the files that ran | **tested** | +| Browser bundle builds and the page is served | **tested** (HTTP fetch only) | +| The page running in a real browser, two windows | **not yet**: no browser on the host that built this recipe | +| Run by CI / `npm run test:ts` | **not yet**: the recipe has its own `package.json` (for `ws`) outside the workspaces; run it by hand as below | +| Authority state on disk (`.cc` file) | **not yet**: the arena cache is in memory | +| Remote play: Odin roots and a signed route | **not yet**: the client uses `local-development` trust, loopback only | +| Dropped players: a closed tab that never sent `leave` stays in the arena | **not yet**: operations do not carry a connection identity to the authority | +| `CultCache` in the browser | **not yet**: the `@gamecult/cultcache-ts` entry imports Node built-ins, so the client keeps the decoded document in a variable and only `./document` is imported | + +No `@gamecult/cultmesh-ts` or `@gamecult/cultnet-ts` TypeScript authority server exists. The server below is the CultNet +message handling a provider owes, written in this recipe with the `ws` package. + +## Run it + +``` +npm ci --ignore-scripts # repo root +(cd packages/cultnet-ts && npm run build) # then cultcache-ts, cultmesh-browser, same command +cd docs/start/io-game && npm install +npm test # node --test arena.test.ts, Node 24 +node serve.ts # http://127.0.0.1:8080, open it twice +``` + +Tested on Node v24.14, which runs `.ts` files directly (no type check; `tsc` is not part of this recipe). + +## schema.ts + +```ts +import { defineDocumentType } from "@gamecult/cultcache-ts/document"; + +export interface Player { x: number; y: number } +export interface ArenaState { players: Record } + +// The one shared document: every player's position. The server owns it; clients lease it. +export const arenaDocument = defineDocumentType({ + type: "io.arena_state", + schemaId: "io.arena_state.v1", + schema: { parse: (value: unknown) => value as ArenaState }, +}); + +export const arenaKey = "arena:main"; +export const verseId = "io.arena"; +export const authorityRuntimeId = "io.arena-daemon"; +export const arenaSize = 100; +``` + +## server.ts + +```ts +import { createServer, type RequestListener, type Server } from "node:http"; +import { CultCache } from "@gamecult/cultcache-ts"; +import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document"; +import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; +import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts"; +import { WebSocketServer, type WebSocket } from "ws"; +import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; + +const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64"); +const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64")); + +// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh). +export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) { + const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build(); + await cache.put(arenaDocument, arenaKey, { players: {} }); + const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); })); + await new Promise(resolve => http.listen(port, host, resolve)); + const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`; + + const subscribers = new Map(); + const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m))); + const record = () => ({ + schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId, + schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(), + payloadEncoding: "messagepack" as const, + payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never, + sourceRuntimeId: authorityRuntimeId, + }); + const publish = async (next: ArenaState) => { + await cache.put(arenaDocument, arenaKey, next); + for (const [ws, ids] of subscribers) for (const subscriptionId of ids) { + send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() }); + } + }; + + let nextId = 0; + const operations: Record Promise> = { + join: async () => { + const id = `p${++nextId}`; + const state = cache.getRequired(arenaDocument, arenaKey); + await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } }); + return { id }; + }, + move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => { + const state = cache.getRequired(arenaDocument, arenaKey); + const me = state.players[id]; + if (!me) return { ok: false }; + const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v)); + await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } }); + return { ok: true }; + }, + leave: async ({ id }: { id: string }) => { + const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players; + await publish({ players: rest }); + return { ok: true }; + }, + }; + + const wss = new WebSocketServer({ server: http }); + wss.on("connection", (ws, req) => { + const odin = req.url === "/odin"; + ws.on("close", () => subscribers.delete(ws)); + ws.on("message", async raw => { + const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer))); + if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") { + send(ws, { + schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId, + verses: [{ + verseId, displayName: "Arena", authorityModel: "ServerAuthoritative", + compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] }, + discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId], + authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }], + }], + }); + } else if (m.schemaVersion === "cultmesh.session_open.v2") { + send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId, + protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" }); + } else if (m.schemaVersion === "cultnet.database_subscribe.v0") { + subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]); + send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] }); + } else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") { + subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId)); + } else if (m.schemaVersion === "cultnet.operation_request.v0") { + const handler = operations[m.operation]; + const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined; + send(ws, { + schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation, + status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64", + payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId, + }); + } + }); + }); + + return { + odinUrl: `${endpoint}/odin`, + state: () => cache.getRequired(arenaDocument, arenaKey), + close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); }, + }; +} +``` + +## client.ts + +```ts +import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser"; +import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; + +// Runs unchanged in a browser or in Node 22+: both have a global WebSocket. +export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) { + const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` }); + const mesh = await CultMeshBrowserClient.connect({ + verseId, authorityRuntimeId, runtimeId, rendezvous, + trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots + }); + let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document + const call = async (operation: string, payload: object) => + decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload })); + + const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey }); + const { id } = await call("join", {}); + lease.watch(record => { + if (!record) return; + latest = decodeCultNetPayload(record, arenaDocument); + onState(latest, id!); + }); + return { + id: id!, + move: (dx: number, dy: number) => call("move", { id, dx, dy }), + state: () => latest, + leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); }, + }; +} +``` + +`browser.ts`, `index.html` and `serve.ts` in [`io-game/`](io-game/) draw the canvas and bundle the client with esbuild. diff --git a/docs/start/io-game/arena.test.ts b/docs/start/io-game/arena.test.ts new file mode 100644 index 00000000..67049907 --- /dev/null +++ b/docs/start/io-game/arena.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { joinArena } from "./client.ts"; +import { startArena } from "./server.ts"; +import { arenaSize, type ArenaState } from "./schema.ts"; + +const until = async (cond: () => boolean) => { + for (let i = 0; i < 200 && !cond(); i++) await new Promise(r => setTimeout(r, 10)); + assert.ok(cond(), "condition not reached in 2s"); +}; + +test("two clients join one arena, see each other, and see each other move", async () => { + const arena = await startArena(); + const seenByA: ArenaState[] = []; + const seenByB: ArenaState[] = []; + const a = await joinArena(arena.odinUrl, "client-a", s => seenByA.push(structuredClone(s))); + const b = await joinArena(arena.odinUrl, "client-b", s => seenByB.push(structuredClone(s))); + try { + assert.notEqual(a.id, b.id); + await until(() => Object.keys(a.state()?.players ?? {}).length === 2); + await until(() => Object.keys(b.state()?.players ?? {}).length === 2); + + const before = structuredClone(b.state()!.players[a.id]!); + // Moves clamp at the arena edge, so pick a direction that always has room to move. + const dx = before.x > 0 ? -1 : 1; + await a.move(dx, 0); + await until(() => b.state()!.players[a.id]!.x !== before.x); + assert.equal(b.state()!.players[a.id]!.x, before.x + dx); + assert.deepEqual(a.state(), arena.state()); + assert.deepEqual(b.state(), arena.state()); + + await b.leave(); + await until(() => !(a.id !== b.id && b.id in a.state()!.players)); + assert.deepEqual(Object.keys(a.state()!.players), [a.id]); + } finally { + await a.leave().catch(() => undefined); + await arena.close(); + } +}); + +test("a player cannot walk out of the arena", async () => { + const arena = await startArena(); + const a = await joinArena(arena.odinUrl, "client-a", () => undefined); + try { + for (let i = 0; i <= arenaSize; i++) await a.move(-1, 0); + assert.equal(arena.state().players[a.id]!.x, 0); + } finally { + await a.leave(); + await arena.close(); + } +}); + +test("serve.ts bundles the browser client and serves the page", async () => { + const { serveArena } = await import("./serve.ts"); + const site = await serveArena(0); + try { + const html = await (await fetch(site.url)).text(); + assert.match(html, / { + const page = readFileSync(new URL("../io-game.md", import.meta.url), "utf8").replace(/\r\n/g, "\n"); + for (const file of ["schema.ts", "server.ts", "client.ts"]) { + const text = readFileSync(new URL(`./${file}`, import.meta.url), "utf8").replace(/\r\n/g, "\n"); + assert.ok(page.includes(text), `docs/start/io-game.md no longer shows ${file} verbatim`); + } +}); diff --git a/docs/start/io-game/browser.ts b/docs/start/io-game/browser.ts new file mode 100644 index 00000000..4120285f --- /dev/null +++ b/docs/start/io-game/browser.ts @@ -0,0 +1,17 @@ +import { joinArena } from "./client.ts"; +import { arenaSize, type ArenaState } from "./schema.ts"; + +const canvas = document.querySelector("canvas")!; +const ctx = canvas.getContext("2d")!; +const scale = canvas.width / arenaSize; + +const game = await joinArena(`ws://${location.host}/odin`, `browser-${crypto.randomUUID()}`, (state: ArenaState, me: string) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + for (const [id, p] of Object.entries(state.players)) { + ctx.fillStyle = id === me ? "#f5c542" : "#6aa9ff"; + ctx.fillRect(p.x * scale, p.y * scale, scale * 2, scale * 2); + } +}); +const keys: Record = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; +addEventListener("keydown", e => { const d = keys[e.key]; if (d) void game.move(...d); }); +addEventListener("pagehide", () => void game.leave()); diff --git a/docs/start/io-game/client.ts b/docs/start/io-game/client.ts new file mode 100644 index 00000000..10c4b4cd --- /dev/null +++ b/docs/start/io-game/client.ts @@ -0,0 +1,28 @@ +import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser"; +import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; + +// Runs unchanged in a browser or in Node 22+: both have a global WebSocket. +export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) { + const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` }); + const mesh = await CultMeshBrowserClient.connect({ + verseId, authorityRuntimeId, runtimeId, rendezvous, + trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots + }); + let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document + const call = async (operation: string, payload: object) => + decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload })); + + const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey }); + const { id } = await call("join", {}); + lease.watch(record => { + if (!record) return; + latest = decodeCultNetPayload(record, arenaDocument); + onState(latest, id!); + }); + return { + id: id!, + move: (dx: number, dy: number) => call("move", { id, dx, dy }), + state: () => latest, + leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); }, + }; +} diff --git a/docs/start/io-game/index.html b/docs/start/io-game/index.html new file mode 100644 index 00000000..bcc77648 --- /dev/null +++ b/docs/start/io-game/index.html @@ -0,0 +1,6 @@ + + +arena + +

Arrow keys. Open this page in a second window.

+ diff --git a/docs/start/io-game/package-lock.json b/docs/start/io-game/package-lock.json new file mode 100644 index 00000000..48afe0a7 --- /dev/null +++ b/docs/start/io-game/package-lock.json @@ -0,0 +1,64 @@ +{ + "name": "cultlib-start-io-game", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cultlib-start-io-game", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.5.0" + } + }, + "node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.22.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", + "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/docs/start/io-game/package.json b/docs/start/io-game/package.json new file mode 100644 index 00000000..d8f1281f --- /dev/null +++ b/docs/start/io-game/package.json @@ -0,0 +1,9 @@ +{ + "name": "cultlib-start-io-game", + "private": true, + "type": "module", + "description": "docs/start recipe: a tiny two-client browser game over CultCache + CultMesh (TypeScript).", + "scripts": { "test": "node --test arena.test.ts" }, + "dependencies": { "ws": "^8.18.0" }, + "devDependencies": { "@types/ws": "^8.5.0" } +} diff --git a/docs/start/io-game/schema.ts b/docs/start/io-game/schema.ts new file mode 100644 index 00000000..d0c1a59b --- /dev/null +++ b/docs/start/io-game/schema.ts @@ -0,0 +1,16 @@ +import { defineDocumentType } from "@gamecult/cultcache-ts/document"; + +export interface Player { x: number; y: number } +export interface ArenaState { players: Record } + +// The one shared document: every player's position. The server owns it; clients lease it. +export const arenaDocument = defineDocumentType({ + type: "io.arena_state", + schemaId: "io.arena_state.v1", + schema: { parse: (value: unknown) => value as ArenaState }, +}); + +export const arenaKey = "arena:main"; +export const verseId = "io.arena"; +export const authorityRuntimeId = "io.arena-daemon"; +export const arenaSize = 100; diff --git a/docs/start/io-game/serve.ts b/docs/start/io-game/serve.ts new file mode 100644 index 00000000..82bf20be --- /dev/null +++ b/docs/start/io-game/serve.ts @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { build } from "esbuild"; +import { startArena } from "./server.ts"; + +// Serves the page, the bundled browser client, and the arena's WebSocket on one port. +export async function serveArena(port = 8080, host = "127.0.0.1") { + const bundle = await build({ entryPoints: [new URL("./browser.ts", import.meta.url).pathname], bundle: true, format: "esm", target: "es2022", write: false }); + const html = readFileSync(new URL("./index.html", import.meta.url)); + const arena = await startArena(port, host, (req, res) => { + if (req.url === "/") res.writeHead(200, { "content-type": "text/html" }).end(html); + else if (req.url === "/browser.js") res.writeHead(200, { "content-type": "text/javascript" }).end(bundle.outputFiles[0]!.contents); + else res.writeHead(404).end(); + }); + return { url: arena.odinUrl.replace("ws:", "http:").replace("/odin", ""), close: arena.close }; +} + +if (import.meta.main) console.log(`arena at ${(await serveArena()).url}`); diff --git a/docs/start/io-game/server.ts b/docs/start/io-game/server.ts new file mode 100644 index 00000000..7ef552b9 --- /dev/null +++ b/docs/start/io-game/server.ts @@ -0,0 +1,100 @@ +import { createServer, type RequestListener, type Server } from "node:http"; +import { CultCache } from "@gamecult/cultcache-ts"; +import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document"; +import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; +import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts"; +import { WebSocketServer, type WebSocket } from "ws"; +import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; + +const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64"); +const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64")); + +// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh). +export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) { + const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build(); + await cache.put(arenaDocument, arenaKey, { players: {} }); + const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); })); + await new Promise(resolve => http.listen(port, host, resolve)); + const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`; + + const subscribers = new Map(); + const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m))); + const record = () => ({ + schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId, + schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(), + payloadEncoding: "messagepack" as const, + payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never, + sourceRuntimeId: authorityRuntimeId, + }); + const publish = async (next: ArenaState) => { + await cache.put(arenaDocument, arenaKey, next); + for (const [ws, ids] of subscribers) for (const subscriptionId of ids) { + send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() }); + } + }; + + let nextId = 0; + const operations: Record Promise> = { + join: async () => { + const id = `p${++nextId}`; + const state = cache.getRequired(arenaDocument, arenaKey); + await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } }); + return { id }; + }, + move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => { + const state = cache.getRequired(arenaDocument, arenaKey); + const me = state.players[id]; + if (!me) return { ok: false }; + const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v)); + await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } }); + return { ok: true }; + }, + leave: async ({ id }: { id: string }) => { + const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players; + await publish({ players: rest }); + return { ok: true }; + }, + }; + + const wss = new WebSocketServer({ server: http }); + wss.on("connection", (ws, req) => { + const odin = req.url === "/odin"; + ws.on("close", () => subscribers.delete(ws)); + ws.on("message", async raw => { + const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer))); + if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") { + send(ws, { + schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId, + verses: [{ + verseId, displayName: "Arena", authorityModel: "ServerAuthoritative", + compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] }, + discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId], + authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }], + }], + }); + } else if (m.schemaVersion === "cultmesh.session_open.v2") { + send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId, + protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" }); + } else if (m.schemaVersion === "cultnet.database_subscribe.v0") { + subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]); + send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] }); + } else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") { + subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId)); + } else if (m.schemaVersion === "cultnet.operation_request.v0") { + const handler = operations[m.operation]; + const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined; + send(ws, { + schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation, + status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64", + payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId, + }); + } + }); + }); + + return { + odinUrl: `${endpoint}/odin`, + state: () => cache.getRequired(arenaDocument, arenaKey), + close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); }, + }; +} diff --git a/docs/write-ownership-map.md b/docs/write-ownership-map.md new file mode 100644 index 00000000..33229fb1 --- /dev/null +++ b/docs/write-ownership-map.md @@ -0,0 +1,119 @@ +# Write-ownership map + +Imagination, 2026-10-27/28, session `self-2026-10-27c-eureka`. Base: CultLib `main` at `b7a68884` (C2a landed). +Serves `variants:follow_up:write-ownership-cut`. Spec: `variants:cut_spec:cut-write-ownership-cs.r1`. Question: +`variants:question:written-type-undeclared-drift`. + +## The rulings: what decides and what illustrates + +- `variants:ruling:cache-refuses-retype`. Her words decide: a stored record's schema id identifies its type, so a write of + another type at that key is the wrong type and is refused, by a cache exactly as by a cacheless write. Retyping is an + explicit remove plus add. The Household / `_pet` / rake analogy is an illustration, not a rule about object fields. Self's + gloss (ownership becomes per staged key) is Self's reading, not hers. +- `variants:ruling:write-ownership-by-written-type`. Her words decide one thing: a record under the written type's own id is + equivalent by construction, so which type owns that id cannot change the write. The full rule ("own id or one the written + type declares it migrates from; anything else is refused; no registry, Shared included, and no owner/lister precedence + enters a write's ownership decision") is Self's reading, which she accepted in the same exchange. Her remark that ids + indicating structural equivalence beats demanding names match is context for the open + `variants:question:store-schema-id-derivation`, not a ruling here. +- In force and touched: `variants:ruling:drift-record-write-ownership` (operator: a type that reads a record as compatible + drift owns it and restamps it, as 2.0.1 does), `variants:ruling:mesh-writes-bound-by-declaration`, + `variants:ruling:undeclared-read-only`, `variants:ruling:unheld-removal-refused-at-commit`, + `variants:ruling:unheld-removal-multi-store-cost-ruled`. + +The two Self readings disagree on one case: a record under an undeclared older id of the written schema name, read whole +(an undeclared version bump). Literal "anything else is refused" makes it read-only; `drift-record-write-ownership` makes it +owned. That is `variants:question:written-type-undeclared-drift`, and only the operator can settle it. + +## Body facts (probed at b7a68884) + +Probes: scratch tests in this worktree, removed. C# filtered runs on Starfire (one job each); TS and Rust on Yggdrasil +(`ygg-verify.sh`, scratch commit `78f604fd`, not pushed to the forge); Python locally. Types A (`tests.wo_a`) and B +(`tests.wo_b`) share one shape; N1/N2 are two versions of `tests.wo_n`, nothing declared. + +| Probe | C# | TypeScript | Rust | Python | +|---|---|---|---|---| +| P1 cache {A,B}: B at k, then A at k | **C**: B's record replaced, k now A's id | C, and **both** records live at k | C, both live at k | C, both live at k | +| P2 cache {A} only, store holds B at k, write A at k | R `CultSchemaConflictException` (B is foreign, carried) | R at load: "No schema is registered for persisted schema id" | R at load, same | R at load: "Unknown persisted document type" | +| P3 cacheless `WriteDocument` over B, registry null / {A} / {A,B} | R / R / R | no cacheless write | no cacheless write | no cacheless write | +| P4 cache {A,B}: remove k and upsert A at k in one batch | C (k is A's) | n/a | n/a | n/a | +| P5 cache {A,B}: remove, then add in a second commit | C | n/a | n/a | n/a | +| P6 N2 over an N1 record (undeclared name drift), cache {N2} / cacheless reg {N2} | C restamped / C restamped | not probed | not probed | C (cache) | + +What this establishes: + +- C#: one record per key. The only path that retypes is the cache arm (P1): `CacheBackingStore.RefuseUndeclared` + (`CultCache.cs:3994-4009`) and the on-disk judge (`OwnsOnDisk`, `:4731-4763`) ask `Registry.Owns(schemaId, catalog)` + (`:841-842`), which answers per schema id for the whole registry: any registered type declaring the id makes every + registered type's write to that key owned. The cacheless arm already refuses the retype (P3) but decides it through + `CatalogEntryLookup`'s registry half (`:596-634`) and the claimant precedence, which is what the ruling removes. + The judge is also per batch, not per key: the cacheless predicate's `declared` is the union over every written entry + (`:4752-4754`), and `UndeclaredOnDisk` memoises one answer per schema id (`:4784-4797`). +- Retype as remove plus add already works in the cache (P4, P5); the cut adds no new primitive. +- Directory store: its own judge (`DirectoryMessagePackBackingStore.cs:287-306`) asks `Registry.Owns` for every key, + upserts included. It must take the same per-key rule. +- TypeScript, Rust, Python: records are keyed by `(type, key)` (Rust `entries: BTreeMap<(String, String), _>`, + `lib.rs:2659`; TS `#entryId(definition.type, key)`, `cult-cache.ts:323`; Python sorts and stores by `(type, key)`). + A write of A at k never meets B's record: both live at k (P1). None carries a foreign record; a store holding an + unregistered type does not load (P2). So no leaf has a "write meets another type's record" path to fix. Bringing the + ruling to the leaves first needs C#'s key identity (one record per key) and C#'s foreign carry, which are parity changes + of the leaves' store model, not a write-ownership rule. Owned by the new follow-up `variants:follow_up:leaf-key-identity-parity`. +- 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. + +## Model and authority table + +| Value | Today (b7a68884) | After the cut | +|---|---|---| +| May an upsert of T replace the stored record at k? | cache: `Registry.Owns(id)` (any registered type); cacheless: union of written entries' declarations plus claimant over written entries and the lent registry | **Owner: `CultDocumentRegistry.WrittenOwns(T's entry, stored id, catalog)`**, per key, both arms, both store kinds | +| May a removal (or check) at k proceed? | cache: `Registry.Owns`; cacheless: the written-entries predicate | unchanged in kind: cache `Registry.Owns`; cacheless: owned when some entry the call writes `WrittenOwns` it (no registry) | +| Which type reads a record (claimant precedence) | `Claimant` | unchanged; read machinery only, no write asks it except through `WrittenOwns`' one-entry lookup (option `own-entry-reads-whole`) | +| Lent registry in a cacheless write | encodes, decides copied-record v3 header, adds claimants | encodes and decides the copied-record header (`CopiedHoldIds`, `:4600-4630`); **adds no claimants** | +| `CatalogEntryLookup` registry half (`_others`, `Other`, `Others`) | live | **deleted** | +| `CultDocumentRegistry.Lookup` | live | **deleted** | +| `Owns`'s `mayClaim` | live | **deleted** (the lookup holds only the written entry) | +| Foreign record at k | refused for every write | unchanged | + +Authority map (also in the spec): + +- Owner: `WrittenOwns(written, storedId, catalog)` decides whether an upsert of `written` may replace the record stored at + its key. Inputs: the written catalog entry, the stored record's schema id, the catalog that publishes it. Nothing else. +- Derived: `Registry.Owns` is demoted to the removal/check rule and to load-time read-only marking; it no longer decides + upserts. `Claimant` is read machinery. +- Forbidden writers: `Registry.Owns` on an upsert key; `CatalogEntryLookup` with a registry; the per-batch union `declared`; + the per-schema-id memo across different written entries. +- Shared paths: cache staging (`Claim` -> `RefuseUndeclared`, `:3156`), single-file on-disk judge (`ApplyWriteSet` -> + `RefuseUndeclaredOnDisk`, `:4565`, `:4736`), single-file `JudgeChecks` (`:4726`, checks only), directory store judge, + `WriteRecord`, `WriteDocument`, unattached `PushAll`, Mesh's `WriteSingleFileDocument` and raw-payload write. + +## Absorb or leave + +- `variants:follow_up:drift-write-ownership-leaves`: **leave**, but it now waits on `leaf-key-identity-parity`. Its premise + (give each leaf the C# Owns rule) assumes one record per key, which no leaf has (P1). The leaf write-ownership cuts in the + sequence below close it together with this follow-up's leaf half. +- `variants:follow_up:public-store-writes-behind-cache`: **leave**. It asks who may stage behind an attached cache (API + shape); this cut changes what any staging is judged by. No collision: direct `Push`/`CommitBatch` reach the same on-disk + judge, so they get the per-key rule for free. Hands must not give direct staging a different predicate. +- `variants:follow_up:writer-dangling-link-tolerance`: **leave**. Rust/TS whole-store `push_all` reading a dangling link is + a reader/writer tolerance question, not ownership; this cut touches no leaf. + +## Deferred doc findings this cut fixes + +The ownership-docs rewrite in part 1 is the fix for (each resolved `Deferred` to `write-ownership-cut`): +`variants:finding:cut-claimant-one-owner.s2.changelog-underdescribes-refusals`, +`variants:finding:cut-claimant-one-owner.s3.changelog-misstates-subset`, +`variants:finding:cut-claimant-one-owner.s5.refusal-list-ignores-declared-win`, +`variants:finding:cut-claimant-one-owner.s5.shared-read-stated-as-contract`. +Sites: `unity/org.gamecult.cultlib/CHANGELOG.md:16`, `src/GameCult.Caching/Contracts/cultcache-persistence-format.md:209-219`, +`CultCache.cs` `WriteRecord`/`WriteDocument` registry docs (`:4476-4519`). Self closes them `Fixed` after Soul holds. + +## Cut sequence + +1. `cut-write-ownership-cs` (spec r1, admitted, blocked on `variants:question:written-type-undeclared-drift`): C# reference, + both arms, both store kinds, docs. Lands before the 3.0.0 release cut. +2. `leaf-key-identity` (one per leaf, TS, Rust, Python; follow-up `leaf-key-identity-parity`): one record per key and + foreign carry at C# parity, with a cross-runtime vector. Not mapped. +3. `leaf-write-ownership` (one per leaf, after 2): `WrittenOwns` per leaf; closes `drift-write-ownership-leaves`. Not mapped. + +Leaves ship on their own versions (cultcache-ts 0.14.2), not in the 3.0.0 Unity package, so 2 and 3 do not gate 3.0.0; +whether they must land first is Self's call. diff --git a/package-lock.json b/package-lock.json index 9100d8db..0a1558da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -482,6 +482,18 @@ "resolved": "packages/cultcache-ts", "link": true }, + "node_modules/@gamecult/cultmesh-browser": { + "resolved": "packages/cultmesh-browser", + "link": true + }, + "node_modules/@gamecult/cultmesh-ts": { + "resolved": "packages/cultmesh-ts", + "link": true + }, + "node_modules/@gamecult/cultnet-ts": { + "resolved": "packages/cultnet-ts", + "link": true + }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", @@ -865,18 +877,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/cultmesh-browser": { - "resolved": "packages/cultmesh-browser", - "link": true - }, - "node_modules/cultmesh-ts": { - "resolved": "packages/cultmesh-ts", - "link": true - }, - "node_modules/cultnet-ts": { - "resolved": "packages/cultnet-ts", - "link": true - }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -1184,7 +1184,7 @@ }, "packages/cultcache-ts": { "name": "@gamecult/cultcache-ts", - "version": "0.14.2", + "version": "0.15.0", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.3", @@ -1200,15 +1200,16 @@ } }, "packages/cultmesh-browser": { + "name": "@gamecult/cultmesh-browser", "version": "0.4.0", "license": "MIT", "dependencies": { - "@gamecult/cultcache-ts": "^0.14.0", - "cultnet-ts": "^0.3.0" + "@gamecult/cultcache-ts": "^0.15.0", + "@gamecult/cultnet-ts": "^0.3.0" }, "devDependencies": { + "@gamecult/cultmesh-ts": "^0.2.0", "@types/node": "^24.0.0", - "cultmesh-ts": "^0.2.0", "typescript": "^5.9.0" }, "engines": { @@ -1216,11 +1217,12 @@ } }, "packages/cultmesh-ts": { + "name": "@gamecult/cultmesh-ts", "version": "0.2.0", "license": "MIT", "dependencies": { - "@gamecult/cultcache-ts": "^0.14.0", - "cultnet-ts": "^0.3.0", + "@gamecult/cultcache-ts": "^0.15.0", + "@gamecult/cultnet-ts": "^0.3.0", "koffi": "3.3.0" }, "devDependencies": { @@ -1233,10 +1235,11 @@ } }, "packages/cultnet-ts": { + "name": "@gamecult/cultnet-ts", "version": "0.3.0", "license": "MIT", "dependencies": { - "@gamecult/cultcache-ts": "^0.14.1", + "@gamecult/cultcache-ts": "^0.15.0", "@msgpack/msgpack": "^3.1.3", "ajv": "^8.17.1" }, diff --git a/package.json b/package.json index c9b79e47..a79661f2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "build:ts": "node scripts/run-typescript-workspaces.mjs build", "test:ts": "node scripts/run-typescript-workspaces.mjs test", - "test:pack:ts": "node scripts/test-typescript-package-closure.mjs", + "test:pack:ts": "node scripts/test-typescript-package-closure.mjs && node scripts/test-declared-dependency-ranges.mjs", "test:eve-browser-network": "node scripts/verify-eve-browser-network.mjs" }, "devDependencies": { diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md new file mode 100644 index 00000000..b460ab05 --- /dev/null +++ b/packages/cultcache-py/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## Unreleased + +### 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. +- The single-file store's `push`, `push_all` and `delete` take the cross-process lock on `.lock` (flock; LockFileEx over the whole file on Windows) across the read and the replace, waiting up to `lock_timeout` seconds (30 by default, `TimeoutError` after), and exclude the C#, Rust and TypeScript runtimes' writers. The staged file is `.tmp--`, fsynced before the replace, with a directory fsync after it off Windows. A fixed `.tmp` is no longer used. + +### 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 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 + write the catalog cannot describe. A store is exactly three slots; a schema is never recovered from a record's payload. +- The single-file writer derives the catalog from the records it writes: one entry per carried schema id, the registered entry over an + arrived one, written as chosen and never merged. A cache stamps a record with its registered schema's id. +- Every registration refusal is now a `SchemaConflictError`, a `ValueError`, where it was a `CultCacheError`, a `RuntimeError`: registering a document type or a schema name twice no longer raises what `except CultCacheError` or `except RuntimeError` catches. +- Two documents that own one schema id are refused at registration (`SchemaConflictError`). 2.0.1 accepted them. A document may still list another's id as compatible. +- A loaded record resolves by its schema id first: the document that owns the id, else the one document that lists it as compatible; several listers and no owner refuse the record with `SchemaConflictError`. The record's type name resolves it only when its schema id names no registered document. 2.0.1 resolved by the record's type name alone. diff --git a/packages/cultcache-py/src/cultcache_py/__init__.py b/packages/cultcache-py/src/cultcache_py/__init__.py index dbfac403..aabd21bf 100644 --- a/packages/cultcache-py/src/cultcache_py/__init__.py +++ b/packages/cultcache-py/src/cultcache_py/__init__.py @@ -17,7 +17,7 @@ from .documents import ( define_union, ) from .leaves import SlotCodec -from .stores import JsonLinesBackingStore, SingleFileMessagePackBackingStore +from .stores import JsonLinesBackingStore, SchemaConflictError, SingleFileMessagePackBackingStore, StoreUnreadableError __all__ = [ "BackingStore", @@ -32,8 +32,10 @@ __all__ = [ "JsonLinesBackingStore", "MAX_PAYLOAD_DEPTH", "MAX_SLOT_KEY", + "SchemaConflictError", "SingleFileMessagePackBackingStore", "SlotCodec", + "StoreUnreadableError", "UnionArm", "leaves", "database_entry_field", diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index 0da2c04a..8f4ac759 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -7,6 +7,7 @@ from typing import Any, Callable, Generic, TypeVar from .backing_store import BackingStore, CultCacheEnvelope from .documents import DocumentDefinition, extract_value +from .stores import SchemaConflictError T = TypeVar("T") F = TypeVar("F", bound=Callable[..., Any]) @@ -138,11 +139,28 @@ class CultCache: self._register_document_type(document) def _register_document_type(self, document: DocumentDefinition[Any]) -> None: + # Every registration refusal is a SchemaConflictError naming the new document's schema id and both schema names. + entry = document.catalog_entry() + schema_name = entry.schema_name + + def refuse(registered: DocumentDefinition[Any], what: str) -> SchemaConflictError: + return SchemaConflictError( + entry.schema_id, + [registered.catalog_entry().schema_name, schema_name], + "", + message=f"{what} is already registered to document type {registered.type!r} and cannot also be claimed by " + f"{document.type!r}.", + ) + if document.type in self._state.documents: - raise CultCacheError(f"Document type already registered: {document.type}") - schema_name = document.catalog_entry().schema_name + raise refuse(self._state.documents[document.type], f"Document type {document.type!r}") if schema_name in self._state.documents_by_schema_name: - raise CultCacheError(f"Document schema name already registered: {schema_name}") + raise refuse(self._state.documents_by_schema_name[schema_name], f"Schema name {schema_name!r}") + # One document owns each schema id. Others may list it as compatible beside its owner: a record under the id resolves to + # its owner, and to a lister only when nothing owns it. Neither depends on the order documents were registered in. + for registered in self._state.documents.values(): + if registered.catalog_entry().schema_id == entry.schema_id: + raise refuse(registered, f"Schema id {entry.schema_id!r}") name_extractors = dict(self._state.name_extractors) index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()} if document.name is not None: @@ -280,6 +298,7 @@ class CultCache: if envelope.key != GLOBAL_KEY: loaded_legacy_global_keys[envelope.type] = envelope.key envelope = replace(envelope, key=GLOBAL_KEY) + envelope = self._stamped(document, envelope) value = document.decode_payload(envelope.payload) loaded_values.setdefault(envelope.type, {})[envelope.key] = value loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope @@ -372,6 +391,7 @@ class CultCache: def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T: self._assert_registered(document) self._check_envelope(document, envelope) + envelope = self._stamped(document, envelope) value = document.decode_payload(envelope.payload) self._write(document, [(envelope, value)], batch=False) return value @@ -380,12 +400,21 @@ class CultCache: def put_envelopes(self, document: DocumentDefinition[T], envelopes: list[CultCacheEnvelope]) -> list[T]: self._assert_registered(document) values: list[T] = [] + stamped: list[CultCacheEnvelope] = [] for envelope in envelopes: self._check_envelope(document, envelope) + stamped.append(self._stamped(document, envelope)) values.append(document.decode_payload(envelope.payload)) - self._write(document, list(zip(envelopes, values)), batch=True) + self._write(document, list(zip(stamped, values)), batch=True) return values + @staticmethod + def _stamped(document: DocumentDefinition[Any], envelope: CultCacheEnvelope) -> CultCacheEnvelope: + """The record under its registered schema's id, whatever id the envelope arrived under, so the catalog entry a store + writes is the one its schema id names.""" + entry = document.catalog_entry() + return replace(envelope, schema_id=entry.schema_id, catalog_entry=entry) + @staticmethod def _check_envelope(document: DocumentDefinition[Any], envelope: CultCacheEnvelope) -> None: if envelope.type != document.type: @@ -581,6 +610,27 @@ class CultCache: raise CultCacheError(f"Document type is not global: {document.type}") def _resolve_document_for_envelope(self, envelope: CultCacheEnvelope) -> DocumentDefinition[Any] | None: + """A record resolves by its schema id: the document that owns the id, else the one document that lists it as compatible. + Several listers and no owner name no single document, and the record is refused. The name its catalog carries is + metadata; it names a document only when the id names none (a store from a runtime whose schema ids this one cannot + know).""" + if envelope.schema_id: + entries = [(document, document.catalog_entry()) for document in self._state.documents.values()] + for document, entry in entries: + if entry.schema_id == envelope.schema_id: + return document + listers = [document for document, entry in entries if envelope.schema_id in entry.compatible_schema_ids] + if len(listers) > 1: + raise SchemaConflictError( + envelope.schema_id, + sorted(document.catalog_entry().schema_name for document in listers), + envelope.key, + message=f"Schema id {envelope.schema_id!r} of record {envelope.key!r} is owned by no registered document and " + f"declared compatible by several ({', '.join(sorted(document.type for document in listers))}), so it resolves " + "to none. Register the document that owns the id, or declare it on one document.", + ) + if listers: + return listers[0] document = self._state.documents.get(envelope.type) if document is not None: return document diff --git a/packages/cultcache-py/src/cultcache_py/documents.py b/packages/cultcache-py/src/cultcache_py/documents.py index 91d09ab8..23849713 100644 --- a/packages/cultcache-py/src/cultcache_py/documents.py +++ b/packages/cultcache-py/src/cultcache_py/documents.py @@ -6,6 +6,9 @@ from collections.abc import Mapping, Sequence, Set from itertools import chain from typing import Any, Callable, Generic, Iterable, TypeVar +import msgpack +from msgpack.exceptions import StackError as _MsgpackStackError + from .backing_store import CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember from .leaves import Float32, SlotCodec from .leaves import any as _any_leaf @@ -24,12 +27,7 @@ or skipped, the outermost one is depth 1; depth 256 is accepted and 257 refused, MAX_SLOT_KEY = 65535 """The largest slot key a record may define: the largest MessagePack array16 length.""" -try: - from msgpack.exceptions import StackError as _MsgpackStackError - - _STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,) -except ModuleNotFoundError: - _STACK_FAULTS = () +_STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,) _CONTAINERS = (Mapping, Sequence, Set) @@ -85,22 +83,10 @@ def _json_payload_decoder(payload: bytes) -> Any: return json.loads(payload.decode("utf-8")) -def _msgpack() -> Any: - try: - import msgpack # type: ignore - except ModuleNotFoundError as exc: - raise RuntimeError( - "MessagePack payload encoding requires the optional 'msgpack' dependency. " - "Install with: python -m pip install cultcache-py[msgpack]" - ) from exc - return msgpack - - def _pack_tree(tree: Any) -> bytes: """The bytes of the tree the leaves produced. `packb` writes everything but a `Float32`, which it refuses with a TypeError; only then does an iterative walk pack each array and map header and each value, a `Float32` through a single-float packer.""" - msgpack = _msgpack() try: return msgpack.packb(tree, use_bin_type=True) # type: ignore[no-any-return] except TypeError: @@ -125,13 +111,6 @@ def _pack_tree(tree: Any) -> bytes: def _messagepack_payload_decoder(payload: bytes) -> Any: - try: - import msgpack # type: ignore - except ModuleNotFoundError as exc: - raise RuntimeError( - "MessagePack payload decoding requires the optional 'msgpack' dependency. " - "Install with: python -m pip install cultcache-py[msgpack]" - ) from exc return msgpack.unpackb(payload, raw=False) diff --git a/packages/cultcache-py/src/cultcache_py/leaves.py b/packages/cultcache-py/src/cultcache_py/leaves.py index 2d76078e..71bc6204 100644 --- a/packages/cultcache-py/src/cultcache_py/leaves.py +++ b/packages/cultcache-py/src/cultcache_py/leaves.py @@ -16,10 +16,7 @@ from collections.abc import Mapping from dataclasses import dataclass from typing import Any, Callable, NoReturn -try: - from msgpack import ExtType as _ExtType -except ModuleNotFoundError: - _ExtType = () # type: ignore[assignment,misc] +from msgpack import ExtType as _ExtType _bool, _bytes, _list, _int, _float, _str = bool, bytes, list, int, float, str _BYTE_LIKE = (_bytes, bytearray, memoryview) diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 704a3851..77d671ad 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -3,9 +3,15 @@ import base64 import json import os +import sys import threading +import time +import uuid +from contextlib import contextmanager from pathlib import Path -from typing import Any, Callable, TypeVar +from typing import Any, Callable, Iterator, TypeVar + +import msgpack from .backing_store import ( CultCacheEnvelope, @@ -14,11 +20,44 @@ from .backing_store import ( ) STORE_FORMAT_VERSION = "cultcache.store.v1" +# A store that can hold element ids. A reader older than element ids refuses this header: it would skip the id slots of a +# nested element and rewrite the element without them. This runtime writes it back only for a store it read under it; it +# does not decide when a document carries ids. +STORE_FORMAT_ELEMENT_IDS = "cultcache.store.v3" +_READABLE_STORE_FORMATS = (STORE_FORMAT_VERSION, STORE_FORMAT_ELEMENT_IDS) _STORE_FORMAT_PREFIX = "cultcache.store." _PERSISTED_RECORD_SLOTS = 4 +_LOCK_POLL_SECONDS = 0.01 +LOCK_TIMEOUT_SECONDS = 30.0 _Contents = TypeVar("_Contents") +class StoreUnreadableError(ValueError): + """A store file this runtime cannot read: not exactly one complete store (truncated, bytes after it, a missing or extra + slot), a header or record it does not know, or a body it cannot decode. Open, push, delete and push_all refuse a file + with this error, and a refused file is left as it was. The cause is `__cause__`.""" + + def __init__(self, path: Path, cause: BaseException) -> None: + super().__init__(f"CultCache store {path} is not readable: {cause}") + self.path = path + + +class SchemaConflictError(ValueError): + """A write the catalog cannot describe: records of different types under one schema id, two arrived schemas that share an id + and disagree on the schema name, or a record no chosen entry publishes. Nothing is written. It names the id, the names + involved (`schema_names`) and a record key (`record_key`). A cache raises it too, with an empty record key, when a + document registers a schema id another registered document already carries.""" + + def __init__(self, schema_id: str, schema_names: list[str], record_key: str, *, message: str | None = None) -> None: + super().__init__( + message + or f"Schema id {schema_id!r} cannot describe record {record_key!r}: {', '.join(repr(name) for name in schema_names)}" + ) + self.schema_id = schema_id + self.schema_names = schema_names + self.record_key = record_key + + class JsonLinesBackingStore: def __init__(self, path: str | os.PathLike[str]) -> None: self.path = Path(path) @@ -83,76 +122,226 @@ class JsonLinesBackingStore: class SingleFileMessagePackBackingStore: - def __init__(self, path: str | os.PathLike[str]) -> None: + def __init__(self, path: str | os.PathLike[str], lock_timeout: float = LOCK_TIMEOUT_SECONDS) -> None: self.path = Path(path) + self.lock_timeout = lock_timeout self._lock = threading.RLock() + # The header this store read; a rewrite keeps it, so a store that holds element ids never sheds its marker. + self._format = STORE_FORMAT_VERSION def pull_all(self) -> list[CultCacheEnvelope]: - msgpack = self._msgpack() with self._lock: # An empty file is not a store (no writer leaves one), so it is decoded and refused. + # The disk decides the header: a file that is gone is not marked. + self._format = STORE_FORMAT_VERSION data = _read_store(self.path, Path.read_bytes) if data is None: return [] - decoded = msgpack.unpackb(data, raw=False) - snapshot = _decode_v1_snapshot(decoded) - if snapshot is not None: - return snapshot - return _decode_legacy_envelopes(decoded, msgpack) + try: + decoded = msgpack.unpackb(data, raw=False) + snapshot = _decode_snapshot(decoded) + if snapshot is not None: + self._format, envelopes = snapshot + return envelopes + return _decode_legacy_envelopes(decoded) + except Exception as exc: + raise StoreUnreadableError(self.path, exc) from exc def push(self, envelope: CultCacheEnvelope) -> None: - with self._lock: - existing = {(item.type, item.key): item for item in self.pull_all()} - existing[(envelope.type, envelope.key)] = envelope - self._replace_all(list(existing.values())) + with self._write_transaction(): + self._replace_all([envelope], self.pull_all()) def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: - with self._lock: - existing = {(item.type, item.key): item for item in self.pull_all()} - for envelope in envelopes: - existing[(envelope.type, envelope.key)] = envelope - self._replace_all(list(existing.values())) + with self._write_transaction(): + self._replace_all(list(envelopes), self.pull_all()) def delete(self, type: str, key: str) -> None: + with self._write_transaction(): + self._replace_all([], [item for item in self.pull_all() if not (item.type == type and item.key == key)]) + + @contextmanager + def _write_transaction(self) -> Iterator[None]: + """The thread lock, then the cross-process lock on `.lock`, held across the read and the replace so two + processes never rewrite from the same stale read.""" with self._lock: - existing = [item for item in self.pull_all() if not (item.type == type and item.key == key)] - self._replace_all(existing) + self.path.parent.mkdir(parents=True, exist_ok=True) + with store_write_lock(self.path, self.lock_timeout): + yield - def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None: - msgpack = self._msgpack() - self.path.parent.mkdir(parents=True, exist_ok=True) - temp = self.path.with_suffix(self.path.suffix + ".tmp") - temp.write_bytes(msgpack.packb(_encode_v1_snapshot(envelopes), use_bin_type=True)) - temp.replace(self.path) - - @staticmethod - def _msgpack() -> Any: - try: - import msgpack # type: ignore - except ModuleNotFoundError as exc: - raise RuntimeError( - "SingleFileMessagePackBackingStore requires the optional 'msgpack' dependency. " - "Install with: python -m pip install cultcache-py[msgpack]" - ) from exc - return msgpack + def _replace_all(self, supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope]) -> None: + commit_store_file(self.path, msgpack.packb(_encode_snapshot(supplied, arrived, self._format), use_bin_type=True)) -def _encode_v1_snapshot(envelopes: list[CultCacheEnvelope]) -> list[Any]: - catalog_by_schema_id: dict[str, CultCacheSchemaCatalogEntry] = {} - for envelope in envelopes: + +def canonical(candidates: Any) -> list[CultCacheSchemaCatalogEntry]: + """Entries that tie, in one fixed order, so the catalog does not depend on the order they arrive in.""" + return sorted(candidates, key=lambda entry: (entry.schema_name, entry.content_hash, ",".join(entry.compatible_schema_ids))) + + +def _derive_catalog(records: list[tuple[CultCacheEnvelope, bool]]) -> list[CultCacheSchemaCatalogEntry]: + """The catalog a write leaves, derived from the records being written and nothing else. For each schema id a record carries, + one entry that publishes it: the entry that owns the id (a registered one, else one that arrived with the records), else one + that lists it as compatible (registered first). Entries are written as chosen, never merged; a registered entry wins over an + arrived one with the same own id. Records of different types under one id, two arrived entries with the same own id and + different schema names, or a record no chosen entry publishes, refuse the write. `records` are (envelope, registered): an + envelope the caller supplied is registered, one read back from the file is arrived.""" + entries: list[tuple[CultCacheSchemaCatalogEntry, bool]] = [] + by_id: dict[str, list[CultCacheEnvelope]] = {} + tiers: dict[str, list[tuple[CultCacheEnvelope, bool, CultCacheSchemaCatalogEntry]]] = {} + for envelope, registered in records: schema_id = _schema_id_for(envelope) - if schema_id not in catalog_by_schema_id: - catalog_by_schema_id[schema_id] = envelope.catalog_entry or _default_catalog_entry(envelope) + supplied = envelope.catalog_entry + publishes = supplied is not None and (supplied.schema_id == schema_id or schema_id in supplied.compatible_schema_ids) + entry = supplied if publishes else _default_catalog_entry(envelope) + entries.append((entry, registered)) + by_id.setdefault(schema_id, []).append(envelope) + tiers.setdefault(schema_id, []).append((envelope, registered, entry)) + chosen: dict[str, tuple[CultCacheSchemaCatalogEntry, bool]] = {} + for schema_id in sorted(by_id): + carrying = by_id[schema_id] + record_key = min(envelope.key for envelope in carrying) + # Records of one tier under one id are one type. Across tiers the id keeps the type its records resolve to, so a write may + # not retype records that are already there: unless the entries of both tiers own the id, which is a rename of the schema + # under a stable id, and the registered descriptor wins. A read-back record's type is its schema's name, so tiers compare + # the names of the entries that publish the id. + for tier in (True, False): + types = sorted({envelope.type for envelope, registered, _ in tiers[schema_id] if registered == tier}) + if len(types) > 1: + raise SchemaConflictError(schema_id, types, record_key) + registered_names = sorted({entry.schema_name for _, registered, entry in tiers[schema_id] if registered}) + arrived_names = sorted({entry.schema_name for _, registered, entry in tiers[schema_id] if not registered}) + owned_by = lambda tier: any(entry.schema_id == schema_id for _, registered, entry in tiers[schema_id] if registered == tier) + if registered_names and arrived_names and registered_names[0] != arrived_names[0] and not (owned_by(True) and owned_by(False)): + raise SchemaConflictError(schema_id, sorted([registered_names[0], arrived_names[0]]), record_key) + own_registered = next(iter(canonical(entry for entry, registered in entries if registered and entry.schema_id == schema_id)), None) + own_arrived = canonical(entry for entry, registered in entries if not registered and entry.schema_id == schema_id) + own_registered_all = canonical(entry for entry, registered in entries if registered and entry.schema_id == schema_id) + # Entries of one tier that share an own id must be one schema; a registered entry settles a disagreement among arrived ones. + names = sorted({entry.schema_name for entry in (own_registered_all or own_arrived)}) + if len(names) > 1: + raise SchemaConflictError(schema_id, names, record_key) + if own_registered is not None: + pick, registered_pick = own_registered, True + elif own_arrived: + pick, registered_pick = own_arrived[0], False + else: + listed_registered = next(iter(canonical(entry for entry, registered in entries if registered and schema_id in entry.compatible_schema_ids)), None) + listed_arrived = next(iter(canonical(entry for entry, registered in entries if not registered and schema_id in entry.compatible_schema_ids)), None) + pick = listed_registered if listed_registered is not None else listed_arrived + registered_pick = listed_registered is not None + if pick is None: + continue + prior = chosen.get(pick.schema_id) + if prior is not None and prior[1] and not registered_pick: + continue + chosen[pick.schema_id] = (pick, registered_pick) + + for schema_id in sorted(by_id): + if not any(entry.schema_id == schema_id or schema_id in entry.compatible_schema_ids for entry, _ in chosen.values()): + raise SchemaConflictError( + schema_id, sorted(entry.schema_name for entry, _ in chosen.values()), min(envelope.key for envelope in by_id[schema_id]) + ) + return [entry for entry, _ in chosen.values()] + + +def _encode_snapshot( + supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope], format_version: str +) -> list[Any]: + replaced = {(envelope.type, envelope.key) for envelope in supplied} + kept = [envelope for envelope in arrived if (envelope.type, envelope.key) not in replaced] + envelopes = [*kept, *supplied] catalog = [ _encode_catalog_entry(entry) - for entry in sorted(catalog_by_schema_id.values(), key=lambda item: item.schema_name) + for entry in sorted( + _derive_catalog([*((envelope, False) for envelope in kept), *((envelope, True) for envelope in supplied)]), + key=lambda item: (item.schema_name, item.schema_id), + ) ] records = [ [envelope.key, _schema_id_for(envelope), envelope.stored_at, envelope.payload] for envelope in sorted(envelopes, key=lambda item: item.key) ] - return [STORE_FORMAT_VERSION, catalog, records] + return [format_version, catalog, records] + +def commit_store_file(path: Path, data: bytes) -> None: + """Writes `data` to a unique staged file beside the store, fsyncs it, replaces the store with it and, off Windows, + fsyncs the directory so the replace is durable. A failure before the replace removes the staged file and leaves the + store untouched. The caller holds the store lock.""" + staged = path.with_name(f"{path.name}.tmp-{os.getpid()}-{uuid.uuid4().hex}") + try: + fd = os.open(staged, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o666) + try: + view = memoryview(data) + while view: + view = view[os.write(fd, view):] + os.fsync(fd) + finally: + os.close(fd) + os.replace(staged, path) + except BaseException: + try: + os.unlink(staged) + except OSError: + pass + raise + if sys.platform != "win32": + directory = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + + +@contextmanager +def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Iterator[None]: + """Holds the exclusive store lock on `.lock`, the lock the C#, Rust and TypeScript runtimes take: flock on Unix, + LockFileEx over the whole file on Windows, polled every 10 ms with no handle held between polls. The lock file is + created if absent and never written or deleted.""" + lock_path = path.with_name(path.name + ".lock") + deadline = time.monotonic() + timeout + while True: + release = _try_lock(lock_path) + if release is not None: + break + if time.monotonic() >= deadline: + raise TimeoutError(f"CultCache timed out after {timeout} s waiting for the store lock {lock_path}.") + time.sleep(_LOCK_POLL_SECONDS) + try: + yield + finally: + release() + + +def _try_lock(lock_path: Path) -> Callable[[], None] | None: + if sys.platform == "win32": + return _try_lock_windows(lock_path) + import fcntl + + fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o666) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + os.close(fd) + return None + return lambda: os.close(fd) # closing drops the flock + + +def _try_lock_windows(lock_path: Path) -> Callable[[], None] | None: + import ctypes + import msvcrt + from ctypes import wintypes + + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.LockFileEx.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p] + fd = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_BINARY, 0o666) + handle = msvcrt.get_osfhandle(fd) + overlapped = ctypes.create_string_buffer(32) + # LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY over the whole range, as the TypeScript runtime takes it. + if not kernel.LockFileEx(handle, 0x3, 0, 0xFFFFFFFF, 0xFFFFFFFF, overlapped): + os.close(fd) + return None + return lambda: os.close(fd) # closing drops the lock def _read_store(path: Path, read: Callable[[Path], _Contents]) -> _Contents | None: @@ -186,25 +375,29 @@ def _describe_header(header: str) -> str: return f"an unrecognised {_STORE_FORMAT_PREFIX}* header of {len(header.encode('utf-8'))} bytes" -def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None: +def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None: if not isinstance(decoded, list) or not decoded: return None - if isinstance(decoded[0], str) and decoded[0].startswith(_STORE_FORMAT_PREFIX) and decoded[0] != STORE_FORMAT_VERSION: + if isinstance(decoded[0], str) and decoded[0].startswith(_STORE_FORMAT_PREFIX) and decoded[0] not in _READABLE_STORE_FORMATS: raise ValueError( f"CultCache store format {_describe_header(decoded[0])} is not one this runtime reads; " - f"it reads {STORE_FORMAT_VERSION!r} only" + f"it reads {STORE_FORMAT_VERSION!r} and {STORE_FORMAT_ELEMENT_IDS!r} only" ) - if decoded[0] != STORE_FORMAT_VERSION: + if decoded[0] not in _READABLE_STORE_FORMATS: return None - if len(decoded) < 3 or not isinstance(decoded[1], list) or not isinstance(decoded[2], list): + if len(decoded) != 3: + raise ValueError(f"CultCache store has {len(decoded)} top-level slots; this runtime reads 3 (header, schema catalog, records)") + if not isinstance(decoded[1], list) or not isinstance(decoded[2], list): raise ValueError("CultCache v1 snapshot must contain a schema catalog and record array") + # An id names the entry that owns it; only an id no entry owns names the entry that lists it as compatible. + entries = [_decode_catalog_entry(raw_entry) for raw_entry in decoded[1]] catalog_by_schema_id: dict[str, CultCacheSchemaCatalogEntry] = {} - for raw_entry in decoded[1]: - entry = _decode_catalog_entry(raw_entry) - catalog_by_schema_id[entry.schema_id] = entry + for entry in entries: + catalog_by_schema_id.setdefault(entry.schema_id, entry) + for entry in entries: for compatible_schema_id in entry.compatible_schema_ids: - catalog_by_schema_id[compatible_schema_id] = entry + catalog_by_schema_id.setdefault(compatible_schema_id, entry) envelopes: list[CultCacheEnvelope] = [] for raw_record in decoded[2]: @@ -225,19 +418,7 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None: raise ValueError("CultCache persisted records must declare storedAt") catalog_entry = catalog_by_schema_id.get(schema_id) if catalog_entry is None: - schema_version = _infer_schema_version_from_payload(_normalize_payload(payload)) - schema_name = _infer_schema_name(schema_version) if schema_version is not None else None - if schema_name is None: - raise ValueError(f'CultCache persisted record "{key}" references missing schema id "{schema_id}"') - catalog_entry = CultCacheSchemaCatalogEntry( - schema_id=schema_id, - schema_name=schema_name, - schema_version=schema_version, - content_hash=schema_id, - canonical_schema_json="", - compatible_schema_ids=(), - members=(), - ) + raise ValueError(f"CultCache persisted record '{key}' references schema id '{schema_id}', which the store's catalog does not publish") normalized_payload = _normalize_payload(payload) envelopes.append( CultCacheEnvelope( @@ -249,10 +430,10 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None: catalog_entry=catalog_entry, ) ) - return envelopes + return decoded[0], envelopes -def _decode_legacy_envelopes(decoded: Any, msgpack: Any) -> list[CultCacheEnvelope]: +def _decode_legacy_envelopes(decoded: Any) -> list[CultCacheEnvelope]: if not isinstance(decoded, list): raise ValueError("CultCache MessagePack store is not a recognized snapshot") envelopes: list[CultCacheEnvelope] = [] @@ -376,27 +557,3 @@ def _normalize_payload(payload: Any) -> bytes: if isinstance(payload, list) and all(isinstance(item, int) and 0 <= item <= 255 for item in payload): return bytes(payload) raise ValueError("CultCache record payload must be binary MessagePack bytes") - - -def _infer_schema_version_from_payload(payload: bytes) -> str | None: - try: - msgpack = SingleFileMessagePackBackingStore._msgpack() - decoded = msgpack.unpackb(payload, raw=False) - except Exception: - return None - if isinstance(decoded, list) and decoded and isinstance(decoded[0], str): - return decoded[0] - if isinstance(decoded, dict): - value = decoded.get("schemaVersion", decoded.get("schema_version")) - return value if isinstance(value, str) else None - return None - - -def _infer_schema_name(schema_version: str) -> str | None: - marker = schema_version.rfind(".v") - if marker <= 0: - return None - version = schema_version[marker + 2:] - if not version or not version.isdigit(): - return None - return schema_version[:marker] diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 6e85f833..f5bad365 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -12,6 +12,7 @@ from cultcache_py import ( CultCache, JsonLinesBackingStore, SingleFileMessagePackBackingStore, + StoreUnreadableError, define_database_entry_type, define_document_type, ) @@ -186,7 +187,7 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(raw[2][0][0], "app") self.assertEqual(raw[2][0][1], "settings") - def test_messagepack_store_recovers_schema_stamped_record_missing_catalog_entry(self) -> None: + def test_messagepack_store_refuses_a_record_whose_schema_the_catalog_does_not_publish(self) -> None: try: import msgpack # type: ignore except ModuleNotFoundError: @@ -227,11 +228,490 @@ class CultCacheTests(unittest.TestCase): .build() ) - cache.pull_all_backing_stores() - self.assertEqual(cache.get_required(document, "record-1")["value"], "still readable") - envelope = cache.get_required_envelope(document, "record-1") - self.assertEqual(envelope.type, "runtime-policy") - self.assertEqual(envelope.schema_id, "sha256:stale-schema-id-from-cold-record") + # The payload opens with a schema version, and the catalog is the store's own description: no recovery. + with self.assertRaises(StoreUnreadableError): + cache.pull_all_backing_stores() + + def _foreign_id_document(self): + return define_database_entry_type( + "tests.foreign-id", + [("name", 0)], + schema_id="tests.foreign-id.current", + schema_name="tests.foreign-id", + schema_version="tests.foreign_id.v1", + compatible_schema_ids=["tests.foreign-id.current", "tests.foreign-id.older"], + ) + + def _open_foreign_id_store(self, document, path: Path) -> CultCache: + cache = CultCache.builder().register_document_type(document).add_generic_store( + SingleFileMessagePackBackingStore(path) + ).build() + cache.pull_all_backing_stores() + return cache + + # A record put under an id its schema only lists as compatible is stamped with the registered id, and the store the write + # leaves opens and takes the next write. + def test_put_envelope_under_a_compatible_schema_id_writes_a_store_that_reopens(self) -> None: + import msgpack # type: ignore + from cultcache_py import CultCacheSchemaCatalogEntry + + document = self._foreign_id_document() + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + cache = self._open_foreign_id_store(document, path) + payload = document.encode_payload({"name": "foreign"}) + foreign_entry = CultCacheSchemaCatalogEntry( + schema_id="tests.foreign-id.older", + schema_name="tests.foreign-id", + schema_version="tests.foreign_id.v1", + content_hash="tests.foreign-id.older", + canonical_schema_json="", + compatible_schema_ids=("tests.foreign-id.older",), + members=(), + ) + cache.put_envelope( + document, + CultCacheEnvelope( + key="foreign", + type="tests.foreign-id", + payload=payload, + stored_at="2026-09-30T00:00:00Z", + schema_id="tests.foreign-id.older", + catalog_entry=foreign_entry, + ), + ) + stored = msgpack.unpackb(path.read_bytes(), raw=False) + self.assertEqual([entry[0] for entry in stored[1]], ["tests.foreign-id.current"]) + self.assertEqual(stored[2][0][1], "tests.foreign-id.current") + reopened = self._open_foreign_id_store(document, path) + self.assertEqual(reopened.get_required(document, "foreign")["name"], "foreign") + reopened.put(document, "next", {"name": "next"}) + self.assertEqual(self._open_foreign_id_store(document, path).get_required(document, "next")["name"], "next") + + def test_the_store_publishes_a_records_schema_id_even_when_the_envelopes_catalog_entry_does_not(self) -> None: + import msgpack # type: ignore + from cultcache_py import CultCacheSchemaCatalogEntry + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + store = SingleFileMessagePackBackingStore(path) + store.push(CultCacheEnvelope( + key="k", + type="tests.store-publishes", + payload=msgpack.packb({"name": "k"}, use_bin_type=True), + stored_at="2026-09-30T00:00:00Z", + schema_id="tests.store-publishes.record", + catalog_entry=CultCacheSchemaCatalogEntry( + schema_id="tests.store-publishes.other", + schema_name="tests.store-publishes", + schema_version="tests.store_publishes.v1", + content_hash="tests.store-publishes.other", + canonical_schema_json="", + compatible_schema_ids=("tests.store-publishes.other",), + members=(), + ), + )) + stored = msgpack.unpackb(path.read_bytes(), raw=False) + self.assertEqual(stored[2][0][1], "tests.store-publishes.record") + self.assertEqual([entry[0] for entry in stored[1]], ["tests.store-publishes.record"]) + self.assertEqual(len(SingleFileMessagePackBackingStore(path).pull_all()), 1) + + # The catalog a write leaves is derived from its records: one entry per carried id, chosen and never merged, the same whatever + # order the records arrive in. A registered (supplied) entry wins over an arrived (read back) entry with the same own id. + @staticmethod + def _writer_entry(schema_id: str, name: str, content_hash: str, compatible: tuple[str, ...]): + from cultcache_py import CultCacheSchemaCatalogEntry + + return CultCacheSchemaCatalogEntry( + schema_id=schema_id, schema_name=name, schema_version=name + ".v1", content_hash=content_hash, + canonical_schema_json="", compatible_schema_ids=compatible, members=(), + ) + + def _writer_record(self, key: str, type: str, schema_id: str, catalog_entry=None) -> CultCacheEnvelope: + import msgpack # type: ignore + + return CultCacheEnvelope( + key=key, type=type, payload=msgpack.packb({"key": key}, use_bin_type=True), + stored_at="2026-09-30T00:00:00Z", schema_id=schema_id, catalog_entry=catalog_entry, + ) + + @staticmethod + def _raw_entry(entry) -> list: + return [entry.schema_id, entry.schema_name, entry.schema_version, entry.content_hash, "", list(entry.compatible_schema_ids), []] + + def test_a_registered_entry_is_written_as_it_is_over_the_arrived_entries_with_the_same_own_id_in_either_order(self) -> None: + import msgpack # type: ignore + + stale1 = self._writer_entry("tests.x", "tests.n", "a-stale-1", ("tests.x", "old")) + stale2 = self._writer_entry("tests.x", "tests.n", "a-stale-2", ("tests.x",)) + registered = self._writer_record("a", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "fresh", ("tests.x",))) + for entries in ([stale1, stale2], [stale2, stale1]): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + path.write_bytes(msgpack.packb([ + "cultcache.store.v1", [self._raw_entry(entry) for entry in entries], + [["b", "tests.x", "2026-09-30T00:00:00Z", msgpack.packb({"b": 1}, use_bin_type=True)]], + ], use_bin_type=True)) + SingleFileMessagePackBackingStore(path).push(registered) + catalog = msgpack.unpackb(path.read_bytes(), raw=False)[1] + self.assertEqual(len(catalog), 1) + self.assertEqual(catalog[0][3], "fresh") + self.assertEqual(catalog[0][5], ["tests.x"], "written as chosen, no union with the arrived entries' ids") + + def test_a_rename_with_a_stable_schema_id_a_whole_view_write_names_the_registered_schema(self) -> None: + import msgpack # type: ignore + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + path.write_bytes(msgpack.packb([ + "cultcache.store.v1", [self._raw_entry(self._writer_entry("tests.x", "tests.old", "stale", ("tests.x",)))], + [["a", "tests.x", "2026-09-30T00:00:00Z", msgpack.packb({"a": 1}, use_bin_type=True)]], + ], use_bin_type=True)) + SingleFileMessagePackBackingStore(path).push_all( + [self._writer_record("a", "tests.old", "tests.x", self._writer_entry("tests.x", "tests.new", "fresh", ("tests.x",)))] + ) + catalog = msgpack.unpackb(path.read_bytes(), raw=False)[1] + self.assertEqual([(entry[1], entry[3]) for entry in catalog], [("tests.new", "fresh")]) + + def test_two_entries_of_one_tier_that_share_an_own_id_and_disagree_on_the_schema_name_refuse_the_write_typed(self) -> None: + from cultcache_py import SchemaConflictError + + first = self._writer_record("b", "tests.first", "tests.x", self._writer_entry("tests.x", "tests.first", "h1", ("tests.x",))) + second = self._writer_record("a", "tests.first", "tests.x", self._writer_entry("tests.x", "tests.second", "h2", ("tests.x",))) + for order in ([first, second], [second, first]): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + with self.assertRaises(SchemaConflictError) as refused: + SingleFileMessagePackBackingStore(path).push_all(order) + self.assertEqual(refused.exception.schema_id, "tests.x") + self.assertEqual(refused.exception.record_key, "a") + self.assertEqual(refused.exception.schema_names, ["tests.first", "tests.second"]) + self.assertFalse(path.exists()) + + def test_records_of_different_types_under_one_schema_id_refuse_the_write_typed_and_no_record_changes_type(self) -> None: + from cultcache_py import SchemaConflictError + + a = self._writer_record("a", "tests.n", "tests.y", self._writer_entry("tests.x", "tests.n", "h1", ("tests.x", "tests.y"))) + b = self._writer_record("b", "tests.m", "tests.y", self._writer_entry("tests.y", "tests.m", "h2", ("tests.y",))) + for order in ([a, b], [b, a]): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(SchemaConflictError) as refused: + SingleFileMessagePackBackingStore(Path(tmp) / "store.msgpack").push_all(order) + self.assertEqual(refused.exception.schema_id, "tests.y") + self.assertEqual(refused.exception.schema_names, ["tests.m", "tests.n"]) + + def test_an_entry_that_owns_an_id_is_chosen_over_one_that_lists_it(self) -> None: + import msgpack # type: ignore + + owner = self._writer_record("a", "tests.n", "tests.y", self._writer_entry("tests.y", "tests.n", "h1", ("tests.y",))) + lister = self._writer_record("b", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "h2", ("tests.x", "tests.y"))) + for order in ([lister, owner], [owner, lister]): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + SingleFileMessagePackBackingStore(path).push_all(order) + catalog = msgpack.unpackb(path.read_bytes(), raw=False)[1] + self.assertEqual(sorted((entry[0], entry[5]) for entry in catalog), [("tests.x", ["tests.x", "tests.y"]), ("tests.y", ["tests.y"])]) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + SingleFileMessagePackBackingStore(path).push_all([owner]) + self.assertEqual([entry[0] for entry in msgpack.unpackb(path.read_bytes(), raw=False)[1]], ["tests.y"]) + + def test_entries_of_one_tier_that_tie_are_taken_in_one_fixed_order_and_a_registered_lister_beats_an_arrived_one(self) -> None: + import msgpack # type: ignore + + # The content hash orders them against the order their compatible ids would give: h1 sorts first, its list sorts last. + one = self._writer_record("a", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "h2", ("tests.x",))) + two = self._writer_record("b", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "h1", ("tests.x", "tests.y"))) + for order in ([one, two], [two, one]): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + SingleFileMessagePackBackingStore(path).push_all(order) + self.assertEqual([entry[3] for entry in msgpack.unpackb(path.read_bytes(), raw=False)[1]], ["h1"]) + + # z sits under old, which the arrived entry (id x) lists; the supplied entry (id r) lists it too: the registered one publishes it. + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + path.write_bytes(msgpack.packb([ + "cultcache.store.v1", [self._raw_entry(self._writer_entry("tests.x", "tests.n", "stale", ("tests.x", "old")))], + [["z", "old", "2026-09-30T00:00:00Z", msgpack.packb({"z": 1}, use_bin_type=True)]], + ], use_bin_type=True)) + SingleFileMessagePackBackingStore(path).push( + self._writer_record("a", "tests.n", "tests.r", self._writer_entry("tests.r", "tests.n", "fresh", ("tests.r", "old"))) + ) + self.assertEqual([entry[0] for entry in msgpack.unpackb(path.read_bytes(), raw=False)[1]], ["tests.r"]) + + # A registered entry that owns an id keeps it against an arrived entry that owns it too and lists the id another record sits + # under, whichever of the two ids sorts first: the arrived entry's list is not merged in, so the record under the listed id is + # published by no chosen entry. + def test_an_arrived_entry_that_owns_a_registered_id_does_not_replace_it_nor_publish_what_it_lists(self) -> None: + import msgpack # type: ignore + from cultcache_py import SchemaConflictError + + for own_id, listed_id in (("tests.a", "tests.b"), ("tests.b", "tests.a")): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + path.write_bytes(msgpack.packb([ + "cultcache.store.v1", [self._raw_entry(self._writer_entry(own_id, "tests.arrived", "stale", (own_id, listed_id)))], + [["k2", listed_id, "2026-09-30T00:00:00Z", msgpack.packb({"k": 2}, use_bin_type=True)]], + ], use_bin_type=True)) + before = path.read_bytes() + with self.assertRaises(SchemaConflictError) as refused: + SingleFileMessagePackBackingStore(path).push( + self._writer_record("k1", "tests.registered", own_id, self._writer_entry(own_id, "tests.registered", "fresh", (own_id,))) + ) + self.assertEqual(refused.exception.schema_id, listed_id) + self.assertEqual(refused.exception.record_key, "k2") + self.assertEqual(path.read_bytes(), before, "the file is left as it was") + + # Ids are taken in sorted order, so which entry survives, and so which id the refusal names, does not depend on the order the + # records arrive in. + def test_the_refusal_names_the_same_id_whatever_order_the_records_arrive_in(self) -> None: + from cultcache_py import SchemaConflictError + + one = self._writer_record("r1", "tests.n", "tests.y", self._writer_entry("tests.x", "tests.n", "h1", ("tests.x", "tests.y"))) + two = self._writer_record("r2", "tests.n", "tests.z", self._writer_entry("tests.x", "tests.n", "h2", ("tests.x", "tests.z"))) + for order in ([one, two], [two, one]): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(SchemaConflictError) as refused: + SingleFileMessagePackBackingStore(Path(tmp) / "store.msgpack").push_all(order) + self.assertEqual(refused.exception.schema_id, "tests.y") + self.assertEqual(refused.exception.record_key, "r1") + + # A write may not retype records already in the file: across tiers the id keeps the type its records resolve to, unless the + # entries of both tiers own it, which is a rename under a stable id and the registered descriptor wins. + def test_a_write_that_would_change_the_type_an_existing_record_resolves_to_refuses_typed_and_leaves_the_file(self) -> None: + import msgpack # type: ignore + from cultcache_py import SchemaConflictError + + cases = [ + ("registered entry lists the id, arrived entry owns it", + self._writer_entry("tests.x", "tests.arrived", "h1", ("tests.x",)), + self._writer_record("a", "tests.registered", "tests.x", self._writer_entry("tests.r", "tests.registered", "h2", ("tests.r", "tests.x")))), + ("registered entry owns the id, arrived entry lists it", + self._writer_entry("tests.r", "tests.arrived", "h1", ("tests.r", "tests.x")), + self._writer_record("a", "tests.registered", "tests.x", self._writer_entry("tests.x", "tests.registered", "h2", ("tests.x",)))), + ] + payload = msgpack.packb({"z": 1}, use_bin_type=True) + with tempfile.TemporaryDirectory() as tmp: + for name, catalog, registered in cases: + path = Path(tmp) / (name.split(",")[0].replace(" ", "-") + ".msgpack") + path.write_bytes(msgpack.packb( + ["cultcache.store.v1", [self._raw_entry(catalog)], [["z", "tests.x", "2026-09-30T00:00:00Z", payload]]], use_bin_type=True)) + before = path.read_bytes() + with self.assertRaises(SchemaConflictError, msg=name) as refused: + SingleFileMessagePackBackingStore(path).push(registered) + self.assertEqual(refused.exception.schema_id, "tests.x", name) + self.assertEqual(sorted(refused.exception.schema_names), ["tests.arrived", "tests.registered"], name) + self.assertEqual(path.read_bytes(), before, name) + + # The same registered record renames the schema when both tiers own the id: it writes, and the reader finds every record. + path = Path(tmp) / "rename.msgpack" + path.write_bytes(msgpack.packb([ + "cultcache.store.v1", [self._raw_entry(self._writer_entry("tests.x", "tests.arrived", "h1", ("tests.x",)))], + [["z", "tests.x", "2026-09-30T00:00:00Z", payload]], + ], use_bin_type=True)) + SingleFileMessagePackBackingStore(path).push( + self._writer_record("a", "tests.registered", "tests.x", self._writer_entry("tests.x", "tests.registered", "h2", ("tests.x",))) + ) + self.assertEqual([(entry[0], entry[1]) for entry in msgpack.unpackb(path.read_bytes(), raw=False)[1]], [("tests.x", "tests.registered")]) + self.assertEqual(len(SingleFileMessagePackBackingStore(path).pull_all()), 2) + + def test_a_record_no_chosen_entry_publishes_refuses_the_write_typed_and_leaves_the_file(self) -> None: + import msgpack # type: ignore + from cultcache_py import SchemaConflictError + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + path.write_bytes(msgpack.packb([ + "cultcache.store.v1", [self._raw_entry(self._writer_entry("tests.x", "tests.legacy", "stale", ("tests.x", "old")))], + [["z", "old", "2026-09-30T00:00:00Z", msgpack.packb({"z": 1}, use_bin_type=True)]], + ], use_bin_type=True)) + before = path.read_bytes() + with self.assertRaises(SchemaConflictError) as refused: + SingleFileMessagePackBackingStore(path).push( + self._writer_record("a", "tests.legacy", "tests.x", self._writer_entry("tests.x", "tests.legacy", "fresh", ("tests.x",))) + ) + self.assertEqual(refused.exception.schema_id, "old") + self.assertEqual(refused.exception.record_key, "z") + self.assertEqual(path.read_bytes(), before) + + def test_an_id_an_entry_owns_names_that_entry_not_one_that_lists_it_as_compatible(self) -> None: + for vector in ("own-id-over-compatible-v3.bin", "compatible-before-owner-v3.bin"): + store = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "readability" / vector) + self.assertEqual([envelope.type for envelope in store.pull_all()], ["vectors.item", "vectors.item"], vector) + + def test_a_record_loaded_under_a_compatible_schema_id_is_written_back_under_the_id_its_catalog_entry_carries(self) -> None: + import msgpack # type: ignore + + document = self._foreign_id_document() + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + path.write_bytes(msgpack.packb([ + "cultcache.store.v1", + [["tests.foreign-id.current", "tests.foreign-id", "tests.foreign_id.v1", "tests.foreign-id.current", "", + ["tests.foreign-id.current", "tests.foreign-id.older"], []]], + [["old", "tests.foreign-id.older", "2026-09-30T00:00:00Z", document.encode_payload({"name": "old"})]], + ], use_bin_type=True)) + cache = self._open_foreign_id_store(document, path) + self.assertEqual(cache.get_required_envelope(document, "old").schema_id, "tests.foreign-id.current") + cache.put(document, "next", {"name": "next"}) + reopened = self._open_foreign_id_store(document, path) + self.assertEqual(reopened.get_required(document, "old")["name"], "old") + self.assertEqual(reopened.get_required(document, "next")["name"], "next") + + # The cache restamps a record on load, so a store's writer never has to: a record loaded under an id the registered document + # only lists is held, and written back by a whole write, under the id its catalog entry carries. A document that does not list + # the id a record sits under cannot have that record published by a write that leaves it in place: the put is refused, typed. + def test_a_cache_that_does_not_list_the_id_a_loaded_record_sits_under_is_refused_a_put_and_the_file_is_left(self) -> None: + import msgpack # type: ignore + from cultcache_py import SchemaConflictError + + document = define_database_entry_type( + "tests.foreign-id", [("name", 0)], schema_id="tests.foreign-id.current", + schema_name="tests.foreign-id", schema_version="tests.foreign_id.v1", + ) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + path.write_bytes(msgpack.packb([ + "cultcache.store.v1", + [["tests.foreign-id.current", "tests.foreign-id", "tests.foreign_id.v1", "tests.foreign-id.current", "", + ["tests.foreign-id.current", "tests.foreign-id.older"], []]], + [["old", "tests.foreign-id.older", "2026-09-30T00:00:00Z", document.encode_payload({"name": "old"})]], + ], use_bin_type=True)) + before = path.read_bytes() + cache = self._open_foreign_id_store(document, path) + self.assertEqual(cache.get_required_envelope(document, "old").schema_id, "tests.foreign-id.current") + with self.assertRaises(SchemaConflictError) as refused: + cache.put(document, "next", {"name": "next"}) + self.assertEqual(refused.exception.schema_id, "tests.foreign-id.older") + self.assertEqual(refused.exception.record_key, "old") + self.assertEqual(path.read_bytes(), before) + + # A record resolves to a local document by its schema id, never by the name its catalog entry carries: a schema renamed under + # a stable id opens, and an id that names one document is not overruled by a name that names another. + def _store_of(self, path: Path, catalog: list, records: list) -> None: + import msgpack # type: ignore + + path.write_bytes(msgpack.packb(["cultcache.store.v1", catalog, records], use_bin_type=True)) + + def test_a_schema_renamed_under_a_stable_id_opens_by_its_id(self) -> None: + document = define_database_entry_type( + "tests.old", [("name", 0)], schema_id="tests.stable", schema_name="tests.old", schema_version="tests.old.v1", + ) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + self._store_of( + path, + [["tests.stable", "tests.renamed", "tests.renamed.v1", "renamed", "", ["tests.stable"], []]], + [["a", "tests.stable", "2026-09-30T00:00:00Z", document.encode_payload({"name": "a"})]], + ) + cache = self._open_foreign_id_store(document, path) + self.assertEqual(cache.get_required(document, "a")["name"], "a") + + def test_a_schema_id_that_names_one_document_is_not_overruled_by_a_name_that_names_another(self) -> None: + first = define_database_entry_type("tests.a", [("name", 0)], schema_id="id.a", schema_name="tests.a", schema_version="tests.a.v1") + second = define_database_entry_type( + "tests.b", [("name", 0)], schema_id="id.b", schema_name="tests.b", schema_version="tests.b.v1", compatible_schema_ids=["id.b", "id.b.older"], + ) + with tempfile.TemporaryDirectory() as tmp: + for schema_id in ("id.b", "id.b.older"): + path = Path(tmp) / (schema_id + ".msgpack") + # The entry that publishes the id carries the other document's name. + self._store_of( + path, + [["id.b", "tests.a", "tests.b.v1", "h", "", ["id.b", "id.b.older"], []]], + [["k", schema_id, "2026-09-30T00:00:00Z", second.encode_payload({"name": "k"})]], + ) + cache = CultCache.builder().register_document_type(first).register_document_type(second).add_generic_store( + SingleFileMessagePackBackingStore(path) + ).build() + cache.pull_all_backing_stores() + self.assertEqual(cache.get_required(second, "k")["name"], "k", schema_id) + self.assertIsNone(cache.get(first, "k"), schema_id) + + def _assert_second_claim_refused(self, first, second, schema_id: str) -> None: + """A registration refusal is typed, names the id and both schema names and document types, and the cache keeps what it + had.""" + from cultcache_py import SchemaConflictError + + cache = CultCache() + cache.register_document_type(first) + with self.assertRaises(SchemaConflictError) as refused: + cache.register_document_type(second) + self.assertEqual(refused.exception.schema_id, schema_id) + self.assertEqual(refused.exception.schema_names, [first.catalog_entry().schema_name, second.catalog_entry().schema_name]) + self.assertIn(first.type, str(refused.exception)) + self.assertIn(second.type, str(refused.exception)) + if second is not first: + with self.assertRaisesRegex(CultCacheError, "not registered"): + cache.put(second, "k", {"name": "k"}) + + def _pull(self, documents: list, catalog: list, records: list) -> CultCache: + tmp = tempfile.mkdtemp() + path = Path(tmp) / "store.msgpack" + self._store_of(path, catalog, records) + builder = CultCache.builder() + for document in documents: + builder = builder.register_document_type(document) + cache = builder.add_generic_store(SingleFileMessagePackBackingStore(path)).build() + cache.pull_all_backing_stores() + return cache + + def test_a_document_listing_an_id_another_owns_registers_beside_it_in_either_order_and_the_owner_wins(self) -> None: + owner = define_database_entry_type("tests.owner", [("name", 0)], schema_id="id.owned", schema_name="tests.owner", schema_version="tests.owner.v1") + lister = define_database_entry_type( + "tests.lister", [("name", 0)], schema_id="id.lister", schema_name="tests.lister", schema_version="tests.lister.v1", + compatible_schema_ids=["id.lister", "id.owned"], + ) + catalog = [ + ["id.owned", "tests.owner", "tests.owner.v1", "h1", "", ["id.owned"], []], + ["id.lister", "tests.lister", "tests.lister.v1", "h2", "", ["id.lister", "id.owned"], []], + ] + records = [ + ["o", "id.owned", "2026-09-30T00:00:00Z", owner.encode_payload({"name": "o"})], + ["l", "id.lister", "2026-09-30T00:00:00Z", lister.encode_payload({"name": "l"})], + ] + for order in ([owner, lister], [lister, owner]): + cache = self._pull(order, catalog, records) + self.assertEqual(cache.get_required(owner, "o")["name"], "o") + self.assertIsNone(cache.get(lister, "o")) + self.assertEqual(cache.get_required(lister, "l")["name"], "l") + self.assertEqual(self._pull([lister], catalog, records[:1]).get_required(lister, "o")["name"], "o") + + def test_two_documents_listing_one_id_register_and_a_record_under_it_needs_an_owner(self) -> None: + from cultcache_py import SchemaConflictError + + a = define_database_entry_type( + "tests.list-a", [("name", 0)], schema_id="id.a", schema_name="tests.list-a", compatible_schema_ids=["id.a", "id.shared"], + ) + b = define_database_entry_type( + "tests.list-b", [("name", 0)], schema_id="id.b", schema_name="tests.list-b", compatible_schema_ids=["id.b", "id.shared"], + ) + owner = define_database_entry_type("tests.shared", [("name", 0)], schema_id="id.shared", schema_name="tests.shared") + catalog = [["id.shared", "tests.shared", "tests.shared.v1", "h", "", ["id.shared"], []]] + records = [["k", "id.shared", "2026-09-30T00:00:00Z", owner.encode_payload({"name": "k"})]] + for listers in ([a, b], [b, a]): + with self.assertRaises(SchemaConflictError) as refused: + self._pull(listers, catalog, records) + self.assertEqual(refused.exception.schema_id, "id.shared") + self.assertEqual(refused.exception.record_key, "k") + self.assertEqual(refused.exception.schema_names, ["tests.list-a", "tests.list-b"]) + for documents in ([*listers, owner], [owner, *listers]): + cache = self._pull(documents, catalog, records) + self.assertEqual(cache.get_required(owner, "k")["name"], "k") + + def test_two_documents_owning_one_schema_id_are_refused_in_either_order(self) -> None: + # Each declares an older id and not its own, so the id they share is only ever their own. + a = define_database_entry_type("tests.own-a", [("name", 0)], schema_id="id.same", schema_name="tests.own-a", compatible_schema_ids=["id.a-older"]) + b = define_database_entry_type("tests.own-b", [("name", 0)], schema_id="id.same", schema_name="tests.own-b", compatible_schema_ids=["id.b-older"]) + self._assert_second_claim_refused(a, b, "id.same") + self._assert_second_claim_refused(b, a, "id.same") + + def test_every_registration_refusal_is_a_schema_conflict(self) -> None: + first = define_database_entry_type("tests.named", [("name", 0)], schema_id="id.first", schema_name="tests.named") + same_name = define_database_entry_type("tests.renamed", [("name", 0)], schema_id="id.second", schema_name="tests.named") + self._assert_second_claim_refused(first, same_name, "id.second") + self._assert_second_claim_refused(first, first, "id.first") def test_interop_cli_helpers_round_trip_v1_store(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -893,6 +1373,32 @@ class CultCacheTests(unittest.TestCase): self.assertNotIn(repr(value[0]), message) self.assertIn(f"of {len(value[0])} bytes", message) + def test_single_file_refusals_say_exactly_what_was_found_and_what_is_read(self) -> None: + import msgpack # type: ignore + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.cc" + reads = "it reads 'cultcache.store.v1' and 'cultcache.store.v3' only" + for header, found in ( + ("cultcache.store.v12", "'cultcache.store.v12'"), + ("cultcache.store.SECRET", "an unrecognised cultcache.store.* header of 22 bytes"), + ("cultcache.store.v", "an unrecognised cultcache.store.* header of 17 bytes"), + # Digits are ASCII only: two Arabic-Indic digits are four bytes and are not the known shape. + ("cultcache.store.v١٢", "an unrecognised cultcache.store.* header of 21 bytes"), + ): + path.write_bytes(msgpack.packb([header, [], []], use_bin_type=True)) + with self.assertRaises(ValueError) as caught: + SingleFileMessagePackBackingStore(path).pull_all() + self.assertIn(f"CultCache store format {found} is not one this runtime reads; {reads}", str(caught.exception)) + + path.write_bytes(msgpack.packb(["cultcache.store.v1", [], [["k", "s", "t", b"\x90", "extra"]]], use_bin_type=True)) + with self.assertRaises(ValueError) as caught: + SingleFileMessagePackBackingStore(path).pull_all() + self.assertIn( + "CultCache record 'k' (schema 's') has 5 slots, more than the 4 of a cultcache.store.v1 record, so this is not a valid store", + str(caught.exception), + ) + def test_single_file_record_refusal_names_only_a_string_key(self) -> None: import msgpack # type: ignore @@ -966,5 +1472,120 @@ class CultCacheTests(unittest.TestCase): self.assertNotIsInstance(caught.exception, FileNotFoundError) + # The element-id marker: tests/vectors/document-variants-c2a/v3-base.msgpack is v1-base with its header replaced. + _C2A_VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c2a" + + def _header_after_rewrite(self, vector: Path) -> str: + import msgpack + + with tempfile.TemporaryDirectory() as tmp: + store_path = Path(tmp) / "store.msgpack" + store_path.write_bytes(vector.read_bytes()) + store = SingleFileMessagePackBackingStore(store_path) + envelopes = store.pull_all() + self.assertEqual([e.key for e in envelopes], ["alpha", "beta"]) + store.push(envelopes[0]) + return msgpack.unpackb(store_path.read_bytes(), raw=False)[0] + + def test_single_file_reads_a_v3_element_id_store_and_a_rewrite_keeps_the_marker(self) -> None: + self.assertEqual(self._header_after_rewrite(self._C2A_VECTORS / "v3-base.msgpack"), "cultcache.store.v3") + + def test_single_file_rewrite_of_a_v1_store_stays_v1(self) -> None: + self.assertEqual(self._header_after_rewrite(self._VECTORS / "v1-base.msgpack"), "cultcache.store.v1") + + def test_single_file_flush_after_the_marked_file_is_gone_writes_unmarked(self) -> None: + import msgpack + + with tempfile.TemporaryDirectory() as tmp: + store_path = Path(tmp) / "store.msgpack" + store_path.write_bytes((self._C2A_VECTORS / "v3-base.msgpack").read_bytes()) + store = SingleFileMessagePackBackingStore(store_path) + envelopes = store.pull_all() + store_path.unlink() + store.push_all(envelopes) + self.assertEqual(msgpack.unpackb(store_path.read_bytes(), raw=False)[0], "cultcache.store.v1") + + def test_single_file_push_after_the_marked_file_is_gone_writes_unmarked(self) -> None: + import msgpack + + with tempfile.TemporaryDirectory() as tmp: + store_path = Path(tmp) / "store.msgpack" + store_path.write_bytes((self._C2A_VECTORS / "v3-base.msgpack").read_bytes()) + store = SingleFileMessagePackBackingStore(store_path) + envelopes = store.pull_all() + store_path.unlink() + store.push(envelopes[0]) + self.assertEqual(msgpack.unpackb(store_path.read_bytes(), raw=False)[0], "cultcache.store.v1") + + def test_single_file_push_all_refuses_a_store_whose_header_it_cannot_read_and_leaves_it_untouched(self) -> None: + envelopes = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "v3-base.msgpack").pull_all() + vectors = self._VECTORS.parent + for vector in ( + self._VECTORS / "unknown-header.msgpack", + self._VECTORS / "variant-v2.msgpack", + vectors / "document-variants-c1" / "variant-store.msgpack", + ): + with tempfile.TemporaryDirectory() as tmp: + store_path = Path(tmp) / "store.msgpack" + store_path.write_bytes(vector.read_bytes()) + before = store_path.read_bytes() + with self.assertRaisesRegex(StoreUnreadableError, "is not readable"): + SingleFileMessagePackBackingStore(store_path).push_all(envelopes) + self.assertEqual(store_path.read_bytes(), before, f"{vector.name} was rewritten") + + # A file is replaced by a rewrite exactly when this runtime's own reader opens it: one verdict per file, asked by + # pull_all, push_all, push and delete alike. The bytes and every runtime's verdict: + # tests/vectors/document-variants-c2a/readability. + def test_single_file_replaces_a_file_exactly_when_it_reads(self) -> None: + import msgpack # type: ignore + + root = self._C2A_VECTORS / "readability" + rows = [ + line.split() + for line in (root / "manifest.txt").read_text(encoding="utf-8").splitlines() + if line and not line.startswith("#") + ] + # Every vector in the folder has a manifest row. + self.assertEqual( + sorted(path.name for path in root.glob("*.bin")), + sorted(vector for vector, *_ in rows if not vector.startswith("..")), + ) + envelopes = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "v3-base.msgpack").pull_all() + for vector, _, _, _, python in rows: + content = (root / vector).read_bytes() + for operation in ("pull_all", "push_all", "push", "delete"): + with tempfile.TemporaryDirectory() as tmp: + store_path = Path(tmp) / "store.msgpack" + store_path.write_bytes(content) + store = SingleFileMessagePackBackingStore(store_path) + run = { + "pull_all": store.pull_all, + "push_all": lambda: store.push_all(envelopes), + "push": lambda: store.push(envelopes[0]), + "delete": lambda: store.delete(envelopes[0].type, envelopes[0].key), + }[operation] + if python == "reads": + run() + if operation == "pull_all": + continue + # The replaced file is a store: it carries the header its old content decides (a v3 store keeps its + # marker, all else is v1), and it holds what the operation wrote. + expected = "cultcache.store.v3" if "v3" in vector else "cultcache.store.v1" + self.assertEqual(msgpack.unpackb(store_path.read_bytes(), raw=False)[0], expected, f"{vector} {operation}") + keys = [envelope.key for envelope in SingleFileMessagePackBackingStore(store_path).pull_all()] + if operation == "push_all": + self.assertEqual(sorted(set(keys) & {e.key for e in envelopes}), sorted(e.key for e in envelopes), f"{vector} {operation}") + elif operation == "push": + self.assertIn(envelopes[0].key, keys, f"{vector} {operation}") + else: + self.assertNotIn(envelopes[0].key, keys, f"{vector} {operation}") + else: + with self.assertRaises(StoreUnreadableError, msg=f"{vector} {operation}") as refused: + run() + self.assertIsNotNone(refused.exception.__cause__, f"{vector} {operation}") + self.assertEqual(store_path.read_bytes(), content, f"{vector} {operation} rewrote a file it cannot read") + + + if __name__ == "__main__": unittest.main() diff --git a/packages/cultcache-py/tests/test_first_call.py b/packages/cultcache-py/tests/test_first_call.py new file mode 100644 index 00000000..f8d4b52b --- /dev/null +++ b/packages/cultcache-py/tests/test_first_call.py @@ -0,0 +1,67 @@ +"""The Python CultCache first call from docs/start/README.md. The docs show the region between the +`snippet` markers verbatim, and test_docs_show_the_tested_snippet fails when they differ.""" + +import unittest +from pathlib import Path + +# snippet:begin +import tempfile +from dataclasses import asdict, dataclass +from pathlib import Path + +from cultcache_py import CultCache, SingleFileMessagePackBackingStore, define_document_type + + +@dataclass +class Item: + name: str + category: str + value: int + + +item_doc = define_document_type( + "item", + encode=lambda item: asdict(item), + decode=lambda raw: Item(**raw), + name="name", + indexes={"category": "category"}, +) + + +def first_call() -> Item: + path = Path(tempfile.mkdtemp()) / "first-call.cc" + + cache = ( + CultCache.builder() + .register_document_type(item_doc) + .add_generic_store(SingleFileMessagePackBackingStore(path)) + .build() + ) + cache.pull_all_backing_stores() + cache.put(item_doc, "item:potion", Item("Potion", "Consumable", 50)) # written to the .cc file + + reopened = ( + CultCache.builder() + .register_document_type(item_doc) + .add_generic_store(SingleFileMessagePackBackingStore(path)) + .build() + ) + reopened.pull_all_backing_stores() # read it back + assert reopened.get_by_name(item_doc, "Potion") == reopened.get_required(item_doc, "item:potion") + return reopened.get_required(item_doc, "item:potion") +# snippet:end + + +class FirstCallTests(unittest.TestCase): + def test_first_call_round_trips_through_a_second_cache(self) -> None: + self.assertEqual(first_call(), Item("Potion", "Consumable", 50)) + + def test_docs_show_the_tested_snippet(self) -> None: + source = Path(__file__).read_text(encoding="utf-8") + snippet = source.split("# snippet:begin\n", 1)[1].split("# snippet:end\n", 1)[0] + docs = (Path(__file__).parents[3] / "docs" / "start" / "README.md").read_text(encoding="utf-8") + self.assertIn("```python\n" + snippet + "```", docs) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultcache-py/tests/test_slot_nil.py b/packages/cultcache-py/tests/test_slot_nil.py new file mode 100644 index 00000000..dca207bb --- /dev/null +++ b/packages/cultcache-py/tests/test_slot_nil.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +import msgpack + +from cultcache_py import database_entry_field, define_database_entry_type, define_record_codec, leaves as t + +# tests/vectors/slot-nil is written only by the C# reference (SlotNilVectorTests): what it reads and rewrites for a nil, a +# str in a byte[] slot, and a record short of its last slots. This runtime accepts what the reference accepts and rewrites +# the same bytes. +VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "slot-nil" + + +def _probe(): + inner = define_record_codec("SlotNilInner", [database_entry_field("x", 0, default="", codec=t.string)]) + return define_database_entry_type( + "tests.slot-nil", + [ + database_entry_field("s", 0, default="", codec=t.nullable(t.string)), + database_entry_field("i", 1, default=0, codec=t.int32), + database_entry_field("b", 2, default=b"", codec=t.nullable(t.bytes)), + database_entry_field("r", 3, default=None, codec=t.nullable(inner)), + database_entry_field("l", 4, default=[], codec=t.nullable(t.list(t.string))), + ], + ) + + +class SlotNilVectorTests(unittest.TestCase): + def test_slots_read_and_rewrite_exactly_what_the_reference_does_for_nil_str_in_bytes_and_short_records(self) -> None: + rows = msgpack.unpackb((VECTORS / "reads.msgpack").read_bytes(), raw=False) + probe = _probe() + self.assertEqual(17, len(rows)) + accepted = 0 + for name, payload, was_accepted, rewritten in rows: + with self.subTest(name): + if was_accepted: + self.assertEqual(rewritten.hex(), probe.encode_payload(probe.decode_payload(payload)).hex()) + accepted += 1 + else: + with self.assertRaises(Exception): + probe.decode_payload(payload) + self.assertEqual(15, accepted) + + def test_a_character_outside_the_bmp_is_four_utf8_bytes(self) -> None: + rows = {name: (payload, rewritten) for name, payload, _, rewritten in msgpack.unpackb((VECTORS / "reads.msgpack").read_bytes(), raw=False)} + probe = _probe() + self.assertEqual("\U0001F600", probe.decode_payload(rows["string-astral"][0])["s"]) + self.assertEqual(b"\xf0\x9f\x98\x80", probe.decode_payload(rows["bytes-str-astral"][0])["b"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultcache-py/tests/test_store_lock.py b/packages/cultcache-py/tests/test_store_lock.py new file mode 100644 index 00000000..5ee29aa2 --- /dev/null +++ b/packages/cultcache-py/tests/test_store_lock.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path +from unittest import mock + +from cultcache_py import SingleFileMessagePackBackingStore, stores +from cultcache_py.backing_store import CultCacheEnvelope + +REPO = Path(__file__).resolve().parents[3] +TS_STORE_FILE = REPO / "packages" / "cultcache-ts" / "dist" / "store-file.js" +HOLD_SECONDS = 1.5 + +# A holder that takes the lock the way the Rust runtime does (an exclusive file lock), not through this package. +PY_HOLDER = """ +import fcntl, os, sys, time +fd = os.open(sys.argv[1] + '.lock', os.O_RDWR | os.O_CREAT, 0o666) +fcntl.flock(fd, fcntl.LOCK_EX) +print('HELD', flush=True) +time.sleep(float(sys.argv[2])) +""" +# A holder that is the TypeScript runtime's own withStoreWriteLock, built from packages/cultcache-ts. +TS_HOLDER = """ +const { withStoreWriteLock } = require(process.argv[1]); +withStoreWriteLock(process.argv[2], async () => { + console.log('HELD'); + await new Promise((r) => setTimeout(r, Number(process.argv[3]) * 1000)); +}); +""" + + +def envelope(key: str) -> CultCacheEnvelope: + return CultCacheEnvelope(key=key, type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z") + + +def start_holder(argv: list[str]) -> subprocess.Popen[str]: + holder = subprocess.Popen(argv, stdout=subprocess.PIPE, text=True) + assert holder.stdout is not None and holder.stdout.readline().strip() == "HELD" + holder.held_at = time.monotonic() # type: ignore[attr-defined] + return holder + + +@unittest.skipIf(sys.platform == "win32", "the foreign holders here use flock; Windows is covered by the TS holder") +class StoreLockTests(unittest.TestCase): + def setUp(self) -> None: + self.dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) + self.path = self.dir / "state.cc" + + def assert_write_waits_for(self, holder: subprocess.Popen[str]) -> None: + self.addCleanup(holder.wait) + self.addCleanup(holder.stdout.close) # type: ignore[union-attr] + refused = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2) + with self.assertRaises(TimeoutError): + refused.push(envelope("blocked")) + self.assertFalse(self.path.exists(), "a write that could not take the lock must not touch the store") + + SingleFileMessagePackBackingStore(self.path, lock_timeout=10).push(envelope("after")) + self.assertGreaterEqual(time.monotonic() - holder.held_at, HOLD_SECONDS - 0.1, "the write finished while the holder still held the lock") # type: ignore[attr-defined] + self.assertEqual(["after"], [e.key for e in SingleFileMessagePackBackingStore(self.path).pull_all()]) + + def test_write_waits_while_a_foreign_process_holds_the_lock(self) -> None: + self.assert_write_waits_for(start_holder([sys.executable, "-c", PY_HOLDER, str(self.path), str(HOLD_SECONDS)])) + + @unittest.skipUnless(shutil.which("node") and TS_STORE_FILE.exists(), "needs node and a built packages/cultcache-ts (npm run build)") + def test_write_waits_while_the_typescript_runtime_holds_the_lock(self) -> None: + holder = start_holder(["node", "-e", TS_HOLDER, str(TS_STORE_FILE), str(self.path), str(HOLD_SECONDS)]) + self.assert_write_waits_for(holder) + + def test_the_lock_is_released_after_each_write(self) -> None: + store = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2) + for key in "abc": + store.push(envelope(key)) + self.assertEqual(["a", "b", "c"], sorted(e.key for e in store.pull_all())) + + def test_concurrent_processes_lose_no_writes(self) -> None: + script = ( + "import sys\n" + "from cultcache_py import SingleFileMessagePackBackingStore as S\n" + "from cultcache_py.backing_store import CultCacheEnvelope as E\n" + "s = S(sys.argv[1])\n" + "for i in range(25):\n" + " s.push(E(key=f'{sys.argv[2]}{i}', type='t', payload=b'\\x01', stored_at='x'))\n" + ) + env = {**os.environ, "PYTHONPATH": str(Path(stores.__file__).parents[1])} + procs = [subprocess.Popen([sys.executable, "-c", script, str(self.path), tag], env=env) for tag in "pqr"] + for proc in procs: + self.assertEqual(0, proc.wait()) + self.assertEqual(75, len(SingleFileMessagePackBackingStore(self.path).pull_all())) + + +class StagedWriteTests(unittest.TestCase): + def setUp(self) -> None: + self.dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) + self.path = self.dir / "state.cc" + + def test_the_staged_file_is_fsynced_before_the_replace(self) -> None: + order: list[str] = [] + real_fsync, real_replace = os.fsync, os.replace + with mock.patch("os.fsync", side_effect=lambda fd: (order.append("fsync"), real_fsync(fd))[1]), mock.patch( + "os.replace", side_effect=lambda a, b: (order.append("replace"), real_replace(a, b))[1] + ): + SingleFileMessagePackBackingStore(self.path).push(envelope("a")) + self.assertEqual("fsync", order[0]) + self.assertLess(order.index("fsync"), order.index("replace")) + + def test_the_staged_name_is_unique_per_write(self) -> None: + names: list[str] = [] + real_replace = os.replace + with mock.patch("os.replace", side_effect=lambda a, b: (names.append(os.fspath(a)), real_replace(a, b))[1]): + store = SingleFileMessagePackBackingStore(self.path) + store.push(envelope("a")) + store.push(envelope("b")) + self.assertEqual(2, len(set(names))) + self.assertNotIn(os.fspath(self.path) + ".tmp", names) + + def test_a_failed_replace_leaves_the_store_and_no_staged_file(self) -> None: + store = SingleFileMessagePackBackingStore(self.path) + store.push(envelope("a")) + before = self.path.read_bytes() + with mock.patch("os.replace", side_effect=OSError("boom")), self.assertRaises(OSError): + store.push(envelope("b")) + self.assertEqual(before, self.path.read_bytes()) + self.assertEqual([], [p.name for p in self.dir.iterdir() if ".tmp" in p.name]) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultcache-py/tests/test_typed_leaves.py b/packages/cultcache-py/tests/test_typed_leaves.py index 9b432efd..b284bcbb 100644 --- a/packages/cultcache-py/tests/test_typed_leaves.py +++ b/packages/cultcache-py/tests/test_typed_leaves.py @@ -133,6 +133,7 @@ def _inner_record() -> t.SlotCodec: [ database_entry_field("n", 0, default="", codec=t.string), database_entry_field("w", 1, default=0.0, codec=t.float32), + database_entry_field("id", 2, default="", codec=t.string), ], type_name=REFERENCE + "LeafInner", ) @@ -662,7 +663,7 @@ def _python_authored_leaf_record() -> dict: return { "id": "py", "flag": False, "blob": bytearray([1, 255]), "i": -(2**31), "l": 2**63 - 1, "f": 0.1, "d": 1e-300, "maybe_i": None, "tags": [], "names": ("\u00e9", "z"), "any": {"k": [1, True, None, 2.5, "s"], "n": -3}, - "nested": None, "items": [{"n": "one", "w": 2**53 + 2**29 + 1}, {"n": "two", "w": 1e-40}], + "nested": None, "items": [{"n": "one", "w": 2**53 + 2**29 + 1, "id": "000000000021"}, {"n": "two", "w": 1e-40, "id": "000000000022"}], "shape": UnionArm("LeafCircle", {"r": 0.1}), "floats": [0.1, 3, -0.0, 1e39], "maybe_f": None, } diff --git a/packages/cultcache-py/tests/test_union_parity.py b/packages/cultcache-py/tests/test_union_parity.py index e6fe6c2f..451cd030 100644 --- a/packages/cultcache-py/tests/test_union_parity.py +++ b/packages/cultcache-py/tests/test_union_parity.py @@ -68,22 +68,51 @@ image = define_record_codec( ], type_name=_GOLDEN + "Image", ) -wrap = define_record_codec("Wrap", [database_entry_field("inner", 0, codec=_node)], type_name=_GOLDEN + "Wrap") +wrap = define_record_codec( + "Wrap", + [database_entry_field("inner", 0, codec=_node), database_entry_field("id", 1, codec=t.string)], + type_name=_GOLDEN + "Wrap", +) node = define_union( "Node", {0: ("Box", box), 1: ("Text", text), 2: ("Use", use), 5: ("Image", image), 6: ("Wrap", wrap)}, type_name=_GOLDEN + "Node", ) +# The golden's Outcome: the arms of a list carry an element id, so Denied, Floor and Ceiling are one-slot records. +_id_only = lambda name: define_record_codec(name, [database_entry_field("id", 0, codec=t.string)]) # noqa: E731 outcome = define_union( + "Outcome", + { + 0: ( + "Pending", + define_record_codec( + "Pending", + [database_entry_field("message", 0, codec=t.string), database_entry_field("id", 1, codec=t.string)], + ), + ), + 1: ( + "Completed", + define_record_codec( + "Completed", + [database_entry_field("source_version", 0, codec=t.int32), database_entry_field("id", 1, codec=t.string)], + ), + ), + 2: ("Denied", _id_only("Denied")), + -2147483648: ("Floor", _id_only("Floor")), + 2147483647: ("Ceiling", _id_only("Ceiling")), + }, + type_name=_GOLDEN + "Outcome", +) +# The codec's unit arms (no value, written [key, []]), kept apart from the golden: a document list's arms carry ids. +unit_outcome = define_union( "Outcome", { 0: ("Pending", define_record_codec("Pending", [database_entry_field("message", 0, codec=t.string)])), - 1: ("Completed", define_record_codec("Completed", [database_entry_field("source_version", 0, codec=t.int32)])), 2: ("Denied", None), -2147483648: ("Floor", None), 2147483647: ("Ceiling", None), }, - type_name=_GOLDEN + "Outcome", + type_name=_GOLDEN + "UnitOutcome", ) tool = define_record_codec( "Tool", @@ -121,41 +150,41 @@ def _surface_value() -> dict: "root": UnionArm( "Box", { - "id": "root", + "id": "0000000000c0", "children": [ - UnionArm("Text", {"id": "t1", "content": "hello"}), + UnionArm("Text", {"id": "0000000000c1", "content": "hello"}), UnionArm( "Use", { - "id": "u1", + "id": "0000000000c2", "composition": { "name": "comp", "body": UnionArm( "Box", { - "id": "inner", + "id": "0000000000d0", "children": [ - UnionArm("Text", {"id": "deep", "content": "x"}), - UnionArm("Image", {"id": "img", "uri": "uri://a", "alt": "alt"}), + UnionArm("Text", {"id": "0000000000d1", "content": "x"}), + UnionArm("Image", {"id": "0000000000d2", "uri": "uri://a", "alt": "alt"}), ], }, ), }, "slots": [ - UnionArm("Text", {"id": "s1", "content": "slot"}), + UnionArm("Text", {"id": "0000000000e1", "content": "slot"}), None, - UnionArm("Box", {"id": "b2", "children": []}), + UnionArm("Box", {"id": "0000000000e2", "children": []}), ], }, ), - UnionArm("Image", {"id": "i2", "uri": "uri://b", "alt": ""}), + UnionArm("Image", {"id": "0000000000c3", "uri": "uri://b", "alt": ""}), ], }, ), "outcomes": [ - UnionArm("Pending", {"message": "wait"}), - UnionArm("Completed", {"source_version": 7}), - UnionArm("Denied"), + UnionArm("Pending", {"message": "wait", "id": "0000000000a1"}), + UnionArm("Completed", {"source_version": 7, "id": "0000000000a2"}), + UnionArm("Denied", {"id": "0000000000a3"}), ], "part": UnionArm("Tool", {"name": "hammer", "uses": 12}), "crafted": UnionArm("Part", {"name": "gear", "count": 4}), @@ -256,12 +285,14 @@ class UnionCodecRuleTests(unittest.TestCase): ) def test_unit_arm_is_written_with_empty_slots_and_reads_back(self) -> None: - self.assertEqual(outcome.encode(UnionArm("Denied")), [2, []]) - self.assertEqual(outcome.decode([2, []]), UnionArm("Denied", None)) + # The bytes are the C# reference's (tests/vectors/union-parity/unit-arm-denied.msgpack), not spelled here. + reference = _vector("unit-arm-denied.msgpack") + self.assertEqual(msgpack.packb(unit_outcome.encode(UnionArm("Denied"))), reference) + self.assertEqual(unit_outcome.decode(msgpack.unpackb(reference, raw=False)), UnionArm("Denied", None)) def test_unit_arm_refuses_a_value_to_carry(self) -> None: with self.assertRaises(ValueError) as caught: - outcome.encode(UnionArm("Denied", {"message": self.CANARY})) + unit_outcome.encode(UnionArm("Denied", {"message": self.CANARY})) self.assertEqual(str(caught.exception), "Union 'Outcome' arm 2 is a unit arm and carries no value") def test_arm_keys_are_written_as_declared(self) -> None: @@ -333,7 +364,7 @@ class UnionCodecRuleTests(unittest.TestCase): self.assertEqual(row.decode(encoded), {"id": "r", "cells": cells}) def test_a_unit_arm_with_extra_slots_is_read_past(self) -> None: - self.assertEqual(outcome.decode([2, ["extra"]]), UnionArm("Denied")) + self.assertEqual(unit_outcome.decode([2, ["extra"]]), UnionArm("Denied")) def test_union_arms_are_immutable_hashable_values(self) -> None: arm = UnionArm("Denied") @@ -478,7 +509,7 @@ CANARY = "CANARY-union-golden-4c2a" def _wrap_chain(wraps: int, leaf: UnionArm) -> UnionArm: value = leaf for _ in range(wraps): - value = UnionArm("Wrap", {"inner": value}) + value = UnionArm("Wrap", {"inner": value, "id": "0000000000b1"}) return value @@ -521,9 +552,9 @@ class BoundsVectorTests(unittest.TestCase): self.assertEqual(counts, {"accepted": 4, "refused": 7, "read-only": 1}) def test_the_value_at_the_limit_writes_the_vector_and_the_value_past_it_is_refused_on_write(self) -> None: - at_limit = {**_surface_value(), "root": _wrap_chain(126, UnionArm("Box", {"id": "leaf", "children": []}))} + at_limit = {**_surface_value(), "root": _wrap_chain(126, UnionArm("Box", {"id": "0000000000f0", "children": []}))} self.assertEqual(surface_document.encode_payload(at_limit), _vector("depth-at-limit.msgpack")) - past = {**_surface_value(), "root": _wrap_chain(127, UnionArm("Text", {"id": "leaf", "content": CANARY}))} + past = {**_surface_value(), "root": _wrap_chain(127, UnionArm("Text", {"id": "0000000000f0", "content": CANARY}))} with self.assertRaises(CultCachePayloadDepthError) as caught: surface_document.encode_payload(past) message = str(caught.exception) @@ -533,7 +564,7 @@ class BoundsVectorTests(unittest.TestCase): self.assertNotIn(CANARY, message) def test_a_cache_put_of_the_value_past_the_limit_refuses_and_stores_nothing(self) -> None: - past = {**_surface_value(), "root": _wrap_chain(127, UnionArm("Box", {"id": "leaf", "children": []}))} + past = {**_surface_value(), "root": _wrap_chain(127, UnionArm("Box", {"id": "0000000000f0", "children": []}))} with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "store.cc" cache = ( @@ -709,8 +740,12 @@ class UnionDecodeBoundsTests(unittest.TestCase): self.assertEqual(str(caught.exception), expected) def test_the_ends_of_the_int32_key_domain_are_unit_arms_that_read_and_write(self) -> None: - self.assertEqual(outcome.decode([-(2**31), []]), UnionArm("Floor")) - self.assertEqual(outcome.encode(UnionArm("Ceiling")), [2**31 - 1, []]) + floor = _vector("unit-arm-floor.msgpack") + ceiling = _vector("unit-arm-ceiling.msgpack") + self.assertEqual(unit_outcome.decode(msgpack.unpackb(floor, raw=False)), UnionArm("Floor")) + self.assertEqual(msgpack.packb(unit_outcome.encode(UnionArm("Floor"))), floor) + self.assertEqual(unit_outcome.decode(msgpack.unpackb(ceiling, raw=False)), UnionArm("Ceiling")) + self.assertEqual(msgpack.packb(unit_outcome.encode(UnionArm("Ceiling"))), ceiling) if __name__ == "__main__": diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md new file mode 100644 index 00000000..c417d535 --- /dev/null +++ b/packages/cultcache-rs/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to `cultcache-rs` are recorded here, following +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Earlier releases are recorded +only in git history. + +## Unreleased + +### 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 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). + +### 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`). +- `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::()`. + +## [0.3.1] + +### Fixed +- `push_all` reads the store's header under the lock and refuses a format this runtime does + not read, with `CultCacheStoreUnreadable` `UnsupportedFormat`, leaving the file as it was. + A file with no readable stamp is still replaced, so a whole write heals it. diff --git a/packages/cultcache-rs/Cargo.toml b/packages/cultcache-rs/Cargo.toml index 9610aeb6..7954370c 100644 --- a/packages/cultcache-rs/Cargo.toml +++ b/packages/cultcache-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cultcache-rs" -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT" publish = false diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index afae7285..2c5c8027 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -174,6 +174,9 @@ without pretending Rust has runtime reflection hiding under the floorboards. - `get_required_envelope::` - `get_all::` - `get_all_with_keys::` +- `get_by_name::` +- `get_by_index::` +- `get_global::` - `soa::` - `put::` - `put_envelope::` diff --git a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs index 65e41b92..fe4e57c6 100644 --- a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs +++ b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs @@ -22,6 +22,7 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result = None; let mut schema_name: Option = None; + let mut global = false; for attribute in input .attrs @@ -37,6 +38,12 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result syn::Result ::std::option::Option<::std::string::String> { + ::cultcache_rs::CultLookupValue::lookup_value(&self.#member) + } + } + }); + let index_members = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident))); + let index_pushes = index_members.map(|(alias, member)| { + quote! { + if let ::std::option::Option::Some(value) = ::cultcache_rs::CultLookupValue::lookup_value(&self.#member) { + held.push((#alias, value)); + } + } + }); + let index_pushes: Vec<_> = index_pushes.collect(); + let indexes_fn = (!index_pushes.is_empty()).then(|| { + quote! { + fn cult_indexes(&self) -> ::std::vec::Vec<(&'static str, ::std::string::String)> { + let mut held = ::std::vec::Vec::new(); + #(#index_pushes)* + held + } + } + }); Ok(quote! { impl ::cultcache_rs::DatabaseEntry for #ident { const TYPE: &'static str = #entry_type; const SCHEMA_NAME: &'static str = #schema_name; + const GLOBAL: bool = #global; + #name_fn + #indexes_fn } #codec @@ -133,6 +169,8 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result = None; let mut default = false; let mut bytes = false; + let mut name = false; + let mut index: Option = None; for attribute in field .attrs .iter() @@ -157,6 +195,22 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result()?.value() + } else { + String::new() + }; + index = Some(if alias.trim().is_empty() { field_ident.to_string() } else { alias }); + Ok(()) } else { Err(meta.error("unsupported field cultcache attribute")) } @@ -180,8 +234,28 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result, } fn union_codec(input: DeriveInput) -> syn::Result { @@ -842,4 +920,89 @@ mod tests { let input: DeriveInput = syn::parse_str(source).expect("the fixture parses"); named_slots(input.data, &input.ident, "CultSlots").expect("the fixture is accepted"); } + fn entry_error(source: &str) -> String { + let input: DeriveInput = syn::parse_str(source).expect("the fixture parses"); + database_entry_codec(input).expect_err("the fixture is refused").to_string() + } + + fn entry_tokens(source: &str) -> String { + let input: DeriveInput = syn::parse_str(source).expect("the fixture parses"); + database_entry_codec(input).expect("the fixture is accepted").to_string().replace(' ', "") + } + + #[test] + fn name_index_and_global_generate_the_lookup_surface() { + let tokens = entry_tokens( + r#"#[cultcache(type = "t", global)] struct S { + #[cultcache(key = 0, name)] label: String, + #[cultcache(key = 1, index)] code: String, + #[cultcache(key = 2, index = "sku")] stock: Option, + }"#, + ); + assert!(tokens.contains("constGLOBAL:bool=true"), "{tokens}"); + assert!(tokens.contains("fncult_name"), "{tokens}"); + assert!(tokens.contains("(\"code\",value)"), "{tokens}"); + assert!(tokens.contains("(\"sku\",value)"), "{tokens}"); + } + + #[test] + fn a_record_without_lookups_generates_none_of_them() { + 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("fncult_name") && !tokens.contains("fncult_indexes"), "{tokens}"); + } + + // ResolveIndexAlias: a blank alias is the member's name. + #[test] + fn a_blank_index_alias_is_the_member_name() { + let tokens = entry_tokens( + r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = " ")] code: String }"#, + ); + assert!(tokens.contains("(\"code\",value)"), "{tokens}"); + } + + #[test] + fn two_names_are_refused_for_that_reason() { + let message = entry_error( + r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name)] a: String, #[cultcache(key = 1, name)] b: String }"#, + ); + assert!(message.contains("more than one member"), "{message}"); + } + + // D10: two members that declare one alias; a bare index is the member name, so it collides with an explicit alias of it. + #[test] + fn two_members_with_one_index_alias_are_refused_for_that_reason() { + let message = entry_error( + r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = "k")] a: String, #[cultcache(key = 1, index = "k")] b: String }"#, + ); + assert!(message.contains("both declare index alias \"k\""), "{message}"); + let message = entry_error( + r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index)] a: String, #[cultcache(key = 1, index = "a")] b: String }"#, + ); + assert!(message.contains("both declare index alias \"a\""), "{message}"); + } + + #[test] + fn a_repeated_lookup_attribute_is_refused() { + let message = entry_error( + r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name, name)] a: String }"#, + ); + assert!(message.contains("repeated `name`"), "{message}"); + let message = entry_error( + r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index, index = "x")] a: String }"#, + ); + assert!(message.contains("repeated `index`"), "{message}"); + let message = entry_error(r#"#[cultcache(type = "t", global, global)] struct S { #[cultcache(key = 0)] a: String }"#); + assert!(message.contains("repeated `global`"), "{message}"); + } + + // Only a DatabaseEntry is looked up: a nested slot record carrying the attributes is refused as before. + #[test] + fn lookup_attributes_are_refused_on_a_slots_record() { + let message = slots_error(r#"struct S { #[cultcache(key = 0, name)] a: String }"#); + assert!(message.contains("unsupported field cultcache attribute"), "{message}"); + let message = slots_error(r#"struct S { #[cultcache(key = 0, index)] a: String }"#); + assert!(message.contains("unsupported field cultcache attribute"), "{message}"); + } + } diff --git a/packages/cultcache-rs/examples/cultcache_interop.rs b/packages/cultcache-rs/examples/cultcache_interop.rs index c9bac14d..8efb9395 100644 --- a/packages/cultcache-rs/examples/cultcache_interop.rs +++ b/packages/cultcache-rs/examples/cultcache_interop.rs @@ -26,6 +26,38 @@ struct CultCacheInteropNote { tags: Vec, } +// An object list: each mark is a positional array [label, id], the id an ordinary member at slot 1. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +struct CultCacheInteropMark(String, String); + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "cultcache.interop-deck", schema = "CultCacheInteropDeck")] +struct CultCacheInteropDeck { + #[cultcache(key = 0)] + document_id: String, + #[cultcache(key = 1, default)] + marks: Vec, +} + +fn read_deck(file: &str) -> Result<()> { + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?; + cache.pull_all_backing_stores()?; + let decks = cache.get_all::()?; + let deck = decks + .first() + .ok_or_else(|| anyhow!("no cultcache.interop-deck records found"))?; + println!( + "{}", + serde_json::to_string(&json!({ + "documentId": deck.document_id, + "ids": deck.marks.iter().map(|mark| mark.1.clone()).collect::>(), + }))? + ); + Ok(()) +} + fn main() -> Result<()> { let mut args = std::env::args().skip(1); let mode = args @@ -37,6 +69,7 @@ fn main() -> Result<()> { match mode.as_str() { "write" => write_note(file, require_arg(&options, "runtime-id")?)?, "read" => read_note(file)?, + "read-deck" => read_deck(file)?, "hold-lock" => hold_lock(file, require_arg(&options, "ms")?.parse()?)?, _ => return Err(anyhow!("unknown mode {mode}")), } diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index ecb69145..4ceb43f2 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -162,8 +162,68 @@ pub use cultcache_rs_derive::DatabaseEntry; pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static { const TYPE: &'static str; const SCHEMA_NAME: &'static str = "DatabaseEntry"; + /// `#[cultcache(global)]`: the cache holds at most one record of this type, found by `get_global`. + const GLOBAL: bool = false; + /// The `#[cultcache(name)]` member's value, which `get_by_name` finds the record by. `None` is no name. + fn cult_name(&self) -> Option { + None + } + /// Each `#[cultcache(index)]` member as (alias, value), which `get_by_index` finds the record by. + fn cult_indexes(&self) -> Vec<(&'static str, String)> { + Vec::new() + } } +/// What a `#[cultcache(name)]` or `#[cultcache(index)]` member says it is called: its text, or `None` for a member +/// that holds no value (an `Option` that is `None`). The cache treats an empty name and a blank index value as none. +pub trait CultLookupValue { + fn lookup_value(&self) -> Option; +} + +impl CultLookupValue for String { + fn lookup_value(&self) -> Option { + Some(self.clone()) + } +} + +impl CultLookupValue for Option { + fn lookup_value(&self) -> Option { + self.as_ref().and_then(CultLookupValue::lookup_value) + } +} + +macro_rules! lookup_value_by_display { + ($($ty:ty),* $(,)?) => {$( + impl CultLookupValue for $ty { + fn lookup_value(&self) -> Option { + Some(self.to_string()) + } + } + )*}; +} + +lookup_value_by_display!(bool, char, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize); + +/// A global type already holds a record under another key; a second would leave `get_global` with two answers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CultCacheSecondGlobal { + pub schema_name: String, + pub existing_key: String, + pub key: String, +} + +impl std::fmt::Display for CultCacheSecondGlobal { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "{} is a global and already has record {}; {} would be a second.", + self.schema_name, self.existing_key, self.key + ) + } +} + +impl std::error::Error for CultCacheSecondGlobal {} + /// A type written as a positional slots array: what the `CultSlots` and `DatabaseEntry` /// derives implement, and what a `CultUnion` arm must carry. #[diagnostic::on_unimplemented( @@ -464,6 +524,11 @@ impl<'de> serde::Deserialize<'de> for PersistedRecord { const STORE_FORMAT_V1: &str = "cultcache.store.v1"; +/// A store that can hold element ids. A reader older than element ids refuses this header: it would skip the id +/// slots of a nested element and rewrite the element without them. This runtime writes it back only for a file it +/// found under it; it does not decide when a document carries ids. +const STORE_FORMAT_ELEMENT_IDS: &str = "cultcache.store.v3"; + /// Opens every refusal CultCache raises about one persisted record. The decoder's own /// messages can quote the bytes; these name only the record's key and schema id. const RECORD_REFUSAL: &str = "CultCache record "; @@ -687,18 +752,27 @@ const DECODE_STACK_SEGMENT: usize = 2 * 1024 * 1024; /// ``` pub const MAX_RECORD_INLINE_BYTES: usize = 16 * 1024; -/// The one grown decoder of stored bytes: the outer stack check, then `serde_stacker` re-checks at -/// every container serde's impls descend through, skipped values included. No nesting bound is applied here; [`decode_payload`] walks first. -fn decode_grown(bytes: &[u8]) -> std::result::Result { +/// The one grown setup for every decode of stored bytes: the outer stack check, then `serde_stacker` +/// re-checks at every container serde's impls descend through, skipped values included. `body` runs on the grown stack with the +/// grown deserializer over `reader`, so a caller that must read `reader`'s position afterwards (the store header) still can. +fn grown( + reader: &mut rmp_serde::Deserializer, + body: impl FnOnce(serde_stacker::Deserializer<&mut rmp_serde::Deserializer>) -> T, +) -> T { stacker::maybe_grow(DECODE_RED_ZONE, DECODE_STACK_SEGMENT, || { - let mut reader = rmp_serde::Deserializer::from_read_ref(bytes); - let mut grown = serde_stacker::Deserializer::new(&mut reader); + let mut grown = serde_stacker::Deserializer::new(reader); grown.red_zone = DECODE_RED_ZONE; grown.stack_size = DECODE_STACK_SEGMENT; - T::deserialize(grown) + body(grown) }) } +/// The one grown decoder of a value from stored bytes. No nesting bound is applied here; [`decode_payload`] walks first. +fn decode_grown(bytes: &[u8]) -> std::result::Result { + let mut reader = rmp_serde::Deserializer::from_read_ref(bytes); + grown(&mut reader, |grown| T::deserialize(grown)) +} + /// The one decode of a document payload: the nesting bound first, then [`decode_grown`], whose /// rmp-serde depth counter (1024) is only a backstop. A failure keeps its kind and never quotes the /// bytes. Readers of stored envelope payloads outside this crate decode through it too. @@ -774,17 +848,98 @@ fn describe_serde_message(message: &str) -> String { } } -/// The store header string, or `None` when the bytes do not open with an array whose -/// first slot is a string (the legacy envelope array starts with a map). Only the -/// leading bytes are read, so a store cut short after its header still names its format. -fn store_header(bytes: &[u8]) -> Option { - let mut offset = 0usize; - if read_array_header(bytes, &mut offset)? == 0 { - return None; - } - read_string(bytes, &mut offset) +/// What one pass over a file's bytes learned about its header. `first` is the leading slot when it is a MessagePack +/// string (`str`, never `bin`, as in every other runtime), and `None` when the bytes do not open with an array whose first +/// slot is a string (a legacy envelope array candidate). `whole` is the proof that the bytes are exactly one complete +/// MessagePack array: a truncated store or bytes after the array fail it, and are never taken for a legacy file. A store cut +/// short after its header string still names its format, so `first` survives a failed `whole` and a newer store is refused +/// as unsupported, not as damaged. +struct StoreHeader { + first: Option, + whole: Result<()>, } +/// The one reader of a store file's header. Nothing else classifies leading bytes. +fn store_header(bytes: &[u8]) -> StoreHeader { + /// A string slot and only a string: MessagePack `bin` is not a header. + fn text<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result { + struct TextVisitor; + impl serde::de::Visitor<'_> for TextVisitor { + type Value = String; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a MessagePack string") + } + + fn visit_str(self, value: &str) -> std::result::Result { + Ok(value.to_string()) + } + } + deserializer.deserialize_any(TextVisitor) + } + + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum FirstSlot { + Text(#[serde(deserialize_with = "text")] String), + Other(serde::de::IgnoredAny), + } + + struct HeaderVisitor<'a>(&'a mut Option); + impl<'de> serde::de::Visitor<'de> for HeaderVisitor<'_> { + type Value = (); + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an array whose first slot may be the store header") + } + + fn visit_seq>(self, mut seq: A) -> std::result::Result<(), A::Error> { + if let Some(FirstSlot::Text(header)) = seq.next_element::()? { + *self.0 = Some(header); + } + while seq.next_element::()?.is_some() {} + Ok(()) + } + } + + let mut first = None; + let mut cursor = std::io::Cursor::new(bytes); + // Walked on the grown stack, as every other decode of stored bytes is: a store may nest values deeper than the thread allows. + let walked = { + let mut reader = rmp_serde::Deserializer::new(&mut cursor); + grown(&mut reader, |grown| serde::Deserializer::deserialize_seq(grown, HeaderVisitor(&mut first))) + }; + let after = bytes.len() - cursor.position() as usize; + let whole = walked + .map_err(|error| opaque_decode_error(error).context("the store is not one complete MessagePack array")) + .and_then(|()| { + ensure!(after == 0, "the store has {after} bytes after its MessagePack array"); + Ok(()) + }); + StoreHeader { first, whole } +} + +/// A write the catalog cannot describe: records of different types under one schema id. This writer derives one entry per carried +/// schema id from its records, so that is its only conflict. Nothing is written. It names the id, the two names and a record key. +#[derive(Debug)] +pub struct SchemaConflictError { + pub schema_id: String, + pub schema_names: Vec, + pub record_key: String, +} + +impl std::fmt::Display for SchemaConflictError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "schema id {:?} cannot describe record {:?}: {:?}", + self.schema_id, self.record_key, self.schema_names + ) + } +} + +impl std::error::Error for SchemaConflictError {} + /// A single-file store whose bytes were read and not accepted. It sits in the error /// chain of every such read, so a consumer tells it apart with /// `error.downcast_ref::()`. A failure to read the bytes @@ -872,6 +1027,8 @@ impl std::error::Error for CultCacheStoreWriteFailed {} #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct PushAllOptions { + /// Seed the store only where nothing is: a single-file flush writes when nothing is at the path, leaves a store it can + /// read byte for byte, and refuses what its reader refuses (a dangling link included), as a plain flush refuses. pub soft: bool, } @@ -1243,46 +1400,60 @@ impl SingleFileMessagePackBackingStore { } fn read_all_unlocked(&self) -> Result> { - // Only a store that is not there is empty. A file that is there and empty is - // not a store (no writer leaves one), and any other failure to reach the file, - // such as a symlink loop, a file where a directory should be, or no permission, - // stays an io::Error: reading it as empty would let a writer replace a store it - // never saw. - // A dangling symbolic link is something at the path, not nothing: reading it as - // empty would let the writer rename a file over the link and move the store off - // the volume the link pointed to. + Ok(self.read_store_unlocked()?.envelopes) + } + + /// The one reader of the store file: open, every rewrite and the read-only snapshot ask it, so they agree on which + /// files are stores. Only a file that is not there is an empty store. A file that is there and empty is not a store + /// (no writer leaves one), and any other failure to reach the file, such as a symlink loop, a file where a directory + /// should be, or no permission, stays an io::Error: reading it as empty would let a writer replace a store it never + /// saw. A dangling symbolic link is something at the path, not nothing, for the same reason. Anything else must decode + /// completely, as exactly one store or one legacy envelope array; bytes this runtime cannot read are refused with + /// [`CultCacheStoreUnreadable`], so a rewrite never overwrites what it cannot see. + fn read_store_unlocked(&self) -> Result { let bytes = match fs::read(&self.path) { Ok(bytes) => bytes, Err(error) if error.kind() == std::io::ErrorKind::NotFound && is_absent(&self.path)? => { - return Ok(Vec::new()); + return Ok(DiskStore::absent()); } Err(error) => { return Err(anyhow::Error::new(error) .context(format!("failed to read {}", self.path.display()))); } }; - let unreadable = |kind, detail: anyhow::Error| { - // The detail is rebuilt as text that names no value read from the bytes. The - // decoder reports a short read as an io::Error, and dropping it keeps an - // io::Error in the chain meaning only that the file itself could not be read. - anyhow!(describe_undecodable(&detail)).context(CultCacheStoreUnreadable { - path: self.path.clone(), - kind, - }) - }; - match store_header(&bytes) { - Some(header) if header == STORE_FORMAT_V1 => decode_store_snapshot(&bytes) - .map_err(|error| unreadable(CultCacheStoreUnreadableKind::Undecodable, error)), - Some(header) if header.starts_with("cultcache.store.") => Err(unreadable( + let head = store_header(&bytes); + self.refuse_unread_format(head.first.as_deref())?; + head.whole + .and_then(|()| decode_store_file(&bytes, head.first.as_deref())) + .map_err(|error| self.unreadable(CultCacheStoreUnreadableKind::Undecodable, error)) + } + + // The one owner of the stamp rule, asked only by the one reader: a cultcache.store.* header this runtime does not read + // (it reads v1 and v3) is refused by name, judged from the header alone, so a newer store cut short is still a newer + // store. Every write over a file asks that reader first, so a whole-store write judges a stamp as a read does. + fn refuse_unread_format(&self, header: Option<&str>) -> Result<()> { + match header { + Some(header) if header.starts_with("cultcache.store.") && readable_store_format(header).is_none() => Err(self.unreadable( CultCacheStoreUnreadableKind::UnsupportedFormat, - anyhow!("its header is {}; this runtime reads {STORE_FORMAT_V1:?} only", describe_header(&header)), + anyhow!( + "its header is {}; this runtime reads {STORE_FORMAT_V1:?} and {STORE_FORMAT_ELEMENT_IDS:?} only", + describe_header(header) + ), )), - _ => decode_grown(&bytes) - .context("failed to decode legacy CultCache envelope array") - .map_err(|error| unreadable(CultCacheStoreUnreadableKind::Undecodable, error)), + _ => Ok(()), } } + fn unreadable(&self, kind: CultCacheStoreUnreadableKind, detail: anyhow::Error) -> anyhow::Error { + // The detail is rebuilt as text that names no value read from the bytes. The + // decoder reports a short read as an io::Error, and dropping it keeps an + // io::Error in the chain meaning only that the file itself could not be read. + anyhow!(describe_undecodable(&detail)).context(CultCacheStoreUnreadable { + path: self.path.clone(), + kind, + }) + } + /// Reads one filesystem snapshot without creating or opening the sibling /// CultCache lock file. /// @@ -1300,7 +1471,18 @@ impl SingleFileMessagePackBackingStore { path: self.path.clone(), kind, }; - let bytes = encode_store_snapshot(entries) + // The header a rewrite writes is the one the file on disk carries: a file marked for element ids stays marked (the + // payloads this store passes through may hold ids), one that is gone or legacy is written v1. The file is read by + // the same reader as an open, so a store this runtime cannot read completely, a variant included, is refused + // and never overwritten. + let disk = match self.read_store_unlocked() { + // A dangling link holds no store, so a whole-file replace has no header to keep and replaces the link (R3 pins + // this). The only NotFound the reader returns is that one: a merging writer has already refused it when it + // read the current entries. + Err(error) if io_not_found(&error) => DiskStore::absent(), + other => other?, + }; + let bytes = encode_store_snapshot(entries, &disk) .and_then(|snapshot| rmp_serde::to_vec(&snapshot).context("failed to encode MessagePack")) .map_err(|error| error.context(failed(CultCacheStoreWriteFailedKind::Rejected)))?; self.replace_unlocked(&bytes) @@ -1569,8 +1751,14 @@ impl CacheBackingStore for SingleFileMessagePackBackingStore { }) } - fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> { + fn push_all(&mut self, entries: &[CultCacheEnvelope], options: PushAllOptions) -> Result<()> { self.with_exclusive_lock(|| { + // A soft flush asks the one reader: nothing at the path is written, a store it reads is left alone, and anything + // the reader refuses (an unreadable file, a dangling link) is refused here as it is for open. + if options.soft && !self.read_store_unlocked()?.absent { + return Ok(()); + } + let mut entries = entries.to_vec(); entries.sort_by_key(entry_id); self.write_all_unlocked(&entries) @@ -2527,6 +2715,8 @@ struct CultCacheStoreRegistration { pub struct CultCache { definitions: BTreeMap, + /// The registered types declared `#[cultcache(global)]`. + globals: BTreeSet, schema_name_definitions: BTreeMap, entries: BTreeMap<(String, String), CultCacheEnvelope>, stores: Vec, @@ -2536,6 +2726,7 @@ impl CultCache { pub fn new() -> Self { Self { definitions: BTreeMap::new(), + globals: BTreeSet::new(), schema_name_definitions: BTreeMap::new(), entries: BTreeMap::new(), stores: Vec::new(), @@ -2566,6 +2757,11 @@ impl CultCache { )); } self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME); + if T::GLOBAL { + self.globals.insert(T::TYPE.to_string()); + } else { + self.globals.remove(T::TYPE); + } self.schema_name_definitions .insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string()); Ok(()) @@ -2650,6 +2846,7 @@ impl CultCache { let Some(canonical_type) = resolve_registered_type( &known_types, &schema_name_definitions, + entry.schema_id.as_deref(), &entry.r#type, ) else { return Err(anyhow!( @@ -2671,6 +2868,7 @@ impl CultCache { loaded.insert(entry_id(&entry), entry); } } + self.refuse_second_globals(&loaded)?; self.entries = loaded; Ok(()) } @@ -2754,6 +2952,65 @@ impl CultCache { Ok(values) } + /// The one record of a `#[cultcache(global)]` type, or `None` before it is written (and for a type that is + /// not a global). + pub fn get_global(&self) -> Result> { + self.require_entry_type::()?; + if !T::GLOBAL { + return Ok(None); + } + self.find_record::(|_| true) + } + + /// The record whose `#[cultcache(name)]` member is `name`. An empty name is no name, so it finds nothing. Records + /// of one type sharing a name find the one with the smallest key (ordinal), whatever order they were written or + /// loaded in; removing it finds the next. + pub fn get_by_name(&self, name: &str) -> Result> { + self.require_entry_type::()?; + if name.is_empty() { + return Ok(None); + } + self.find_record::(|document| document.cult_name().as_deref() == Some(name)) + } + + /// The record whose `#[cultcache(index)]` member `alias` holds `value`. A blank value is not indexed, so it finds + /// nothing. Records of one type sharing a value find the one with the smallest key (ordinal), whatever order they + /// were written or loaded in; removing it finds the next. + pub fn get_by_index(&self, alias: &str, value: &str) -> Result> { + self.require_entry_type::()?; + if value.trim().is_empty() { + return Ok(None); + } + self.find_record::(|document| { + document + .cult_indexes() + .iter() + .any(|(held_alias, held_value)| *held_alias == alias && held_value == value) + }) + } + + /// The first record of `T`, in key order, that `matches`. + fn find_record(&self, matches: impl Fn(&T) -> bool) -> Result> { + let held = self + .entries + .range((T::TYPE.to_string(), String::new())..) + .take_while(|((held_type, _), _)| held_type == T::TYPE); + for (_, entry) in held { + let document: T = decode_payload(&entry.payload).with_context(|| { + format!( + "failed to decode CultCache entry {:?} at key {:?} as {}", + T::TYPE, + entry.key, + T::SCHEMA_NAME + ) + })?; + if matches(&document) { + return Ok(Some(document)); + } + } + Ok(None) + } + pub fn soa(&self) -> Result> { let keyed_rows = self.get_all_with_keys::()?; let (keys, documents): (Vec<_>, Vec<_>) = keyed_rows.into_iter().unzip(); @@ -2771,6 +3028,7 @@ impl CultCache { pub fn put(&mut self, key: impl Into, value: &T) -> Result { let (entry, parsed) = self.prepare_entry(key, value)?; + self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; if let Some(home) = self.home_index(T::TYPE)? { self.stores[home].store.push(&entry)?; } @@ -2882,6 +3140,7 @@ impl CultCache { for entry in &staged { next.insert(entry_id(entry), entry.clone()); } + self.refuse_second_globals(&next)?; if let Some(store_index) = store_index { // The home store receives its own records only: those it already // holds in this cache plus the staged ones. @@ -2900,6 +3159,7 @@ impl CultCache { pub fn put_envelope(&mut self, entry: CultCacheEnvelope) -> Result { let parsed = self.validate_envelope::(&entry)?; + self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; if let Some(home) = self.home_index(T::TYPE)? { self.stores[home].store.push(&entry)?; } @@ -2932,6 +3192,7 @@ impl CultCache { )); } require_payload_depth(&entry)?; + self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; if let Some(home) = self.home_index(&entry.r#type)? { self.stores[home].store.push(&entry)?; } @@ -3017,10 +3278,43 @@ impl CultCache { self.home_index(&entry.r#type)?; loaded.insert(entry_id(&entry), entry); } + self.refuse_second_globals(&loaded)?; self.entries = loaded; Ok(()) } + fn refuse_second_global( + &self, + entries: &BTreeMap<(String, String), CultCacheEnvelope>, + type_id: &str, + key: &str, + ) -> Result<()> { + if !self.globals.contains(type_id) { + return Ok(()); + } + let held_other = entries + .range((type_id.to_string(), String::new())..) + .take_while(|((held_type, _), _)| held_type == type_id) + .map(|((_, held_key), _)| held_key) + .find(|held_key| held_key.as_str() != key); + match held_other { + Some(existing_key) => Err(CultCacheSecondGlobal { + schema_name: self.definitions.get(type_id).copied().unwrap_or(type_id).to_string(), + existing_key: existing_key.clone(), + key: key.to_string(), + } + .into()), + None => Ok(()), + } + } + + fn refuse_second_globals(&self, entries: &BTreeMap<(String, String), CultCacheEnvelope>) -> Result<()> { + for (type_id, key) in entries.keys() { + self.refuse_second_global(entries, type_id, key)?; + } + Ok(()) + } + fn require_entry_type(&self) -> Result<()> { match self.definitions.get(T::TYPE) { Some(schema_name) if *schema_name == T::SCHEMA_NAME => Ok(()), @@ -3085,40 +3379,68 @@ fn now_utc_second() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } -fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> Result { - let mut schema_types = BTreeMap::::new(); +/// The catalog names each schema id once. An entry the file already holds is arrived, and its type is its schema's name; +/// one the caller supplies is registered. Records of one tier under one id are one type. Across tiers a write may not +/// retype what the file holds, unless the registered type owns the id (a registered type's id is its entry type): that is +/// a rename under a stable id, and the registered type names the id. +fn encode_store_snapshot(entries: &[CultCacheEnvelope], disk: &DiskStore) -> Result { + let arrived: BTreeMap<(String, String), &CultCacheEnvelope> = disk.envelopes.iter().map(|entry| (entry_id(entry), entry)).collect(); + // Per schema id and tier: the type its records carry, and the key of the record that named it. + let mut schema_types = BTreeMap::<(String, bool), (String, String)>::new(); for entry in entries { let schema_id = entry .schema_id .clone() .unwrap_or_else(|| entry.r#type.clone()); - if let Some(existing_type) = schema_types.insert(schema_id.clone(), entry.r#type.clone()) { - ensure!( - existing_type == entry.r#type, - "CultCache schema {schema_id:?} cannot identify both {existing_type:?} and {:?}", - entry.r#type - ); + let registered = arrived.get(&entry_id(entry)) != Some(&entry); + if let Some((existing_type, _)) = schema_types.insert((schema_id.clone(), registered), (entry.r#type.clone(), entry.key.clone())) { + if existing_type != entry.r#type { + return Err(anyhow::Error::new(SchemaConflictError { + schema_id, + schema_names: vec![existing_type, entry.r#type.clone()], + record_key: entry.key.clone(), + })); + } } } - let catalog = schema_types - .into_iter() - .map(|(schema_id, document_type)| { - PersistedSchemaCatalogEntry( - schema_id.clone(), - document_type, - format!("{schema_id}.v1"), - schema_id.clone(), - format!( - "{{\"schemaName\":\"{}\",\"schemaVersion\":\"{}.v1\",\"members\":[]}}", - escape_json_string(&schema_id), - escape_json_string(&schema_id) - ), - vec![schema_id], - Vec::new(), - ) - }) + let mut named = BTreeMap::::new(); + for ((schema_id, registered), (document_type, key)) in schema_types { + if !registered { + named.insert(schema_id, document_type); + } else if let Some(arrived_type) = named.insert(schema_id.clone(), document_type.clone()) + && arrived_type != document_type + && schema_id != document_type + { + return Err(anyhow::Error::new(SchemaConflictError { + schema_id, + schema_names: vec![arrived_type, document_type], + record_key: key, + })); + } + } + // An entry the file holds for an id is written back as the file holds it: this runtime keeps no description of another + // runtime's schema, so it never replaces one. It describes an id itself only when the file has no entry for it, or when a + // registered type owns the id under another name (a rename, where the registered type names the id). + let owned: BTreeSet<&str> = entries + .iter() + .filter(|entry| entry.schema_id.as_deref().is_none_or(|schema_id| schema_id == entry.r#type)) + .map(|entry| entry.r#type.as_str()) .collect(); + let mut catalog = BTreeMap::::new(); + for (schema_id, document_type) in named { + let held = disk + .catalog + .iter() + .find(|entry| entry.0 == schema_id) + .or_else(|| disk.catalog.iter().find(|entry| entry.5.contains(&schema_id))) + .filter(|entry| !owned.contains(schema_id.as_str()) || entry.1 == document_type); + let entry = match held { + Some(entry) => entry.clone(), + None => described(&schema_id, document_type), + }; + catalog.entry(entry.0.clone()).or_insert(entry); + } let records = entries .iter() .map(|entry| { @@ -3135,51 +3457,101 @@ fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> Result Result> { +/// The entry this runtime writes for a schema id it describes itself: its entry type is the id and the schema's name. +fn described(schema_id: &str, document_type: String) -> PersistedSchemaCatalogEntry { + PersistedSchemaCatalogEntry( + schema_id.to_string(), + document_type, + format!("{schema_id}.v1"), + schema_id.to_string(), + format!( + "{{\"schemaName\":\"{}\",\"schemaVersion\":\"{}.v1\",\"members\":[]}}", + escape_json_string(schema_id), + escape_json_string(schema_id) + ), + vec![schema_id.to_string()], + Vec::new(), + ) +} + +fn readable_store_format(header: &str) -> Option<&'static str> { + match header { + STORE_FORMAT_V1 => Some(STORE_FORMAT_V1), + STORE_FORMAT_ELEMENT_IDS => Some(STORE_FORMAT_ELEMENT_IDS), + _ => None, + } +} + +/// One store file's bytes, decoded: the header it carries (the one a rewrite keeps) and its envelopes. `header` is +/// [`store_header`]'s first slot and the bytes are already proven one complete array. An array whose first slot is not a +/// store header is the legacy envelope array. +fn decode_store_file(bytes: &[u8], header: Option<&str>) -> Result { + match header { + Some(header) => { + let format = readable_store_format(header) + .ok_or_else(|| anyhow!("the first slot is not a store header this runtime reads"))?; + let (envelopes, catalog) = decode_store_snapshot(bytes)?; + Ok(DiskStore { absent: false, format, envelopes, catalog }) + } + None => Ok(DiskStore { absent: false, format: STORE_FORMAT_V1, envelopes: decode_legacy_envelopes(bytes)?, catalog: Vec::new() }), + } +} + +/// What a store file holds, as its one reader decoded it: the header a rewrite keeps, its envelopes, and the catalog entries it +/// publishes them by, which a rewrite lays back for the ids they name. +struct DiskStore { + /// Nothing was at the path; the other fields describe an empty v1 store. + absent: bool, + format: &'static str, + envelopes: Vec, + catalog: Vec, +} + +impl DiskStore { + /// A file that is gone, or a dangling link a whole-file replace is about to replace: an empty v1 store. + fn absent() -> Self { + Self { absent: true, format: STORE_FORMAT_V1, envelopes: Vec::new(), catalog: Vec::new() } + } +} + +/// The legacy envelope array: an array of envelope maps. Its acceptance is the one verdict on an array whose first slot +/// is not a store header, for reading it and for replacing it. +fn decode_legacy_envelopes(bytes: &[u8]) -> Result> { + decode_grown(bytes).context("failed to decode legacy CultCache envelope array") +} + +/// Decodes a store whose header the caller has already read as a readable one: its envelopes and its catalog. +fn decode_store_snapshot(bytes: &[u8]) -> Result<(Vec, Vec)> { let snapshot: PersistedStoreSnapshot = decode_grown(bytes).context("failed to decode CultCache v1 snapshot")?; - let catalog = snapshot - .1 - .into_iter() - .map(|entry| (entry.0, entry.1)) - .collect::>(); - snapshot + // A schema id names the entry that has it as its own id; only an id no entry owns names the entry that lists it as compatible. + let mut catalog = BTreeMap::::new(); + for entry in &snapshot.1 { + catalog.entry(entry.0.clone()).or_insert_with(|| entry.1.clone()); + } + for entry in &snapshot.1 { + for compatible in &entry.5 { + catalog.entry(compatible.clone()).or_insert_with(|| entry.1.clone()); + } + } + let envelopes = snapshot .2 .into_iter() .map(|record| { let r#type = match catalog.get(&record.1) { Some(schema_name) => schema_name.clone(), - None => { - // A record can outlive its catalog entry when another runtime - // wrote it, or when the record is older than the catalog it - // was stamped against. The payload is a MessagePack array - // whose first field is the schema version, so the type can be - // recovered from the record itself rather than failing the - // whole snapshot. - let schema_version = - infer_schema_version_from_payload(&record.3).ok_or_else(|| { - anyhow!( - "CultCache record {:?} references missing schema {:?}", - record.0, - record.1 - ) - })?; - infer_schema_name(&schema_version).ok_or_else(|| { - anyhow!( - "CultCache record {:?} references missing schema {:?}", - record.0, - record.1 - ) - })? - } + None => bail!( + "CultCache record {:?} references schema {:?}, which the store's catalog does not publish", + record.0, + record.1 + ), }; Ok(CultCacheEnvelope { key: record.0, @@ -3189,17 +3561,25 @@ fn decode_store_snapshot(bytes: &[u8]) -> Result> { schema_id: Some(record.1), }) }) - .collect() + .collect::>>()?; + Ok((envelopes, snapshot.1)) } -/// A record recovered from its payload carries a schema name, while the -/// registry is keyed by entry type. Accept either, so a snapshot whose catalog -/// entry is gone still resolves to the registered definition. +/// A record resolves by its schema id: a registered type's id is its entry type. The name its catalog carries is metadata, +/// and names a type only when the id names none: the registry is keyed by entry type, and a store from a runtime with other +/// schema ids can be read by the type or schema name it carries. fn resolve_registered_type( known_types: &BTreeSet, schema_name_definitions: &BTreeMap, + schema_id: Option<&str>, persisted_type: &str, ) -> Option { + if let Some(schema_id) = schema_id + && known_types.contains(schema_id) + { + return Some(schema_id.to_string()); + } + if known_types.contains(persisted_type) { return Some(persisted_type.to_string()); } @@ -3207,68 +3587,6 @@ fn resolve_registered_type( schema_name_definitions.get(persisted_type).cloned() } -fn infer_schema_version_from_payload(payload: &[u8]) -> Option { - let mut offset = 0usize; - read_array_header(payload, &mut offset)?; - read_string(payload, &mut offset) -} - -fn read_array_header(payload: &[u8], offset: &mut usize) -> Option { - let marker = *payload.get(*offset)?; - *offset += 1; - match marker { - 0x90..=0x9f => Some((marker & 0x0f) as u32), - 0xdc => { - let bytes = payload.get(*offset..(*offset + 2))?; - *offset += 2; - Some(u16::from_be_bytes([bytes[0], bytes[1]]) as u32) - } - 0xdd => { - let bytes = payload.get(*offset..(*offset + 4))?; - *offset += 4; - Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) - } - _ => None, - } -} - -fn read_string(payload: &[u8], offset: &mut usize) -> Option { - let marker = *payload.get(*offset)?; - *offset += 1; - let length = match marker { - 0xa0..=0xbf => (marker & 0x1f) as usize, - 0xd9 => { - let length = *payload.get(*offset)? as usize; - *offset += 1; - length - } - 0xda => { - let bytes = payload.get(*offset..(*offset + 2))?; - *offset += 2; - u16::from_be_bytes([bytes[0], bytes[1]]) as usize - } - 0xdb => { - let bytes = payload.get(*offset..(*offset + 4))?; - *offset += 4; - u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize - } - _ => return None, - }; - - let bytes = payload.get(*offset..(*offset + length))?; - std::str::from_utf8(bytes).ok().map(str::to_string) -} - -fn infer_schema_name(schema_version: &str) -> Option { - let marker = schema_version.rfind(".v")?; - let version = schema_version.get((marker + 2)..)?; - if marker == 0 || version.is_empty() || !version.bytes().all(|value| value.is_ascii_digit()) { - return None; - } - - Some(schema_version[..marker].to_string()) -} - fn escape_json_string(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } @@ -3312,6 +3630,12 @@ fn release_lock(lock: File, result: Result) -> Result { result } +fn io_not_found(error: &anyhow::Error) -> bool { + error + .chain() + .any(|cause| cause.downcast_ref::().is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)) +} + /// True only when nothing is at `path`; a symbolic link, dangling or not, is something. /// A path that cannot be inspected (a file where a directory should be, no permission) /// is an error, never absent. @@ -3362,7 +3686,7 @@ fn remove_abandoned_staging_files(destination: &Path) -> Result<()> { #[cfg(unix)] fn replace_file_atomically(staged: &Path, destination: &Path) -> Result<()> { - fs::rename(staged, destination).with_context(|| { + injected_write_fault(WriteStep::Rename).and_then(|()| fs::rename(staged, destination)).with_context(|| { format!( "failed to atomically replace {} with {}", destination.display(), @@ -3378,6 +3702,8 @@ fn replace_file_atomically(staged: &Path, destination: &Path) -> Result<()> { MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, }; + injected_write_fault(WriteStep::Rename).context("failed to atomically replace the store")?; + let staged_wide = staged .as_os_str() .encode_wide() @@ -3478,6 +3804,7 @@ fn sync_parent_directory(path: &Path) -> Result<()> { #[allow(dead_code)] enum WriteStep { StagingSync, + Rename, DirectorySync, Unlock, } @@ -5016,7 +5343,7 @@ mod tests { } #[test] - fn messagepack_store_recovers_schema_stamped_records_missing_catalog_entries() -> Result<()> { + fn messagepack_store_refuses_a_record_whose_schema_the_catalog_does_not_publish() -> Result<()> { let temp = tempfile::tempdir()?; let store_path = temp.path().join("missing-catalog.msgpack"); let expected = SchemaStamped { @@ -5024,8 +5351,7 @@ mod tests { name: "schema-stamped".to_string(), value: "still readable".to_string(), }; - // An empty catalog with a stale schema id is what a record written by - // another runtime, or one older than its catalog, looks like on disk. + // An empty catalog with a stale schema id. let snapshot = PersistedStoreSnapshot( "cultcache.store.v1".to_string(), Vec::new(), @@ -5041,15 +5367,9 @@ mod tests { let mut cache = CultCache::new(); cache.register_entry_type::()?; cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path))?; - cache.pull_all_backing_stores()?; - - assert_eq!(cache.get_required::("record-1")?, expected); - assert_eq!( - cache - .get_required_envelope::("record-1")? - .schema_id, - Some("sha256:stale-schema-id-from-cold-record".to_string()) - ); + // The payload opens with a schema version, and the catalog is the store's own description: no recovery. + let error = cache.pull_all_backing_stores().unwrap_err(); + assert!(error.downcast_ref::().is_some(), "{error:#}"); Ok(()) } @@ -5333,6 +5653,243 @@ mod tests { Ok(()) } + // The element-id marker: tests/vectors/document-variants-c2a/v3-base.msgpack is v1-base with its header replaced. + fn header_after_rewrite(vector: &str) -> Result { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/vectors") + .join(vector); + std::fs::copy(source, &path)?; + let mut store = SingleFileMessagePackBackingStore::new(&path); + let envelopes = store.pull_all()?; + let keys: Vec<_> = envelopes.iter().map(|e| e.key.as_str()).collect(); + assert_eq!(keys, vec!["alpha", "beta"]); + store.push(&envelopes[0])?; + Ok(store_header(&std::fs::read(&path)?).first.expect("a store header")) + } + + #[test] + fn a_v3_element_id_store_reads_and_a_rewrite_keeps_the_marker() -> Result<()> { + assert_eq!( + header_after_rewrite("document-variants-c2a/v3-base.msgpack")?, + "cultcache.store.v3" + ); + Ok(()) + } + + // A whole-store flush reads the header the file carries first: one it cannot read refuses, and the file is untouched. + #[test] + fn push_all_refuses_a_store_whose_header_it_cannot_read_and_leaves_it_untouched() -> Result<()> { + for vector in [ + "document-variants-c0/unknown-header.msgpack", + "document-variants-c0/variant-v2.msgpack", + "document-variants-c1/variant-store.msgpack", + ] { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors").join(vector); + std::fs::copy(source, &path)?; + let before = std::fs::read(&path)?; + let mut store = SingleFileMessagePackBackingStore::new(&path); + let error = store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions::default()).unwrap_err(); + assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::UnsupportedFormat), "{vector}: {error:#}"); + assert_eq!(std::fs::read(&path)?, before, "{vector} was rewritten"); + } + Ok(()) + } + + // The header is read by the one snapshot reader, whatever string encoding carries it. + #[test] + fn push_all_refuses_a_header_it_cannot_read_however_the_string_is_encoded() -> Result<()> { + let long = format!("cultcache.store.v9.{}", "x".repeat(90)); + let mut str16 = vec![0x93, 0xda, 0x00, 18]; + str16.extend_from_slice(b"cultcache.store.v9"); + str16.extend_from_slice(&[0x90, 0x90]); + let mut str8 = vec![0x93, 0xd9, long.len() as u8]; + str8.extend_from_slice(long.as_bytes()); + str8.extend_from_slice(&[0x90, 0x90]); + for (name, bytes) in [("str16", str16), ("str8 over 90 bytes", str8)] { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + std::fs::write(&path, &bytes)?; + let mut store = SingleFileMessagePackBackingStore::new(&path); + let error = store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions::default()).unwrap_err(); + assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::UnsupportedFormat), "{name}: {error:#}"); + assert_eq!(std::fs::read(&path)?, bytes, "{name} was rewritten"); + } + Ok(()) + } + + + // A file is replaced by a rewrite exactly when this runtime's own reader opens it: one verdict per file, asked by open, + // push_all and push alike. The bytes and every runtime's verdict are tests/vectors/document-variants-c2a/readability. + // A refusal is a CultCacheStoreUnreadable, and a replaced file carries the header its old content decides. + #[test] + fn a_file_is_replaced_exactly_when_it_reads() -> Result<()> { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/document-variants-c2a/readability"); + let manifest = std::fs::read_to_string(root.join("manifest.txt"))?; + let mut listed = Vec::new(); + for line in manifest.lines().filter(|line| !line.is_empty() && !line.starts_with('#')) { + let cells: Vec<&str> = line.split_whitespace().collect(); + let (vector, reads) = (cells[0], cells[2] == "reads"); + if !vector.starts_with("..") { + listed.push(vector.to_string()); + } + let bytes = std::fs::read(root.join(vector))?; + // A v3 store keeps its marker through a rewrite; every other file that reads is written v1. + let header = if vector.contains("v3") { "cultcache.store.v3" } else { STORE_FORMAT_V1 }; + for operation in ["open", "soft_push_all", "push_all", "push"] { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + std::fs::write(&path, &bytes)?; + let mut store = SingleFileMessagePackBackingStore::new(&path); + let outcome = match operation { + "open" => store.pull_all().map(|_| ()), + "soft_push_all" => store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions { soft: true }), + "push_all" => store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions::default()), + _ => store.push(&snapshot_envelope("x", b"one")), + }; + let what = format!("{vector} {operation}"); + assert_eq!(outcome.is_ok(), reads, "{what}: {outcome:?}"); + if operation == "soft_push_all" && reads { + // A soft flush leaves a store it reads byte for byte. + assert_eq!(std::fs::read(&path)?, bytes, "{what} rewrote a store it reads"); + continue; + } + if !reads { + let error = outcome.unwrap_err(); + let expected = if vector == "header-v9.bin" { + CultCacheStoreUnreadableKind::UnsupportedFormat + } else { + CultCacheStoreUnreadableKind::Undecodable + }; + assert_eq!(unreadable_kind(&error), Some(expected), "{what}: {error:#}"); + assert!(io_error_in(&error).is_none(), "{what}: a refusal is not an I/O failure: {error:#}"); + assert_eq!(std::fs::read(&path)?, bytes, "{what} rewrote a file it cannot read"); + } else if operation != "open" { + assert_eq!(store_header(&std::fs::read(&path)?).first.as_deref(), Some(header), "{what}"); + assert!(store.pull_all()?.iter().any(|entry| entry.key == "x"), "{what}"); + } + } + } + // Every vector in the folder has a manifest row. + let mut files: Vec = std::fs::read_dir(&root)? + .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) + .filter(|name| name.ends_with(".bin")) + .collect(); + files.sort(); + listed.sort(); + assert_eq!(listed, files); + Ok(()) + } + + // An id one entry owns and a later entry lists as compatible names the entry that owns it. + #[test] + fn an_id_an_entry_owns_names_that_entry_not_one_that_lists_it_as_compatible() -> Result<()> { + for vector in ["own-id-over-compatible-v3.bin", "compatible-before-owner-v3.bin"] { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + std::fs::copy( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/vectors/document-variants-c2a/readability") + .join(vector), + &path, + )?; + let envelopes = SingleFileMessagePackBackingStore::new(&path).pull_all()?; + assert_eq!(envelopes.len(), 2, "{vector}"); + assert!(envelopes.iter().all(|envelope| envelope.r#type == "vectors.item"), "{vector}: {envelopes:?}"); + } + Ok(()) + } + + // Records of different types under one schema id cannot be described by one catalog entry: the write is refused, typed, in + // either order, and the file is left as it was. A rename with a stable id (same type name in the new schema) writes fine. + #[test] + fn records_of_different_types_under_one_schema_id_refuse_the_write_typed() -> Result<()> { + let record = |key: &str, r#type: &str| CultCacheEnvelope { + key: key.into(), + r#type: r#type.into(), + payload: b"one".to_vec(), + stored_at: "2026-09-30T00:00:00Z".into(), + schema_id: Some("tests.x".into()), + }; + for order in [vec![record("a", "tests.n"), record("b", "tests.m")], vec![record("b", "tests.m"), record("a", "tests.n")]] { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + let mut store = SingleFileMessagePackBackingStore::new(&path); + let error = store.push_all(&order, PushAllOptions::default()).unwrap_err(); + let conflict = error.downcast_ref::().unwrap_or_else(|| panic!("{error:#}")); + assert_eq!(conflict.schema_id, "tests.x"); + let mut names = conflict.schema_names.clone(); + names.sort(); + assert_eq!(names, vec!["tests.m".to_string(), "tests.n".to_string()]); + assert!(conflict.record_key == "a" || conflict.record_key == "b"); + assert!(!path.exists(), "nothing is written"); + } + Ok(()) + } + + // Another runtime's catalog entry survives this runtime's writes: a write of any record lays back, byte for byte, the entry the + // file holds for an id this runtime does not own. This one carries what a C# writer publishes (a version, a content hash, + // canonical JSON, members), none of which this runtime could describe. Whole-store writes and single pushes alike. + #[test] + fn a_write_lays_back_the_catalog_entry_the_file_holds_for_an_id_it_does_not_own() -> Result<()> { + let held = PersistedSchemaCatalogEntry( + "sha256:csharp".into(), + "tests.note".into(), + "tests.note_v7".into(), + "sha256:content".into(), + "{\"schemaName\":\"tests.note\"}".into(), + vec!["sha256:csharp".into()], + vec![PersistedSchemaCatalogMember(0, "Title".into(), "System.String".into(), false, false, None, true, Some("title".into()))], + ); + let own = CultCacheEnvelope { + key: "b".into(), + r#type: "tests.other".into(), + payload: b"two".to_vec(), + stored_at: "2026-09-30T00:00:00Z".into(), + schema_id: Some("tests.other".into()), + }; + // An older entry that lists the id: the entry that owns the id is the one laid back. + let lister = PersistedSchemaCatalogEntry( + "sha256:older".into(), + "tests.note".into(), + "tests.note_v6".into(), + "sha256:older".into(), + String::new(), + vec!["sha256:older".into(), "sha256:csharp".into()], + Vec::new(), + ); + for whole_store in [false, true] { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + let record = PersistedRecord("a".into(), "sha256:csharp".into(), "2026-09-30T00:00:00Z".into(), b"one".to_vec()); + std::fs::write( + &path, + rmp_serde::to_vec(&PersistedStoreSnapshot("cultcache.store.v1".into(), vec![lister.clone(), held.clone()], vec![record]))?, + )?; + let mut store = SingleFileMessagePackBackingStore::new(&path); + if whole_store { + // As a Rust cache holds it after a pull: under the registered type that reads the schema by name. + let mut entries = store.pull_all()?; + for entry in &mut entries { + entry.r#type = "tests.rust_note".into(); + } + entries.push(own.clone()); + store.push_all(&entries, PushAllOptions::default())?; + } else { + store.push(&own)?; + } + + let written: PersistedStoreSnapshot = rmp_serde::from_slice(&std::fs::read(&path)?)?; + let entry = written.1.iter().find(|entry| entry.0 == "sha256:csharp").expect("the entry for the id is written"); + assert_eq!(rmp_serde::to_vec(entry)?, rmp_serde::to_vec(&held)?, "whole store: {whole_store}"); + assert!(written.1.iter().any(|entry| entry.0 == "tests.other"), "its own id is described"); + } + Ok(()) + } + // Store read and write faults: what a consumer can tell apart, and what a failed // write leaves on disk. @@ -5606,6 +6163,141 @@ mod tests { Ok(()) } + fn store_file(path: &std::path::Path, catalog: &[(&str, &str)], records: &[(&str, &str, Vec)]) -> Result<()> { + let snapshot = PersistedStoreSnapshot( + "cultcache.store.v1".to_string(), + catalog + .iter() + .map(|(id, name)| { + PersistedSchemaCatalogEntry( + id.to_string(), + name.to_string(), + format!("{name}.v1"), + id.to_string(), + String::new(), + vec![id.to_string()], + Vec::new(), + ) + }) + .collect(), + records + .iter() + .map(|(key, id, payload)| { + PersistedRecord(key.to_string(), id.to_string(), "2026-09-30T00:00:00Z".to_string(), payload.clone()) + }) + .collect(), + ); + std::fs::write(path, rmp_serde::to_vec(&snapshot)?)?; + Ok(()) + } + + // A schema renamed under a stable id: a registered type owns its id (its id is its entry type), so a write of it onto a file + // whose entry carries the old name is not a conflict. Every record under the id, the file's and the write's, is then described + // by the registered name. A write of another type under that id retypes what the file holds, and is refused. + #[test] + fn a_schema_renamed_under_a_stable_id_is_written_under_the_registered_name() -> Result<()> { + let envelope = |r#type: &str, schema_id: &str| CultCacheEnvelope { + key: "b".into(), + r#type: r#type.into(), + payload: b"two".to_vec(), + stored_at: "2026-09-30T00:00:00Z".into(), + schema_id: Some(schema_id.into()), + }; + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + store_file(&path, &[("t.s", "t.old")], &[("a", "t.s", b"one".to_vec())])?; + SingleFileMessagePackBackingStore::new(&path).push(&envelope("t.s", "t.s"))?; + + let mut envelopes = SingleFileMessagePackBackingStore::new(&path).pull_all()?; + envelopes.sort_by(|left, right| left.key.cmp(&right.key)); + assert_eq!( + envelopes.iter().map(|entry| (entry.key.as_str(), entry.r#type.as_str())).collect::>(), + vec![("a", "t.s"), ("b", "t.s")] + ); + + store_file(&path, &[("t.s", "t.old")], &[("a", "t.s", b"one".to_vec())])?; + let before = std::fs::read(&path)?; + let error = SingleFileMessagePackBackingStore::new(&path).push(&envelope("t.new", "t.s")).unwrap_err(); + let conflict = error.downcast_ref::().unwrap_or_else(|| panic!("{error:#}")); + assert_eq!(conflict.schema_id, "t.s"); + assert_eq!(conflict.record_key, "b"); + assert_eq!(std::fs::read(&path)?, before, "the file is left as it was"); + Ok(()) + } + + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] + #[cultcache(type = "tests.stable", schema = "tests.old_name")] + struct StableId { + #[cultcache(key = 0)] + name: String, + } + + #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] + #[cultcache(type = "tests.other", schema = "tests.other_name")] + struct OtherId { + #[cultcache(key = 0)] + name: String, + } + + // A record resolves to a registered type by its schema id, never by the name its catalog entry carries: a schema renamed under a + // stable id opens, and an id that names one type is not overruled by a name that names another. + #[test] + fn a_schema_renamed_under_a_stable_id_opens_by_its_id() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + store_file(&path, &[("tests.stable", "tests.renamed")], &[("a", "tests.stable", rmp_serde::to_vec(&StableId { name: "a".into() })?)])?; + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?; + cache.pull_all_backing_stores()?; + assert_eq!(cache.get_required::("a")?.name, "a"); + Ok(()) + } + + #[test] + fn a_schema_id_that_names_one_type_is_not_overruled_by_a_name_that_names_another() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + // The entry that publishes the id carries the other type's name. + store_file(&path, &[("tests.other", "tests.stable")], &[("k", "tests.other", rmp_serde::to_vec(&OtherId { name: "k".into() })?)])?; + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path))?; + cache.pull_all_backing_stores()?; + assert_eq!(cache.get_required::("k")?.name, "k"); + assert!(cache.get::("k")?.is_none()); + Ok(()) + } + + // A store cut short is refused whatever its version, and left as it is. + #[test] + fn a_truncated_v1_or_v2_store_is_refused_on_open_and_rewrite() -> Result<()> { + for vector in [ + "document-variants-c0/v1-base.msgpack", + "document-variants-c0/variant-v2.msgpack", + "document-variants-c1/variant-store.msgpack", + ] { + let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors").join(vector); + let whole = std::fs::read(source)?; + let cut = &whole[..whole.len() - 1]; + for operation in ["open", "push_all"] { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + std::fs::write(&path, cut)?; + let mut store = SingleFileMessagePackBackingStore::new(&path); + let outcome = match operation { + "open" => store.pull_all().map(|_| ()), + _ => store.push_all(&[snapshot_envelope("x", b"one")], PushAllOptions::default()), + }; + let error = outcome.expect_err(&format!("{vector} {operation}")); + assert!(unreadable_kind(&error).is_some(), "{vector} {operation}: {error:#}"); + assert_eq!(std::fs::read(&path)?, cut, "{vector} {operation} rewrote a truncated store"); + } + } + Ok(()) + } + #[test] fn refusals_say_what_was_found_without_blaming_variants() { let unsupported = format!("{:#}", pull_vector("unknown-header.msgpack").unwrap_err()); @@ -5621,11 +6313,10 @@ mod tests { assert!(!invalid.contains("runtime that"), "{invalid}"); } - /// The store path is a directory, so the staging file is written and synced and - /// then cannot be renamed over it: a failure after the write, reached without - /// permissions. + /// The store path is a directory, so the one reader cannot reach a store there: the write stops at the read, as an + /// I/O error and not a write failure, before any staging file exists. #[test] - fn a_write_that_fails_at_the_rename_leaves_no_staging_file() -> Result<()> { + fn a_write_over_a_directory_stops_at_the_read_and_leaves_no_staging_file() -> Result<()> { let temp = tempfile::tempdir()?; let path = temp.path().join("store.cc"); fs::create_dir(&path)?; @@ -5635,7 +6326,7 @@ mod tests { let error = store .push_all(&[snapshot_envelope("alpha", b"1")], PushAllOptions::default()) .unwrap_err(); - assert!(format!("{error:#}").contains("failed to atomically replace"), "{error:#}"); + assert!(io_error_in(&error).is_some() && write_failure_kind(&error).is_none(), "{error:#}"); let staging: Vec<_> = fs::read_dir(temp.path())? .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned())) @@ -5684,6 +6375,15 @@ mod tests { Ok(()) } + #[test] + fn a_rewrite_of_a_v1_store_stays_v1() -> Result<()> { + assert_eq!( + header_after_rewrite("document-variants-c0/v1-base.msgpack")?, + "cultcache.store.v1" + ); + Ok(()) + } + #[test] fn a_header_is_echoed_only_in_the_known_shape() -> Result<()> { let temp = tempfile::tempdir()?; @@ -5694,6 +6394,8 @@ mod tests { ("cultcache.store.SECRET-HEADER", false), ("cultcache.store.v12SECRET", false), ("cultcache.store.v", false), + // Digits are ASCII only: Arabic-Indic digits are not the known shape. + ("cultcache.store.v١٢", false), ] { let mut bytes = vec![0x93]; bytes.extend(fixstr(header)); @@ -5702,6 +6404,9 @@ mod tests { let error = store.pull_all().unwrap_err(); let text = format!("{error:#}"); assert_eq!(unreadable_kind(&error), Some(CultCacheStoreUnreadableKind::UnsupportedFormat), "{text}"); + // The refusal names every format this runtime reads. + assert!(text.contains("\"cultcache.store.v1\""), "{text}"); + assert!(text.contains("\"cultcache.store.v3\""), "{text}"); if echoed { assert!(text.contains(&format!("\"{header}\"")), "{text}"); } else { @@ -5730,6 +6435,21 @@ mod tests { assert!(io_error_in(&error).is_some(), "{error:#}"); assert_eq!(unreadable_kind(&error), None, "{error:#}"); } + // Every merging writer refuses on its own read, so none can replace the link. + let mut writer = SingleFileMessagePackBackingStore::new(&path); + let delta = snapshot_envelope("delta", b"4"); + let writes = [ + writer.push(&delta), + writer.delete(&delta), + writer.insert_entry_if_absent(delta.clone()).map(|_| ()), + writer.compare_and_swap_batch(&[], vec![delta.clone()]).map(|_| ()), + writer.append_if_snapshot_unchanged(&[], vec![delta.clone()]).map(|_| ()), + ]; + for (index, result) in writes.into_iter().enumerate() { + let error = result.expect_err("a merging write through a dangling link must refuse"); + assert!(io_error_in(&error).is_some(), "write {index}: {error:#}"); + assert_eq!(write_failure_kind(&error), None, "write {index}: {error:#}"); + } assert!(fs::symlink_metadata(&path)?.file_type().is_symlink()); assert!(!target.exists()); assert_eq!(fs::read_dir(&volume)?.count(), 0); @@ -5765,18 +6485,46 @@ mod tests { assert_eq!(fs::read(&path)?, before); assert_eq!(fs::read_dir(temp.path())?.count(), 2, "only the store and its lock remain"); - // The rename fails when the store path is a directory. + // A store path that is a directory is refused at the read: an I/O error, not a write failure. let blocked = temp.path().join("blocked.cc"); fs::create_dir(&blocked)?; let mut blocked_store = SingleFileMessagePackBackingStore::new(&blocked); let error = blocked_store .push_all(&[snapshot_envelope("alpha", b"1")], PushAllOptions::default()) .unwrap_err(); - assert_eq!(write_failure_kind(&error), Some(CultCacheStoreWriteFailedKind::NotReplaced), "{error:#}"); + assert!(io_error_in(&error).is_some() && write_failure_kind(&error).is_none(), "{error:#}"); assert!(fs::metadata(&blocked)?.is_dir()); Ok(()) } + #[test] + fn a_write_whose_rename_fails_is_not_replaced_and_leaves_no_staging_file() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.cc"); + let (mut store, before) = written_store(&path)?; + let replacement = [snapshot_envelope("delta", b"4")]; + + let error = with_write_fault(WriteStep::Rename, || { + store.compare_exchange_snapshot(&store.pull_all().unwrap(), &replacement) + }) + .expect_err("a rename that failed is not a write that landed"); + assert_eq!(write_failure_kind(&error), Some(CultCacheStoreWriteFailedKind::NotReplaced), "{error:#}"); + assert!(io_error_in(&error).is_some(), "{error:#}"); + assert_eq!(fs::read(&path)?, before, "the store is as it was"); + assert_eq!(fs::read_dir(temp.path())?.count(), 2, "only the store and its lock remain: no staging file"); + + let error = with_write_fault(WriteStep::Rename, || store.push(&replacement[0])) + .expect_err("a push whose rename failed is not a push that landed"); + assert_eq!(write_failure_kind(&error), Some(CultCacheStoreWriteFailedKind::NotReplaced), "{error:#}"); + assert_eq!(fs::read(&path)?, before); + assert_eq!(fs::read_dir(temp.path())?.count(), 2, "only the store and its lock remain: no staging file"); + + // The failure left nothing behind that stops the next write. + store.push(&replacement[0])?; + assert!(store.pull_all()?.contains(&replacement[0])); + Ok(()) + } + #[cfg(unix)] #[test] fn a_write_whose_directory_sync_fails_is_replaced_but_not_durable() -> Result<()> { @@ -5859,6 +6607,34 @@ mod tests { Ok(()) } + // The two cases the vectors cannot hold: nothing at the path is written, and a dangling link is refused and left as it is. + #[test] + fn a_soft_push_all_writes_where_nothing_is_and_refuses_a_dangling_link() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.cc"); + SingleFileMessagePackBackingStore::new(&path).push_all(&[snapshot_envelope("a", b"1")], PushAllOptions { soft: true })?; + let seeded = fs::read(&path)?; + let mut store = SingleFileMessagePackBackingStore::new(&path); + assert_eq!(store.pull_all()?.len(), 1); + store.push_all(&[snapshot_envelope("b", b"2")], PushAllOptions { soft: true })?; + assert_eq!(fs::read(&path)?, seeded, "a soft flush over a store it reads wrote"); + + #[cfg(unix)] + { + let volume = temp.path().join("volume"); + fs::create_dir(&volume)?; + let link = temp.path().join("dangling.cc"); + std::os::unix::fs::symlink(volume.join("store.cc"), &link)?; + let error = SingleFileMessagePackBackingStore::new(&link) + .push_all(&[snapshot_envelope("a", b"1")], PushAllOptions { soft: true }) + .unwrap_err(); + assert!(io_not_found(&error), "{error:#}"); + assert!(fs::symlink_metadata(&link)?.file_type().is_symlink(), "the link was replaced"); + assert_eq!(fs::read_dir(&volume)?.count(), 0, "nothing reached the link's volume"); + } + Ok(()) + } + #[cfg(unix)] #[test] fn r3_decides_resolve_or_refuse_a_push_all_through_a_dangling_link_replaces_the_link() -> Result<()> { @@ -5985,4 +6761,4 @@ mod tests { assert_eq!(fs::read(&path)?, before); Ok(()) } -} +} \ No newline at end of file diff --git a/packages/cultcache-rs/tests/first_call.rs b/packages/cultcache-rs/tests/first_call.rs new file mode 100644 index 00000000..6859ae07 --- /dev/null +++ b/packages/cultcache-rs/tests/first_call.rs @@ -0,0 +1,56 @@ +//! The Rust CultCache first call from docs/start/README.md. The docs show the region between the +//! `snippet` markers verbatim, and `docs_show_the_tested_snippet` fails when they differ. + +use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore}; + +// snippet:begin +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "item")] +struct Item { + #[cultcache(key = 0)] + name: String, + #[cultcache(key = 1)] + category: String, + #[cultcache(key = 2, default)] + value: u32, +} + +fn first_call() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("first-call.cc"); + + let mut cache = CultCache::new(); + cache.register_entry_type::()?; + cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)); + cache.pull_all_backing_stores()?; + cache.put("item:potion", &Item { + name: "Potion".to_string(), + category: "Consumable".to_string(), + value: 50, + })?; // written to the .cc file before this returns + + let mut reopened = CultCache::new(); + reopened.register_entry_type::()?; + reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)); + reopened.pull_all_backing_stores()?; // read it back + let potion = reopened.get_required::("item:potion")?; + assert_eq!(potion.name, "Potion"); + assert_eq!(potion.value, 50); + Ok(()) +} +// snippet:end + +#[test] +fn first_call_round_trips_through_a_second_cache() { + first_call().unwrap(); +} + +#[test] +fn docs_show_the_tested_snippet() { + let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/first_call.rs")).unwrap(); + let begin = source.find("// snippet:begin\n").unwrap() + "// snippet:begin\n".len(); + let end = source.find("// snippet:end\n").unwrap(); + let docs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/start/README.md")).unwrap(); + let block = format!("```rust\n{}```", &source[begin..end]); + assert!(docs.contains(&block), "docs/start/README.md no longer shows tests/first_call.rs verbatim"); +} diff --git a/packages/cultcache-rs/tests/lookups.rs b/packages/cultcache-rs/tests/lookups.rs new file mode 100644 index 00000000..ca5b459d --- /dev/null +++ b/packages/cultcache-rs/tests/lookups.rs @@ -0,0 +1,352 @@ +//! Lookups parity with the C# reference: `GetByName`, `GetByIndex` and `GetGlobal` +//! (`src/GameCult.Caching/CultCache.cs`) and the one-global rule its commit enforces. + +use cultcache_rs::{ + CacheBackingStore, CultCache, CultCacheSecondGlobal, CultCacheSoaTable, DatabaseEntry, + SingleFileMessagePackBackingStore, +}; + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "item")] +struct Item { + #[cultcache(key = 0, name)] + name: String, + #[cultcache(key = 1, index)] + category: String, + #[cultcache(key = 2, index = "sku")] + code: Option, + #[cultcache(key = 3, default)] + value: u32, +} + +fn item(name: &str, category: &str, code: Option<&str>) -> Item { + Item { name: name.into(), category: category.into(), code: code.map(Into::into), value: 0 } +} + +/// Same names and values as `Item`, a different type: lookups never cross types. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "gadget")] +struct Gadget { + #[cultcache(key = 0, name)] + name: String, + #[cultcache(key = 1, index)] + category: String, +} + +/// Sorts after `Item` and has its exact shape: a lookup that ran past the end of its own type would answer from it. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "zed")] +struct Zed { + #[cultcache(key = 0, name)] + name: String, + #[cultcache(key = 1, index)] + category: String, + #[cultcache(key = 2, index = "sku")] + code: Option, + #[cultcache(key = 3, default)] + value: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "settings", global)] +struct Settings { + #[cultcache(key = 0)] + value: String, +} + +fn settings(value: &str) -> Settings { + Settings { value: value.into() } +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "plain")] +struct Plain { + #[cultcache(key = 0)] + value: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "numbered")] +struct Numbered { + #[cultcache(key = 0, name)] + id: u32, + #[cultcache(key = 1, index)] + owner: u64, +} + +fn cache() -> CultCache { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache +} + +fn reopened(path: &std::path::Path) -> CultCache { + let mut reopened = cache(); + reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap(); + reopened.pull_all_backing_stores().unwrap(); + reopened +} + +fn stored_cache(path: &std::path::Path) -> CultCache { + let mut stored = cache(); + stored.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap(); + stored.pull_all_backing_stores().unwrap(); + stored +} + +// The C# FirstCall snippet: a record written to a .cc file is found by its [CultName] through a second cache. +#[test] +fn a_record_is_found_by_name_and_by_index_after_a_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lookups.cc"); + let mut written = stored_cache(&path); + written.put("item:potion", &Item { value: 50, ..item("Potion", "Consumable", Some("P-1")) }).unwrap(); + + let reopened = reopened(&path); + let by_name = reopened.get_by_name::("Potion").unwrap().unwrap(); + assert_eq!(by_name.value, 50); + assert_eq!(reopened.get_by_index::("category", "Consumable").unwrap(), Some(by_name.clone())); + assert_eq!(reopened.get_by_index::("sku", "P-1").unwrap(), Some(by_name)); +} + +#[test] +fn a_missing_name_or_value_is_none_and_an_unregistered_type_is_refused() { + let mut cache = cache(); + cache.put("item:a", &item("A", "cat", Some("a-1"))).unwrap(); + + assert_eq!(cache.get_by_name::("B").unwrap(), None); + assert_eq!(cache.get_by_name::("a").unwrap(), None, "names compare ordinally"); + assert_eq!(cache.get_by_index::("category", "other").unwrap(), None); + assert_eq!(cache.get_by_index::("nosuchalias", "cat").unwrap(), None); + assert_eq!(cache.get_by_index::("code", "a-1").unwrap(), None, "the alias is sku, not the member name"); + + let bare = CultCache::new(); + assert!(bare.get_by_name::("A").is_err()); + assert!(bare.get_by_index::("category", "cat").is_err()); + assert!(bare.get_global::().is_err()); +} + +#[test] +fn an_index_without_an_alias_is_named_by_its_member() { + let mut cache = cache(); + cache.put("item:a", &item("A", "cat", None)).unwrap(); + assert!(cache.get_by_index::("category", "cat").unwrap().is_some()); +} + +// A blank name is no name; a blank index value is not indexed; a nil member holds no value. +#[test] +fn blank_and_nil_values_are_not_found() { + let mut cache = cache(); + cache.put("item:blank", &item("", " ", None)).unwrap(); + assert_eq!(cache.get_by_name::("").unwrap(), None); + assert_eq!(cache.get_by_index::("category", " ").unwrap(), None); + assert_eq!(cache.get_by_index::("category", "").unwrap(), None); + assert_eq!(cache.get_by_index::("sku", "").unwrap(), None); + + // Only emptiness voids a name: whitespace is a name, as in C# (Length > 0). + cache.put("item:space", &item(" ", "x", None)).unwrap(); + assert_eq!(cache.get_by_name::(" ").unwrap().unwrap().category, "x"); +} + +#[test] +fn lookups_never_cross_types() { + let mut cache = cache(); + cache.put("item:a", &item("same", "same", None)).unwrap(); + cache.put("gadget:a", &Gadget { name: "same".into(), category: "same".into() }).unwrap(); + cache.put("zed:a", &Zed { name: "same".into(), category: "same".into(), code: None, value: 0 }).unwrap(); + + assert_eq!(cache.get_by_name::("same").unwrap().unwrap().name, "same"); + cache.delete::("item:a").unwrap(); + assert_eq!(cache.get_by_name::("same").unwrap(), None); + assert_eq!(cache.get_by_index::("category", "same").unwrap(), None); + assert!(cache.get_by_name::("same").unwrap().is_some()); + assert!(cache.get_by_index::("category", "same").unwrap().is_some()); +} + +#[test] +fn a_name_and_index_follow_a_rewritten_record_and_leave_a_removed_one() { + let mut cache = cache(); + cache.put("item:a", &item("old", "c1", None)).unwrap(); + cache.put("item:a", &item("new", "c2", None)).unwrap(); + + assert_eq!(cache.get_by_name::("old").unwrap(), None, "a stale name is not found"); + assert_eq!(cache.get_by_index::("category", "c1").unwrap(), None, "a stale value is not found"); + assert!(cache.get_by_name::("new").unwrap().is_some()); + assert!(cache.get_by_index::("category", "c2").unwrap().is_some()); + + cache.delete::("item:a").unwrap(); + assert_eq!(cache.get_by_name::("new").unwrap(), None); + assert_eq!(cache.get_by_index::("category", "c2").unwrap(), None); +} + +#[test] +fn non_string_members_are_found_by_their_text() { + let mut cache = cache(); + let owner = 161_u64; + cache.put("numbered:1", &Numbered { id: 42, owner }).unwrap(); + assert!(cache.get_by_name::("42").unwrap().is_some()); + assert!(cache.get_by_index::("owner", "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::("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::("category", "x").unwrap().unwrap().name, "p1"); + + cache.delete::("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:p2").unwrap(); + assert_eq!(found(&cache).as_deref(), Some("p1"), "removing the other changes nothing"); + cache.delete::("item:p1").unwrap(); + assert_eq!(found(&cache), None); + } + assert_eq!(reopened(&path).get_by_index::("category", "x").unwrap(), None); +} + +#[test] +fn records_sharing_a_name_find_the_smallest_key() { + let mut cache = cache(); + cache.put("item:b", &Item { value: 2, ..item("twin", "c", None) }).unwrap(); + cache.put("item:a", &Item { value: 1, ..item("twin", "c", None) }).unwrap(); + assert_eq!(cache.get_by_name::("twin").unwrap().unwrap().value, 1); + cache.delete::("item:a").unwrap(); + assert_eq!(cache.get_by_name::("twin").unwrap().unwrap().value, 2); +} + +// BackingStoreTests.HydratesPersistedGlobal: opening a store invents no global; a written one survives a reopen. +#[test] +fn a_global_is_none_until_written_and_survives_a_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("global.cc"); + { + let mut seed = stored_cache(&path); + assert_eq!(seed.get_global::().unwrap(), None, "opening a store invented a global"); + seed.put("global:settings", &settings("durable authority")).unwrap(); + assert_eq!(seed.get_global::().unwrap(), Some(settings("durable authority"))); + } + assert_eq!(reopened(&path).get_global::().unwrap(), Some(settings("durable authority"))); +} + +#[test] +fn a_type_that_is_not_a_global_has_no_global() { + let mut cache = cache(); + cache.put("plain:one", &Plain { value: "v".into() }).unwrap(); + assert_eq!(cache.get_global::().unwrap(), None); +} + +#[test] +fn a_second_global_under_another_key_is_refused_and_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("second.cc"); + let mut cache = stored_cache(&path); + cache.put("g1", &settings("one")).unwrap(); + + let refusal = cache.put("g2", &settings("two")).unwrap_err(); + let second = refusal.downcast_ref::().expect("a typed refusal"); + assert_eq!((second.existing_key.as_str(), second.key.as_str()), ("g1", "g2")); + assert!(refusal.to_string().contains("is a global and already has record g1; g2 would be a second.")); + assert_eq!(cache.get_global::().unwrap(), Some(settings("one"))); + assert_eq!(reopened(&path).get_global::().unwrap(), Some(settings("one")), "nothing reached the store"); + + // The same key is the same record, not a second. + cache.put("g1", &settings("one again")).unwrap(); + assert_eq!(cache.get_global::().unwrap(), Some(settings("one again"))); +} + +// RetypeEvictionTests.EvictingAGlobalFreesItsSlot: removing the global frees the slot for another key. +#[test] +fn removing_a_global_frees_its_slot() { + let mut cache = cache(); + cache.put("g1", &settings("one")).unwrap(); + assert!(cache.put("g2", &settings("two")).is_err()); + cache.delete::("g1").unwrap(); + assert_eq!(cache.get_global::().unwrap(), None); + cache.put("g2", &settings("two")).unwrap(); + assert_eq!(cache.get_global::().unwrap(), Some(settings("two"))); +} + +#[test] +fn every_write_path_refuses_a_second_global() { + let mut cache = cache(); + cache.put("g1", &settings("one")).unwrap(); + let (second, _) = cache.prepare_entry("g2", &settings("two")).unwrap(); + + assert!(cache.put_envelope::(second.clone()).unwrap_err().is::()); + assert!(cache.put_raw_envelope(second.clone()).unwrap_err().is::()); + let (plain, _) = cache.prepare_entry("plain:1", &Plain { value: "v".into() }).unwrap(); + assert!(cache.put_prepared_batch(vec![plain, second]).unwrap_err().is::()); + assert_eq!(cache.get::("plain:1").unwrap(), None, "a refused batch lands none of it"); + assert_eq!(cache.get_global::().unwrap(), Some(settings("one"))); +} + +#[test] +fn a_batch_that_stages_two_globals_is_refused_whole() { + let mut cache = cache(); + let (one, _) = cache.prepare_entry("g1", &settings("one")).unwrap(); + let (two, _) = cache.prepare_entry("g2", &settings("two")).unwrap(); + assert!(cache.put_prepared_batch(vec![one, two]).unwrap_err().is::()); + assert_eq!(cache.get_global::().unwrap(), None); +} + +// StoreRoutingTests: a store holding two globals is refused on load rather than answering one of them. +#[test] +fn loading_a_store_that_holds_two_globals_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("two.cc"); + let source = cache(); + let mut store = SingleFileMessagePackBackingStore::new(&path); + for (key, value) in [("g1", "one"), ("g2", "two")] { + let (entry, _) = source.prepare_entry(key, &settings(value)).unwrap(); + store.push(&entry).unwrap(); + } + + let mut loading = cache(); + loading.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); + assert!(loading.pull_all_backing_stores().unwrap_err().is::()); + assert_eq!(loading.get_global::().unwrap(), None, "a refused load installs nothing"); +} + +// load_soa installs a whole table at once, so the one-global rule has to hold there too: a table holding two +// records of a global type is refused whole and the prior view survives. +#[test] +fn loading_an_soa_table_that_holds_two_globals_is_refused() { + let source = cache(); + let (first, _) = source.prepare_entry("g1", &settings("one")).unwrap(); + let (second, _) = source.prepare_entry("g2", &settings("two")).unwrap(); + + let mut loading = cache(); + loading.put("g0", &settings("prior")).unwrap(); + loading.put("plain:1", &Plain { value: "prior".into() }).unwrap(); + + let refusal = loading + .load_soa(CultCacheSoaTable::from_envelopes(vec![first.clone(), second])) + .unwrap_err(); + let second_global = refusal.downcast_ref::().expect("a typed refusal"); + let mut named = [second_global.existing_key.as_str(), second_global.key.as_str()]; + named.sort(); + assert_eq!(named, ["g1", "g2"], "the refusal names both records"); + assert_eq!(loading.get_global::().unwrap(), Some(settings("prior")), "a refused load keeps the prior view"); + assert_eq!(loading.get::("plain:1").unwrap(), Some(Plain { value: "prior".into() })); + + // One global in the table is the rule's own case, not a second. + loading.load_soa(CultCacheSoaTable::from_envelopes(vec![first])).unwrap(); + assert_eq!(loading.get_global::().unwrap(), Some(settings("one"))); + assert_eq!(loading.get::("plain:1").unwrap(), None, "a load replaces the view"); +} diff --git a/packages/cultcache-rs/tests/union_parity.rs b/packages/cultcache-rs/tests/union_parity.rs index 6b120791..24fe999a 100644 --- a/packages/cultcache-rs/tests/union_parity.rs +++ b/packages/cultcache-rs/tests/union_parity.rs @@ -32,6 +32,8 @@ enum Node { struct WrapArm { #[cultcache(key = 0)] inner: Option>, + #[cultcache(key = 1)] + id: String, } #[derive(Clone, Debug, PartialEq, CultSlots)] @@ -85,6 +87,17 @@ enum Outcome { 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)] @@ -97,12 +110,23 @@ enum Outcome { 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)] @@ -178,26 +202,26 @@ fn boxed(id: &str, children: Vec) -> Node { } fn constructed() -> UnionSurface { - let inner = boxed("inner", vec![text("deep", "x"), image("img", "uri://a", "alt")]); + let inner = boxed("0000000000d0", vec![text("0000000000d1", "x"), image("0000000000d2", "uri://a", "alt")]); UnionSurface { id: "surface".into(), version: 3, root: Some(boxed( - "root", + "0000000000c0", vec![ - text("t1", "hello"), + text("0000000000c1", "hello"), Node::Use(UseArm { - id: "u1".into(), + id: "0000000000c2".into(), composition: Composition { name: "comp".into(), body: Some(std::boxed::Box::new(inner)) }, - slots: vec![Some(text("s1", "slot")), None, Some(boxed("b2", vec![]))], + slots: vec![Some(text("0000000000e1", "slot")), None, Some(boxed("0000000000e2", vec![]))], }), - image("i2", "uri://b", ""), + image("0000000000c3", "uri://b", ""), ], )), outcomes: vec![ - Outcome::Pending(PendingArm { message: "wait".into() }), - Outcome::Completed(CompletedArm { source_version: 7 }), - Outcome::Denied, + 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 })), @@ -205,6 +229,10 @@ fn constructed() -> UnionSurface { } } +fn id_arm(id: &str) -> IdArm { + IdArm { id: id.into() } +} + fn vector(name: &str) -> Vec { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../tests/vectors/union-parity") @@ -226,8 +254,25 @@ fn union_surface_decodes_and_reencodes() { #[test] fn a_unit_arm_is_a_key_and_an_empty_slots_array() { - assert_eq!(rmp_serde::to_vec(&Outcome::Denied).unwrap(), [0x92, 0x02, 0x90]); - assert_eq!(rmp_serde::from_slice::(&[0x92, 0x02, 0x90]).unwrap(), Outcome::Denied); + // The bytes are the C# reference's, not spelled here. + for (file, arm) in [ + ("unit-arm-denied.msgpack", UnitOutcome::Denied), + ("unit-arm-floor.msgpack", UnitOutcome::Floor), + ("unit-arm-ceiling.msgpack", UnitOutcome::Ceiling), + ] { + let reference = vector(file); + assert_eq!(rmp_serde::to_vec(&arm).unwrap(), reference, "{file}"); + assert_eq!(rmp_serde::from_slice::(&reference).unwrap(), arm, "{file}"); + } +} + +#[test] +fn an_id_only_arm_is_a_key_and_a_one_slot_array() { + let mut bytes = vec![0x92, 0x02, 0x91, 0xac]; + bytes.extend_from_slice(b"0000000000a3"); + let denied = Outcome::Denied(id_arm("0000000000a3")); + assert_eq!(rmp_serde::to_vec(&denied).unwrap(), bytes); + assert_eq!(rmp_serde::from_slice::(&bytes).unwrap(), denied); } #[test] @@ -246,7 +291,7 @@ fn union_refusal_vectors() { let key = key.expect(&file); assert!(message.contains(&format!("unknown arm key {key} ")), "{file}: {message}"); } - for canary in ["root", "hello", "uri://", "surface"] { + for canary in ["0000000000c0", "hello", "uri://", "surface"] { assert!(!message.contains(canary), "{file} echoed a value: {message}"); } } @@ -287,8 +332,8 @@ fn a_union_that_is_not_a_key_and_slots_pair_is_refused_naming_the_enum() { ("a unit arm with slots", rmp_serde::to_vec(&(2u64, ("CANARY-slot",))).unwrap()), ]; for (label, bytes) in cases { - let message = rmp_serde::from_slice::(&bytes).unwrap_err().to_string(); - assert!(message.contains("Outcome"), "{label}: {message}"); + let message = rmp_serde::from_slice::(&bytes).unwrap_err().to_string(); + assert!(message.contains("UnitOutcome"), "{label}: {message}"); assert!(!message.contains("CANARY"), "{label}: {message}"); } } @@ -679,9 +724,9 @@ fn bounds_vectors() { let value = result.unwrap_or_else(|message| panic!("{file}: {message}")); assert_eq!(rmp_serde::to_vec(&value).unwrap(), vector(detail), "{file}"); match file { - "arm-key-int32-min.msgpack" => assert_eq!(value.outcomes.last(), Some(&Outcome::Floor)), + "arm-key-int32-min.msgpack" => assert_eq!(value.outcomes.last(), Some(&Outcome::Floor(id_arm("0000000000a4")))), "arm-key-int32-max.msgpack" | "arm-key-wide-coding.msgpack" => { - assert_eq!(value.outcomes.last(), Some(&Outcome::Ceiling)) + assert_eq!(value.outcomes.last(), Some(&Outcome::Ceiling(id_arm("0000000000a5")))) } _ => {} } @@ -715,7 +760,7 @@ fn bounds_vectors() { fn wrap_chain(wraps: usize, leaf: Node) -> UnionSurface { let mut node = leaf; for _ in 0..wraps { - node = Node::Wrap(WrapArm { inner: Some(std::boxed::Box::new(node)) }); + node = Node::Wrap(WrapArm { inner: Some(std::boxed::Box::new(node)), id: "0000000000b1".into() }); } UnionSurface { root: Some(node), ..constructed() } } @@ -724,9 +769,9 @@ fn wrap_chain(wraps: usize, leaf: Node) -> UnionSurface { // children) ends at 2 * (wraps + 1) + 2 and a Text leaf at 2 * (wraps + 1) + 1. #[test] fn the_constructed_chains_write_the_vector_bytes() { - let at_limit = wrap_chain(126, boxed("leaf", vec![])); + let at_limit = wrap_chain(126, boxed("0000000000f0", vec![])); assert_eq!(rmp_serde::to_vec(&at_limit).unwrap(), vector("depth-at-limit.msgpack")); - let past_limit = wrap_chain(127, text("leaf", CANARY)); + let past_limit = wrap_chain(127, text("0000000000f0", CANARY)); assert_eq!(rmp_serde::to_vec(&past_limit).unwrap(), vector("depth-past-limit.msgpack")); } @@ -744,14 +789,14 @@ fn write_refused_past_limit() { cache.register_entry_type::().unwrap(); cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); - let at_limit = wrap_chain(126, boxed("leaf", vec![])); + let at_limit = wrap_chain(126, boxed("0000000000f0", vec![])); assert_eq!(cache.put("at", &at_limit).unwrap(), at_limit); let stored = cache.snapshot_envelopes(); assert_eq!(stored.len(), 1); assert_eq!(stored[0].payload, vector("depth-at-limit.msgpack")); let file_before = std::fs::read(&path).unwrap(); - let error = cache.put("past", &wrap_chain(127, text("leaf", CANARY))).unwrap_err(); + let error = cache.put("past", &wrap_chain(127, text("0000000000f0", CANARY))).unwrap_err(); assert_eq!(error.downcast_ref::(), Some(&PayloadTooDeep { depth: 257, limit: 256 })); assert!(!format!("{error:#}").contains(CANARY), "{error:#}"); assert_eq!(cache.get::("past").unwrap(), None); @@ -808,8 +853,8 @@ fn raw(type_name: &str, key: &str, payload: Vec) -> CultCacheEnvelope { #[test] fn the_raw_doors_hold_the_bound_for_arms_maps_and_arrays() { - let arms_at = rmp_serde::to_vec(&wrap_chain(126, boxed("leaf", vec![]))).unwrap(); - let arms_past = rmp_serde::to_vec(&wrap_chain(127, text("leaf", CANARY))).unwrap(); + let arms_at = rmp_serde::to_vec(&wrap_chain(126, boxed("0000000000f0", vec![]))).unwrap(); + let arms_past = rmp_serde::to_vec(&wrap_chain(127, text("0000000000f0", CANARY))).unwrap(); let maps_at = rmp_serde::to_vec_named(&map_doc(MAX_PAYLOAD_DEPTH - 1)).unwrap(); let maps_past = rmp_serde::to_vec_named(&map_doc(MAX_PAYLOAD_DEPTH)).unwrap(); let arrays_at = vector("depth-at-limit.msgpack"); @@ -857,13 +902,13 @@ fn the_union_chain_at_the_limit_decodes_on_a_1mib_thread() { .spawn(|| { assert_eq!( through_cache(&vector("depth-at-limit.msgpack")).unwrap(), - wrap_chain(126, boxed("leaf", vec![])) + wrap_chain(126, boxed("0000000000f0", vec![])) ); let refused = through_cache(&vector("depth-past-limit.msgpack")).unwrap_err(); assert!(refused.contains("the limit is 256"), "{refused}"); let mut cache = CultCache::new(); cache.register_entry_type::().unwrap(); - let value = wrap_chain(126, boxed("leaf", vec![])); + let value = wrap_chain(126, boxed("0000000000f0", vec![])); assert_eq!(cache.put("at", &value).unwrap(), value); assert_eq!(cache.get::("at").unwrap(), Some(value)); let mut deep = vec![0x91u8; 100_000]; diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 5c7a1561..819df6c4 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -3,9 +3,26 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [0.14.2] +## [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. + +### 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 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`. +- 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. ### Added + +- `@gamecult/cultcache-ts/browser`, also what a bundler resolves for the package root under the `browser` export condition: the root surface without `SingleFileMessagePackBackingStore` and `readSingleFileStore`, so it imports no `node:*` built-in and no `koffi` and bundles with esbuild `platform: "browser"`. The Node entry exports exactly what it did. +- `readSingleFileStore(path)`, the one synchronous reader of a single-file store, for consumers that must read a store file rather than decode it themselves. `pullAll`, `push`, `delete` and `pushAll` ask the same reader. +- `StoreUnreadableError` for a store file this runtime cannot read, `SchemaConflictError` for a write the catalog cannot describe, and `schemaIdentityOf` with its result type `CultCacheSchemaIdentity` (`schemaId`, `schemaName`, `schemaVersion`, `compatibleSchemaIds`), the one derivation of a definition's identity. +- The document payload codec, exported from `@gamecult/cultcache-ts/document`: `decodeDocumentPayload`, `encodeDocumentPayload`, and the two named byte escapes `documentPayloadFromBytes` and `documentPayloadBytes`. +- `SingleFileStoreRead`, exported: the result type of `readSingleFileStore` (`absent`, `format`, `envelopes`, `repairedLegacyPayload`). +- `CultCacheInspection.format` is `StoreFormat | "legacy.envelope-array"`: it reports `cultcache.store.v3` for a v3 store. +- A soft `pushAll` writes only where nothing is at the path, leaves a store it can read byte for byte, and refuses what the reader refuses. - `SingleFileMessagePackBackingStore` takes a cross-process write lock on `.lock`, interoperable with the C# and Rust runtimes (`flock` on Unix, `LockFileEx` on Windows). `push` and `delete` read the current file, merge and replace it under the lock, so concurrent @@ -15,8 +32,12 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following is, but C# writers can still overwrite records a concurrent TypeScript writer added. - The staged file is fsynced before the replace, and on Unix the directory after it. A directory sync failure after the replace is reported as replaced but not durable. +- `checkPayloadDepth` is re-exported from `@gamecult/cultcache-ts/document`, so dependents + (`cultnet-ts/content`) bound payload nesting with the same check the cache + applies on decode. ### Changed + - 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 apart on errors 5, 32 and 33. Writers wait up to 30 s for the lock. @@ -24,11 +45,5 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following under the lock. ### Dependencies + - `koffi` 3.3.0, loaded only on the first write. - -## [0.14.1] - -### Added -- `checkPayloadDepth` is re-exported from `@gamecult/cultcache-ts/document`, so dependents - (`cultnet-ts/content`) bound payload nesting with the same check the cache - applies on decode. diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index 192d4b5d..b3a85345 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -61,7 +61,7 @@ const note = decodeDocumentPayload(noteDocument, envelope.payload); // parsed No const payload = encodeDocumentPayload(noteDocument, note); // DocumentPayload ``` -Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins. +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`. ## `.cc` Inspection diff --git a/packages/cultcache-ts/package.json b/packages/cultcache-ts/package.json index c48e54e5..b6803979 100644 --- a/packages/cultcache-ts/package.json +++ b/packages/cultcache-ts/package.json @@ -1,15 +1,21 @@ { "name": "@gamecult/cultcache-ts", - "version": "0.14.2", + "version": "0.15.0", "description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", + "browser": "./dist/browser.js", "require": "./dist/index.js", "default": "./dist/index.js" }, + "./browser": { + "types": "./dist/browser.d.ts", + "require": "./dist/browser.js", + "default": "./dist/browser.js" + }, "./document": { "types": "./dist/document.d.ts", "require": "./dist/document.js", @@ -25,6 +31,9 @@ "*": { "document": [ "dist/document.d.ts" + ], + "browser": [ + "dist/browser.d.ts" ] } }, diff --git a/packages/cultcache-ts/src/browser.ts b/packages/cultcache-ts/src/browser.ts new file mode 100644 index 00000000..bf62976f --- /dev/null +++ b/packages/cultcache-ts/src/browser.ts @@ -0,0 +1,49 @@ +// Browser-safe surface: everything the package root exports except the node-only single-file store +// (node:fs, koffi). Bring your own CacheBackingStore, or none. +export * as codecs from "./codec"; +export type { Codec, SlotsCodec } from "./codec"; +export { CultCache, CultCacheBuilder } from "./cult-cache"; +export { inspectCultCacheBytes } from "./cult-cache-inspector"; +export { + decodeDocumentPayload, + defineDocumentRegistry, + defineDocumentType, + documentPayloadBytes, + documentPayloadFromBytes, + encodeDocumentPayload, + schemaIdentityOf, + type CultCacheSchemaIdentity, +} from "./document"; +export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth"; +export type { DocumentPayload } from "./document"; +export { SchemaConflictError, StoreUnreadableError } from "./store-format"; +export type { + AnyCultCacheDocumentDefinition, + CacheBackingStore, + CultCacheDocumentAccessor, + CultCacheDocumentDefinition, + CultCacheDocumentFieldName, + CultCacheDocumentFormatter, + CultCacheDocumentIndexDefinition, + CultCacheDocumentRegistry, + CultCacheSchemaCatalogEntry, + CultCacheSchemaCatalogMember, + CultCacheSchema, + CultCacheDocumentValue, + CultCacheEnvelope, + CultCacheIndexScalar, + InferCultCacheSchemaValue, + CultCacheStoreRegistration, + PushAllOptions, +} from "./types"; +export type { + CultCacheInspection, + InspectedCatalogEntry, + InspectedCatalogMember, + InspectedRecord, +} from "./cult-cache-inspector"; +export { + defineSwarmDocuments, + type DefineDocumentType, + type SwarmDocumentCatalog, +} from "./swarm-documents"; diff --git a/packages/cultcache-ts/src/cult-cache-inspector.ts b/packages/cultcache-ts/src/cult-cache-inspector.ts index 56a0f6e3..1c359157 100644 --- a/packages/cultcache-ts/src/cult-cache-inspector.ts +++ b/packages/cultcache-ts/src/cult-cache-inspector.ts @@ -1,12 +1,12 @@ import { decode } from "@msgpack/msgpack"; import { checkPayloadDepth } from "./payload-depth"; -import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format"; +import { type StoreFormat, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots } from "./store-format"; export interface CultCacheInspection { filePath: string; fileSizeBytes: number; - format: "cultcache.store.v1" | "legacy.envelope-array"; + format: StoreFormat | "legacy.envelope-array"; catalog: InspectedCatalogEntry[]; records: InspectedRecord[]; } @@ -47,9 +47,10 @@ export function inspectCultCacheBytes( bytes: Uint8Array, fileSizeBytes = bytes.length, ): CultCacheInspection { + refuseUnreadStoreBytes(bytes); const decoded = decode(bytes); - if (isV1Snapshot(decoded)) { + if (isStoreSnapshot(decoded)) { return inspectV1Snapshot(filePath, fileSizeBytes, decoded); } @@ -60,7 +61,8 @@ export function inspectCultCacheBytes( throw new Error("CultCache file is not a recognized v1 snapshot or legacy envelope array."); } -function inspectV1Snapshot(filePath: string, fileSizeBytes: number, decoded: unknown[]): CultCacheInspection { +function inspectV1Snapshot(filePath: string, fileSizeBytes: number, decoded: [StoreFormat, ...unknown[]]): CultCacheInspection { + requireStoreSlots(decoded); const catalogRaw = decoded[1]; const recordsRaw = decoded[2]; if (!Array.isArray(catalogRaw) || !Array.isArray(recordsRaw)) { @@ -68,36 +70,25 @@ function inspectV1Snapshot(filePath: string, fileSizeBytes: number, decoded: unk } const catalog = catalogRaw.map(decodeCatalogEntry); + // An id names the entry that owns it; only an id no entry owns names the entry that lists it as compatible. const catalogBySchemaId = new Map(); for (const entry of catalog) { - catalogBySchemaId.set(entry.schemaId, entry); - for (const compatibleSchemaId of entry.compatibleSchemaIds) { - catalogBySchemaId.set(compatibleSchemaId, entry); + if (!catalogBySchemaId.has(entry.schemaId)) { + catalogBySchemaId.set(entry.schemaId, entry); } } - const records = recordsRaw.map((record) => decodeV1Record(record, catalogBySchemaId)); - for (const record of records) { - if (!catalog.some((entry) => entry.schemaId === record.schemaId)) { - const schemaVersion = inferSchemaVersionFromPayloadPreview(record.payloadPreview); - const schemaName = schemaVersion ? inferSchemaName(schemaVersion) : undefined; - if (schemaName) { - catalog.push({ - schemaId: record.schemaId, - schemaName, - schemaVersion: schemaVersion!, - contentHash: record.schemaId, - canonicalSchemaJson: "", - compatibleSchemaIds: [], - members: [], - }); + for (const entry of catalog) { + for (const compatibleSchemaId of entry.compatibleSchemaIds) { + if (!catalogBySchemaId.has(compatibleSchemaId)) { + catalogBySchemaId.set(compatibleSchemaId, entry); } } } - + const records = recordsRaw.map((record) => decodeV1Record(record, catalogBySchemaId)); return { filePath, fileSizeBytes, - format: STORE_FORMAT_VERSION, + format: decoded[0], catalog, records, }; @@ -205,64 +196,21 @@ function decodeV1Record(value: unknown, catalogBySchemaId: Map", + schemaName: catalogEntry.schemaName, storedAt, payloadBytes: payloadBytes.length, ...previewPayload(payloadBytes), }; } -function recoverCatalogEntry(schemaId: string, payload: Uint8Array): InspectedCatalogEntry | undefined { - let payloadPreview: unknown; - try { - payloadPreview = decodeBoundedPayload(payload); - } catch { - return undefined; - } - - const schemaVersion = inferSchemaVersionFromPayloadPreview(payloadPreview); - if (!schemaVersion) { - return undefined; - } - - const schemaName = inferSchemaName(schemaVersion); - if (!schemaName) { - return undefined; - } - - return { - schemaId, - schemaName, - schemaVersion, - contentHash: schemaId, - canonicalSchemaJson: "", - compatibleSchemaIds: [], - members: [], - }; -} - -function inferSchemaVersionFromPayloadPreview(payloadPreview: unknown): string | undefined { - if (Array.isArray(payloadPreview) && typeof payloadPreview[0] === "string") { - return payloadPreview[0]; - } - - if (isRecord(payloadPreview)) { - const schemaVersion = payloadPreview.schemaVersion ?? payloadPreview.schema_version; - return typeof schemaVersion === "string" ? schemaVersion : undefined; - } - - return undefined; -} - -function inferSchemaName(schemaVersion: string): string | undefined { - const match = /^(.*)\.v\d+$/.exec(schemaVersion); - return match?.[1]; -} - function decodeLegacyEnvelope(value: unknown): InspectedRecord { if (!isRecord(value)) { throw new Error("CultCache legacy envelopes must be MessagePack maps."); diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index 2f2fb063..88ab94e9 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -4,9 +4,11 @@ import { documentPayloadFromBytes, encodeDocumentPayload, resolveDocumentMembers, + schemaIdentityOf, } from "./document"; import { resolveDocumentFormatter } from "./document-formatter"; import type { DocumentPayload } from "./document"; +import { SchemaConflictError } from "./store-format"; import type { AnyCultCacheDocumentDefinition, CacheBackingStore, @@ -161,7 +163,9 @@ export class CultCache { } readonly #definitions = new Map(); + // The definition that owns each schema id, and every definition listing an id as compatible, in registration order. readonly #schemaIdDefinitions = new Map(); + readonly #listingDefinitions = new Map(); readonly #schemaNameDefinitions = new Map(); #hydrated = emptyHydratedState(); readonly #stores: CultCacheStoreRegistration[] = []; @@ -474,8 +478,10 @@ export class CultCache { type: envelope.type, payload, storedAt: envelope.storedAt, - schemaId: envelope.schemaId ?? registered.catalogEntry.schemaId, - catalogEntry: envelope.catalogEntry ?? registered.catalogEntry, + // The record is stamped with its registered schema's id, whatever id the envelope arrived under, so the + // catalog entry the store writes is the one its schema id names. + schemaId: registered.catalogEntry.schemaId, + catalogEntry: registered.catalogEntry, }; await this.#writeNow(registered, entry, parsed); return parsed; @@ -535,9 +541,13 @@ export class CultCache { } #registerNow(definition: AnyCultCacheDocumentDefinition): void { + // Every registration refusal is a SchemaConflictError naming the new definition's schema id and both schema names. + const refuse = (holder: RegisteredDefinition, claimed: CultCacheSchemaCatalogEntry, what: string) => + new SchemaConflictError(claimed.schemaId, [holder.catalogEntry.schemaName, claimed.schemaName], "", + `CultCache ${what} is already registered for type "${holder.definition.type}" and cannot also be claimed by type "${definition.type}".`); const existing = this.#definitions.get(definition.type); if (existing && existing.definition !== definition) { - throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`); + throw refuse(existing, this.#createCatalogEntry(definition), `type "${definition.type}"`); } const registered: RegisteredDefinition = existing ?? { @@ -557,25 +567,28 @@ export class CultCache { indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor)); } - const schemaIds = registered.catalogEntry.compatibleSchemaIds ?? [registered.catalogEntry.schemaId]; - for (const schemaId of schemaIds) { - const existingBySchema = this.#schemaIdDefinitions.get(schemaId); - if (existingBySchema && existingBySchema !== registered) { - throw new Error(`CultCache schema id "${schemaId}" is already registered for type "${existingBySchema.definition.type}".`); - } + // One definition owns each schema id. Others may list it as compatible beside its owner: a record under the id resolves to + // its owner, and to a lister only when nothing owns it. Neither depends on the order definitions were registered in. + const ownId = registered.catalogEntry.schemaId; + const listed = (registered.catalogEntry.compatibleSchemaIds ?? []).filter((schemaId) => schemaId !== ownId); + const owner = this.#schemaIdDefinitions.get(ownId); + if (owner && owner !== registered) { + throw refuse(owner, registered.catalogEntry, `schema id "${ownId}"`); } const existingBySchemaName = this.#schemaNameDefinitions.get(registered.catalogEntry.schemaName); if (existingBySchemaName && existingBySchemaName !== registered) { - throw new Error( - `CultCache schema name "${registered.catalogEntry.schemaName}" is already registered for type "${existingBySchemaName.definition.type}".`, - ); + throw refuse(existingBySchemaName, registered.catalogEntry, `schema name "${registered.catalogEntry.schemaName}"`); } const derived = this.#deriveTypeLookups(definition.type, nameAccessor, indexAccessors); // Every check and accessor has run; install. this.#definitions.set(definition.type, registered); - for (const schemaId of schemaIds) { - this.#schemaIdDefinitions.set(schemaId, registered); + this.#schemaIdDefinitions.set(ownId, registered); + for (const schemaId of listed) { + const listers = this.#listingDefinitions.get(schemaId) ?? []; + if (!listers.includes(registered)) { + this.#listingDefinitions.set(schemaId, [...listers, registered]); + } } this.#schemaNameDefinitions.set(registered.catalogEntry.schemaName, registered); this.#installAccessors(registered, nameAccessor, indexAccessors, derived); @@ -698,8 +711,7 @@ export class CultCache { } #createCatalogEntry(definition: AnyCultCacheDocumentDefinition): CultCacheSchemaCatalogEntry { - const schemaName = definition.schemaName ?? definition.type; - const schemaVersion = definition.schemaVersion ?? `${schemaName}.v1`; + const { schemaId, schemaName, schemaVersion, compatibleSchemaIds } = schemaIdentityOf(definition); const canonicalSchemaJson = definition.canonicalSchemaJson ?? JSON.stringify({ schemaName, schemaVersion, @@ -716,10 +728,6 @@ export class CultCache { isName: member.isName === true, })), }); - const schemaId = definition.schemaId ?? schemaName; - const compatibleSchemaIds = [ - ...new Set([schemaId, ...(definition.compatibleSchemaIds ?? [])]), - ]; return { schemaId, @@ -727,7 +735,7 @@ export class CultCache { schemaVersion, contentHash: definition.contentHash ?? schemaId, canonicalSchemaJson, - compatibleSchemaIds, + compatibleSchemaIds: [...compatibleSchemaIds], members: [...resolveDocumentMembers(definition)] .sort((left, right) => left.slot - right.slot) .map((member) => ({ @@ -810,7 +818,16 @@ export class CultCache { #resolveDefinitionForEnvelope(entry: CultCacheEnvelope): RegisteredDefinition | undefined { if (entry.schemaId) { - return this.#schemaIdDefinitions.get(entry.schemaId) + const listers = this.#listingDefinitions.get(entry.schemaId) ?? []; + const owner = this.#schemaIdDefinitions.get(entry.schemaId); + if (!owner && listers.length > 1) { + const types = listers.map((lister) => lister.definition.type).sort(); + throw new SchemaConflictError(entry.schemaId, listers.map((lister) => lister.catalogEntry.schemaName).sort(), entry.key, + `CultCache schema id "${entry.schemaId}" of record "${entry.key}" is owned by no registered type and declared compatible by ` + + `several (${types.join(", ")}), so it resolves to none. Register the type that owns the id, or declare it on one type.`); + } + return owner + ?? listers[0] ?? this.#schemaNameDefinitions.get(entry.type) ?? this.#definitions.get(entry.type); } @@ -880,8 +897,10 @@ export class CultCache { return { ...entry, payload: this.#cloneBytes(entry.payload), - schemaId: entry.schemaId ?? registered.catalogEntry.schemaId, - catalogEntry: entry.catalogEntry ?? registered.catalogEntry, + // Admitted under the registered schema's id, as a put stamps it: a record loaded under a compatible id is + // written back under the id its catalog entry carries. + schemaId: registered.catalogEntry.schemaId, + catalogEntry: registered.catalogEntry, value, lookups: this.#lookupsFor(registered.nameAccessor, registered.indexAccessors, value), }; diff --git a/packages/cultcache-ts/src/document.ts b/packages/cultcache-ts/src/document.ts index 0b019011..99ec170c 100644 --- a/packages/cultcache-ts/src/document.ts +++ b/packages/cultcache-ts/src/document.ts @@ -149,6 +149,27 @@ export function resolveDocumentMembers(definition: AnyCultCacheDocumentDefinitio })); } +// The identity a definition's records carry: the schema id they are stamped with, the schema name and version, and every id +// the definition answers to (its own first). Every CultCache runtime surface and every CultNet or CultMesh layer over it +// derives a definition's identity here and nowhere else, so a record's id means the same type wherever it is read. +export interface CultCacheSchemaIdentity { + readonly schemaId: string; + readonly schemaName: string; + readonly schemaVersion: string; + readonly compatibleSchemaIds: readonly string[]; +} + +export function schemaIdentityOf(definition: AnyCultCacheDocumentDefinition): CultCacheSchemaIdentity { + const schemaName = definition.schemaName ?? definition.type; + const schemaId = definition.schemaId ?? schemaName; + return { + schemaId, + schemaName, + schemaVersion: definition.schemaVersion ?? `${schemaName}.v1`, + compatibleSchemaIds: [...new Set([schemaId, ...(definition.compatibleSchemaIds ?? [])])], + }; +} + export function defineDocumentRegistry< TDefinitions extends readonly AnyCultCacheDocumentDefinition[], >(...definitions: TDefinitions): CultCacheDocumentRegistry { diff --git a/packages/cultcache-ts/src/index.ts b/packages/cultcache-ts/src/index.ts index 8646d12e..f55dd049 100644 --- a/packages/cultcache-ts/src/index.ts +++ b/packages/cultcache-ts/src/index.ts @@ -1,45 +1,2 @@ -export * as codecs from "./codec"; -export type { Codec, SlotsCodec } from "./codec"; -export { CultCache, CultCacheBuilder } from "./cult-cache"; -export { inspectCultCacheBytes } from "./cult-cache-inspector"; -export { - decodeDocumentPayload, - defineDocumentRegistry, - defineDocumentType, - documentPayloadBytes, - documentPayloadFromBytes, - encodeDocumentPayload, -} from "./document"; -export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth"; -export type { DocumentPayload } from "./document"; -export { SingleFileMessagePackBackingStore } from "./single-file-messagepack-backing-store"; -export type { - AnyCultCacheDocumentDefinition, - CacheBackingStore, - CultCacheDocumentAccessor, - CultCacheDocumentDefinition, - CultCacheDocumentFieldName, - CultCacheDocumentFormatter, - CultCacheDocumentIndexDefinition, - CultCacheDocumentRegistry, - CultCacheSchemaCatalogEntry, - CultCacheSchemaCatalogMember, - CultCacheSchema, - CultCacheDocumentValue, - CultCacheEnvelope, - CultCacheIndexScalar, - InferCultCacheSchemaValue, - CultCacheStoreRegistration, - PushAllOptions, -} from "./types"; -export type { - CultCacheInspection, - InspectedCatalogEntry, - InspectedCatalogMember, - InspectedRecord, -} from "./cult-cache-inspector"; -export { - defineSwarmDocuments, - type DefineDocumentType, - type SwarmDocumentCatalog, -} from "./swarm-documents"; +export * from "./browser"; +export { SingleFileMessagePackBackingStore, readSingleFileStore, type SingleFileStoreRead } from "./single-file-messagepack-backing-store"; diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index f10b146b..2501c632 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -1,13 +1,13 @@ -import { lstat, mkdir, readFile } from "node:fs/promises"; +import { lstatSync, readFileSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { decode, encode } from "@msgpack/msgpack"; import { z } from "zod"; import { documentPayloadBytes, documentPayloadFromBytes } from "./document"; -import { checkPayloadDepth } from "./payload-depth"; import { commitStoreFile, withStoreWriteLock } from "./store-file"; -import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format"; +import { STORE_FORMAT_VERSION, SchemaConflictError, type StoreFormat, StoreUnreadableError, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots } from "./store-format"; import type { CacheBackingStore, CultCacheEnvelope, @@ -39,110 +39,89 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { #writeQueue: Promise = Promise.resolve(); + // The header this store read; a rewrite keeps it, so a store that holds element ids never sheds its marker. + #format: StoreFormat = STORE_FORMAT_VERSION; + constructor(filePath: string) { this.filePath = resolve(filePath); } async pullAll(): Promise { - const { envelopes, healLegacy } = await this.#readDisk(); - if (healLegacy) { + // The disk decides the header: a file that is gone or legacy is not marked. + this.#format = STORE_FORMAT_VERSION; + const disk = this.#readDisk(); + 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 = await this.#readDisk(); - if (again.healLegacy) { - await this.#writeAll(again.envelopes); + const again = this.#readDisk(); + this.#format = again.format; + if (again.repairedLegacyPayload) { + await this.#writeAll([], again.envelopes); } }); } - return envelopes; - } - - async #readDisk(): Promise<{ envelopes: CultCacheEnvelope[]; healLegacy: boolean }> { - // An empty file is not a store, since no CultCache writer leaves one, so it is - // decoded and refused. Only a read that finds nothing at the path is an empty store. - const data = await readStore(this.filePath); - if (data === undefined) { - return { envelopes: [], healLegacy: false }; - } - - const decoded = decode(data); - const snapshot = decodeSnapshot(decoded); - if (snapshot) { - const envelopes = snapshot.records.map((record) => { - const catalogEntry = resolveCatalogEntryForRecord(record, snapshot.catalogBySchemaId); - if (!catalogEntry) { - throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`); - } - - return { - key: record.key, - type: catalogEntry.schemaName, - schemaId: record.schemaId, - catalogEntry, - payload: documentPayloadFromBytes(record.payload), - storedAt: record.storedAt, - }; - }); - return { envelopes, healLegacy: false }; - } - - const legacy = decodeLegacyEnvelopeArray(decoded); - if (legacy) { - let repairedLegacyPayload = false; - const normalized = legacy.map((entry) => { - const payload = normalizePayload(entry.payload); - if (payload !== entry.payload) { - repairedLegacyPayload = true; - } - - return { - ...entry, - payload, - }; - }); - const parsed: CultCacheEnvelope[] = envelopeArraySchema.parse(normalized).map((entry) => ({ - ...entry, - payload: documentPayloadFromBytes(entry.payload), - })); - - return { envelopes: parsed, healLegacy: repairedLegacyPayload }; - } - - throw new Error(`CultCache file ${this.filePath} is not a recognized CultCache MessagePack store.`); + return disk.envelopes; } async push(entry: CultCacheEnvelope): Promise { await this.#enqueue(async () => { - const existing = (await this.#readDisk()).envelopes; + const disk = this.#readDisk(); + this.#format = disk.format; + const existing = disk.envelopes; const filtered = existing.filter( (candidate) => !(candidate.type === entry.type && candidate.key === entry.key), ); - filtered.push(entry); - await this.#writeAll(filtered); + await this.#writeAll([entry], filtered); }); } async delete(entry: CultCacheEnvelope): Promise { await this.#enqueue(async () => { - const existing = (await this.#readDisk()).envelopes; + const disk = this.#readDisk(); + this.#format = disk.format; + const existing = disk.envelopes; const filtered = existing.filter( (candidate) => !(candidate.type === entry.type && candidate.key === entry.key), ); - await this.#writeAll(filtered); + await this.#writeAll([], filtered); }); } async pushAll(entries: CultCacheEnvelope[], options: PushAllOptions = {}): Promise { await this.#enqueue(async () => { - if (options.soft && (await readStore(this.filePath)) !== undefined) { - return; + // A flush of the whole store writes the header the file on disk carries, read now: a file marked for element ids + // stays marked, and one that is not (or is gone or legacy) is written unmarked. The file is read by the same + // reader as `pullAll`, so one it would refuse (not exactly one store, a variant, a body it cannot decode) is refused + // and left as it is. A soft flush asks that reader too: nothing at the path is written, a store is left alone. + try { + const disk = this.#readDisk(); + if (options.soft && !disk.absent) { + return; + } + + this.#format = disk.format; + } catch (error) { + // Only a dangling link reaches here with ENOENT: it holds no store, so a whole-store flush has no header to keep and + // replaces the link (R3 pins this). A soft flush does not replace what it cannot read, and every merging writer + // has already refused the link when it read the current entries. + if (options.soft || (error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + + this.#format = STORE_FORMAT_VERSION; } - await this.#writeAll(entries); + await this.#writeAll(entries, []); }); } + // The one reader of the store file, asked by open, push, delete and flush alike; see readSingleFileStore. + #readDisk(): SingleFileStoreRead { + return readSingleFileStore(this.filePath); + } + async #enqueue(operation: () => Promise): Promise { let result!: T; @@ -161,29 +140,54 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { return result; } - async #writeAll(entries: CultCacheEnvelope[]): Promise { + async #writeAll(supplied: CultCacheEnvelope[], arrived: CultCacheEnvelope[]): Promise { await mkdir(dirname(this.filePath), { recursive: true }); - await commitStoreFile(this.filePath, encode(encodeSnapshot(entries))); + await commitStoreFile(this.filePath, encode(encodeSnapshot(supplied, arrived, this.#format))); } } /** - * Reads a store file, or returns undefined when nothing is at its path. A dangling symbolic - * link is something: reading it as empty would let the writer rename a file over the link - * and move the store off its volume. Any other failure to reach the file is thrown. + * 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, + * unmarked store. A dangling symbolic link is something: reading it as empty would let a writer rename a file over + * the link and move the store off its volume, so it throws with code ENOENT, as does any other failure to reach the + * file. A zero-byte file is not a store and, like anything that does not decode completely, throws StoreUnreadableError. + * The read is synchronous so a consumer that loads authority at start-up needs no second reader to stay synchronous. */ -async function readStore(path: string): Promise { +export function readSingleFileStore(path: string): SingleFileStoreRead { + const data = readStoreBytes(path); + if (data === undefined) { + return { absent: true, format: STORE_FORMAT_VERSION, envelopes: [], repairedLegacyPayload: false }; + } + try { - return await readFile(path); + return { absent: false, ...decodeStoreFile(data) }; + } catch (error) { + throw new StoreUnreadableError(path, error); + } +} + +/** + * The bytes at `path`, or undefined when nothing is there. Nothing is there only when both looks agree: the read found no + * file and the follow-up lstat found no entry. Every other outcome is an error, because the path may have changed + * between the two calls (a link in a parent swapped for a file, a store created or removed): an lstat failure other than + * ENOENT is thrown as it is, and an entry the lstat does find, a dangling link or one that appeared since the read, is + * thrown with code ENOENT rather than read as an empty store. `lstat` is a seam so a test can stage the second look; + * this module exports the function for that test, and the package index does not. + */ +export function readStoreBytes(path: string, lstat: (path: string) => { isSymbolicLink(): boolean } = lstatSync): Uint8Array | undefined { + try { + return readFileSync(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } } + let entry: { isSymbolicLink(): boolean }; try { - await lstat(path); + entry = lstat(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return undefined; @@ -192,7 +196,8 @@ async function readStore(path: string): Promise { throw error; } - throw Object.assign(new Error(`CultCache store ${path} is a symbolic link whose target does not exist.`), { code: "ENOENT" }); + const what = entry.isSymbolicLink() ? "is a symbolic link whose target does not exist" : "appeared while it was being read"; + throw Object.assign(new Error(`CultCache store ${path} ${what}.`), { code: "ENOENT" }); } type PersistedRecord = { @@ -203,13 +208,19 @@ type PersistedRecord = { }; type DecodedSnapshot = { + format: StoreFormat; catalogBySchemaId: Map; records: PersistedRecord[]; }; -function encodeSnapshot(entries: CultCacheEnvelope[]): unknown[] { - const catalog = [...catalogEntriesFor(entries).values()] - .sort((left, right) => compareOrdinal(left.schemaName, right.schemaName)); +function encodeSnapshot(supplied: CultCacheEnvelope[], arrived: CultCacheEnvelope[], format: StoreFormat): unknown[] { + const replaced = new Set(supplied.map((entry) => `${entry.type}::${entry.key}`)); + const kept = arrived.filter((entry) => !replaced.has(`${entry.type}::${entry.key}`)); + const entries = [...kept, ...supplied]; + const catalog = catalogEntriesFor([ + ...kept.map((envelope) => ({ envelope, registered: false })), + ...supplied.map((envelope) => ({ envelope, registered: true })), + ]).sort((left, right) => compareOrdinal(left.schemaName, right.schemaName) || compareOrdinal(left.schemaId, right.schemaId)); const records = [...entries] .sort((left, right) => compareOrdinal(left.key, right.key)) .map((entry) => [ @@ -220,34 +231,134 @@ function encodeSnapshot(entries: CultCacheEnvelope[]): unknown[] { ]); return [ - STORE_FORMAT_VERSION, + format, catalog.map(encodeCatalogEntry), records, ]; } -function catalogEntriesFor(entries: CultCacheEnvelope[]): Map { - const catalog = new Map(); - for (const entry of entries) { - const schemaId = schemaIdForEnvelope(entry); - if (!catalog.has(schemaId)) { - catalog.set(schemaId, entry.catalogEntry ?? { +// The catalog a write leaves, derived from the records being written and nothing else. For each schema id a record carries, one +// entry that publishes it: the entry that owns the id (a registered one, else one that arrived with the records), else one that +// lists it as compatible (registered first). Entries are written as chosen, never merged; a registered entry wins over an arrived +// one with the same own id. Records of different types under one id, two arrived entries with the same own id and different schema +// names, or a record no chosen entry publishes, refuse the write. An envelope the caller supplied is registered; one read back +// from the file is arrived. +function catalogEntriesFor( + records: Array<{ envelope: CultCacheEnvelope; registered: boolean }>, +): CultCacheSchemaCatalogEntry[] { + const entries: Array<{ entry: CultCacheSchemaCatalogEntry; registered: boolean }> = []; + const byId = new Map(); + const tiers = new Map>(); + for (const { envelope, registered } of records) { + const schemaId = schemaIdForEnvelope(envelope); + const supplied = envelope.catalogEntry; + const publishes = + supplied !== undefined && (supplied.schemaId === schemaId || (supplied.compatibleSchemaIds ?? []).includes(schemaId)); + const entry = publishes && supplied ? supplied : defaultEntryFor(envelope); + entries.push({ entry, registered }); + byId.set(schemaId, [...(byId.get(schemaId) ?? []), envelope]); + tiers.set(schemaId, [...(tiers.get(schemaId) ?? []), { envelope, registered, entry }]); + } + + const lists = (entry: CultCacheSchemaCatalogEntry, schemaId: string): boolean => + (entry.compatibleSchemaIds ?? [entry.schemaId]).includes(schemaId); + const chosen = new Map(); + for (const schemaId of [...byId.keys()].sort(compareOrdinal)) { + const carrying = byId.get(schemaId)!; + const recordKey = carrying.map((envelope) => envelope.key).sort(compareOrdinal)[0]!; + // Records of one tier under one id are one type. Across tiers the id keeps the type its records resolve to, so a write may + // not retype records that are already there: unless the entries of both tiers own the id, which is a rename of the schema + // under a stable id, and the registered descriptor wins. A read-back record's type is its schema's name, so tiers compare + // the names of the entries that publish the id. + const carried = tiers.get(schemaId)!; + for (const tier of [true, false]) { + const types = [...new Set(carried.filter((candidate) => candidate.registered === tier).map((candidate) => candidate.envelope.type))].sort( + compareOrdinal, + ); + if (types.length > 1) { + throw new SchemaConflictError(schemaId, types, recordKey); + } + } + + const tierNames = (tier: boolean): string[] => [ + ...new Set(carried.filter((candidate) => candidate.registered === tier).map((candidate) => candidate.entry.schemaName)), + ]; + const ownedBy = (tier: boolean): boolean => + carried.some((candidate) => candidate.registered === tier && candidate.entry.schemaId === schemaId); + const [registeredName, arrivedName] = [tierNames(true)[0], tierNames(false)[0]]; + if (registeredName !== undefined && arrivedName !== undefined && registeredName !== arrivedName && !(ownedBy(true) && ownedBy(false))) { + throw new SchemaConflictError(schemaId, [registeredName, arrivedName].sort(compareOrdinal), recordKey); + } + + const ownRegistered = canonical(entries.filter((candidate) => candidate.registered && candidate.entry.schemaId === schemaId))[0]; + const ownArrived = canonical(entries.filter((candidate) => !candidate.registered && candidate.entry.schemaId === schemaId)); + // Entries of one tier that share an own id must be one schema; a registered entry settles a disagreement among arrived ones. + const ownRegisteredAll = canonical(entries.filter((candidate) => candidate.registered && candidate.entry.schemaId === schemaId)); + const names = [ + ...new Set((ownRegisteredAll.length > 0 ? ownRegisteredAll : ownArrived).map((candidate) => candidate.entry.schemaName)), + ].sort(compareOrdinal); + if (names.length > 1) { + throw new SchemaConflictError(schemaId, names, recordKey); + } + + let pick: { entry: CultCacheSchemaCatalogEntry; registered: boolean } | undefined; + if (ownRegistered !== undefined) { + pick = ownRegistered; + } else if (ownArrived.length > 0) { + pick = ownArrived[0]; + } else { + pick = + canonical(entries.filter((candidate) => candidate.registered && lists(candidate.entry, schemaId)))[0] ?? + canonical(entries.filter((candidate) => !candidate.registered && lists(candidate.entry, schemaId)))[0]; + } + + if (pick === undefined) { + continue; + } + + const prior = chosen.get(pick.entry.schemaId); + if (prior !== undefined && prior.registered && !pick.registered) { + continue; + } + + chosen.set(pick.entry.schemaId, pick); + } + + for (const [schemaId, carrying] of byId) { + if (![...chosen.values()].some(({ entry }) => entry.schemaId === schemaId || lists(entry, schemaId))) { + throw new SchemaConflictError( schemaId, - schemaName: entry.type, - schemaVersion: `${entry.type}.v1`, - contentHash: schemaId, - canonicalSchemaJson: JSON.stringify({ - schemaName: entry.type, - schemaVersion: `${entry.type}.v1`, - members: [], - }), - compatibleSchemaIds: [schemaId], - members: [], - }); + [...chosen.values()].map(({ entry }) => entry.schemaName).sort(compareOrdinal), + carrying.map((envelope) => envelope.key).sort(compareOrdinal)[0]!, + ); } } - return catalog; + return [...chosen.values()].map(({ entry }) => entry); +} + +// Entries that tie, in one fixed order, so the catalog does not depend on the order they arrive in. +function canonical(candidates: T[]): T[] { + const key = (candidate: T): string => + [candidate.entry.schemaName, candidate.entry.contentHash ?? "", (candidate.entry.compatibleSchemaIds ?? []).join(",")].join("\u0000"); + return [...candidates].sort((left, right) => compareOrdinal(key(left), key(right))); +} + +function defaultEntryFor(entry: CultCacheEnvelope): CultCacheSchemaCatalogEntry { + const schemaId = schemaIdForEnvelope(entry); + return { + schemaId, + schemaName: entry.type, + schemaVersion: `${entry.type}.v1`, + contentHash: schemaId, + canonicalSchemaJson: JSON.stringify({ + schemaName: entry.type, + schemaVersion: `${entry.type}.v1`, + members: [], + }), + compatibleSchemaIds: [schemaId], + members: [], + }; } function schemaIdForEnvelope(entry: CultCacheEnvelope): string { @@ -279,82 +390,89 @@ function encodeCatalogMember(member: CultCacheSchemaCatalogMember): unknown[] { ]; } +export type SingleFileStoreRead = { + // True when nothing is at the path; the other fields then describe an empty unmarked store. + absent: boolean; + format: StoreFormat; + 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 { + // The stamp is judged before the body is decoded, as Rust's store_header does: a newer store is refused by name. + refuseUnreadStoreBytes(data); + const decoded = decode(data); + const snapshot = decodeSnapshot(decoded); + if (snapshot) { + const envelopes = snapshot.records.map((record): CultCacheEnvelope => { + const catalogEntry = snapshot.catalogBySchemaId.get(record.schemaId); + if (!catalogEntry) { + throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`); + } + + return { + key: record.key, + type: catalogEntry.schemaName, + schemaId: record.schemaId, + catalogEntry, + payload: documentPayloadFromBytes(record.payload), + storedAt: record.storedAt, + }; + }); + return { format: snapshot.format, envelopes, repairedLegacyPayload: false }; + } + + const legacy = decodeLegacyEnvelopeArray(decoded); + if (!legacy) { + throw new Error("CultCache file is not a recognized CultCache MessagePack store."); + } + + let repairedLegacyPayload = false; + const normalized = legacy.map((entry) => { + const payload = normalizePayload(entry.payload); + 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 { - if (!isV1Snapshot(decoded)) { + if (!isStoreSnapshot(decoded)) { return undefined; } + requireStoreSlots(decoded); const catalogRaw = decoded[1]; const recordsRaw = decoded[2]; if (!Array.isArray(catalogRaw) || !Array.isArray(recordsRaw)) { throw new Error("CultCache v1 snapshot must contain a schema catalog and record array."); } + // A schema id names the entry that has it as its own id; only an id no entry owns names the entry that lists it as compatible. + const catalogEntries = catalogRaw.map(decodeCatalogEntry); const catalogBySchemaId = new Map(); - for (const entry of catalogRaw) { - const catalogEntry = decodeCatalogEntry(entry); - catalogBySchemaId.set(catalogEntry.schemaId, catalogEntry); + for (const catalogEntry of catalogEntries) { + if (!catalogBySchemaId.has(catalogEntry.schemaId)) { + catalogBySchemaId.set(catalogEntry.schemaId, catalogEntry); + } + } + for (const catalogEntry of catalogEntries) { for (const compatibleSchemaId of catalogEntry.compatibleSchemaIds ?? []) { - catalogBySchemaId.set(compatibleSchemaId, catalogEntry); + if (!catalogBySchemaId.has(compatibleSchemaId)) { + catalogBySchemaId.set(compatibleSchemaId, catalogEntry); + } } } const records = recordsRaw.map(decodeRecord); - return { catalogBySchemaId, records }; -} - -function resolveCatalogEntryForRecord( - record: PersistedRecord, - catalogBySchemaId: Map, -): CultCacheSchemaCatalogEntry | undefined { - const existing = catalogBySchemaId.get(record.schemaId); - if (existing) { - return existing; - } - - const schemaVersion = inferSchemaVersionFromPayload(record.payload); - if (!schemaVersion) { - return undefined; - } - - const schemaName = inferSchemaName(schemaVersion); - if (!schemaName) { - return undefined; - } - - return { - schemaId: record.schemaId, - schemaName, - schemaVersion, - contentHash: record.schemaId, - canonicalSchemaJson: "", - compatibleSchemaIds: [], - members: [], - }; -} - -function inferSchemaVersionFromPayload(payload: Uint8Array): string | undefined { - try { - checkPayloadDepth(payload); - const decoded = decode(payload); - if (Array.isArray(decoded) && typeof decoded[0] === "string") { - return decoded[0]; - } - - if (isObject(decoded)) { - const schemaVersion = decoded.schemaVersion ?? decoded.schema_version; - return typeof schemaVersion === "string" ? schemaVersion : undefined; - } - - return undefined; - } catch { - return undefined; - } -} - -function inferSchemaName(schemaVersion: string): string | undefined { - const match = /^(.*)\.v\d+$/.exec(schemaVersion); - return match?.[1]; + return { format: decoded[0], catalogBySchemaId, records }; } function decodeCatalogEntry(value: unknown): CultCacheSchemaCatalogEntry { diff --git a/packages/cultcache-ts/src/store-format.ts b/packages/cultcache-ts/src/store-format.ts index 4f579505..4efab504 100644 --- a/packages/cultcache-ts/src/store-format.ts +++ b/packages/cultcache-ts/src/store-format.ts @@ -1,24 +1,109 @@ +import { decode } from "@msgpack/msgpack"; + export const STORE_FORMAT_VERSION = "cultcache.store.v1"; +/** + * A store that can hold element ids. A reader older than element ids refuses this header: it would skip the id slots + * of a nested element and rewrite the element without them. This runtime writes it back only for a store it read + * under it; it does not decide when a document carries ids. + */ +export const STORE_FORMAT_ELEMENT_IDS = "cultcache.store.v3"; + +export type StoreFormat = typeof STORE_FORMAT_VERSION | typeof STORE_FORMAT_ELEMENT_IDS; + const STORE_FORMAT_PREFIX = "cultcache.store."; const PERSISTED_RECORD_SLOTS = 4; +const STORE_SLOTS = 3; /** - * True for a v1 snapshot, false for anything that is not a CultCache store header (the legacy - * envelope array). Any other `cultcache.store.*` header is refused by name. + * True for a decoded array that leads with a `cultcache.store.*` stamp, false for the legacy envelope array. It judges the + * shape only: the verdict on the stamp is {@link refuseUnreadStoreBytes}, which a reader calls on the bytes before it decodes, + * so the stamp of a decoded array it is given is one this runtime reads. */ -export function isV1Snapshot(decoded: unknown): decoded is unknown[] { - if (!Array.isArray(decoded) || typeof decoded[0] !== "string" || !decoded[0].startsWith(STORE_FORMAT_PREFIX)) { - return false; - } +export function isStoreSnapshot(decoded: unknown): decoded is [StoreFormat, ...unknown[]] { + return Array.isArray(decoded) && typeof decoded[0] === "string" && decoded[0].startsWith(STORE_FORMAT_PREFIX); +} - if (decoded[0] !== STORE_FORMAT_VERSION) { +/** + * Refuses a store whose header names a format this runtime does not read, judging the header alone: the leading array's first + * element, when it is a complete string. The reader calls it before it decodes the body, so a newer store is refused by name + * whether or not its body is whole. Bytes with no such header (a legacy array, an empty, truncated or garbled file) are not + * refused here. + */ +export function refuseUnreadStoreBytes(bytes: Uint8Array): void { + const header = peekHeader(bytes); + if (header !== undefined && header.startsWith(STORE_FORMAT_PREFIX)) { + refuseUnreadFormat(header); + } +} + +// The first element of a leading non-empty MessagePack array when it is a complete string; anything else is undefined. The +// string is decoded by the decoder a read uses, so a stamp gets one verdict from the peek and from the whole decode. +function peekHeader(bytes: Uint8Array): string | undefined { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const lead = bytes[0]; + let offset: number; + if (lead === undefined) return undefined; + if (lead >= 0x91 && lead <= 0x9f) offset = 1; + else if (lead === 0xdc && bytes.length >= 3 && view.getUint16(1) > 0) offset = 3; + else if (lead === 0xdd && bytes.length >= 5 && view.getUint32(1) > 0) offset = 5; + else return undefined; + const start = offset; + const tag = bytes[offset]; + let length: number; + if (tag === undefined) return undefined; + if (tag >= 0xa0 && tag <= 0xbf) { length = tag & 0x1f; offset += 1; } + else if (tag === 0xd9 && bytes.length >= offset + 2) { length = bytes[offset + 1]!; offset += 2; } + else if (tag === 0xda && bytes.length >= offset + 3) { length = view.getUint16(offset + 1); offset += 3; } + else if (tag === 0xdb && bytes.length >= offset + 5) { length = view.getUint32(offset + 1); offset += 5; } + else return undefined; + if (bytes.length < offset + length) return undefined; + const header = decode(bytes.subarray(start, offset + length)); + return typeof header === "string" ? header : undefined; +} + +/** The one verdict on a stamp: this runtime reads v1 and v3, and any other `cultcache.store.*` header is refused by name. */ +function refuseUnreadFormat(header: string): void { + if (header !== STORE_FORMAT_VERSION && header !== STORE_FORMAT_ELEMENT_IDS) { throw new Error( - `CultCache store format ${describeHeader(decoded[0])} is not one this runtime reads; it reads "${STORE_FORMAT_VERSION}" only.`, + `CultCache store format ${describeHeader(header)} is not one this runtime reads; it reads "${STORE_FORMAT_VERSION}" and "${STORE_FORMAT_ELEMENT_IDS}" only.`, ); } +} - return true; +/** + * A write the catalog cannot describe: records of different types under one schema id, two arrived schemas that share an id and + * disagree on the schema name, or a record no chosen entry publishes. Nothing is written. It names the id, the names involved and + * a record key. A cache throws it too, with an empty record key, when a definition cannot register beside one it already holds, + * and when a record's schema id names no single registered definition. + */ +export class SchemaConflictError extends Error { + readonly schemaId: string; + readonly schemaNames: string[]; + readonly recordKey: string; + + constructor(schemaId: string, schemaNames: string[], recordKey: string, message?: string) { + super(message ?? `Schema id "${schemaId}" cannot describe record "${recordKey}": ${schemaNames.map((name) => `"${name}"`).join(", ")}.`); + this.name = "SchemaConflictError"; + this.schemaId = schemaId; + this.schemaNames = schemaNames; + this.recordKey = recordKey; + } +} + +/** + * A store file this runtime cannot read: not exactly one complete store (truncated, bytes after it, a missing or extra slot), + * a header or record it does not know, or a body it cannot decode. Open, flush and every rewrite refuse a file with this + * error, and a refused file is left as it was. `cause` is what the reader choked on. + */ +export class StoreUnreadableError extends Error { + readonly filePath: string; + + constructor(filePath: string, cause: unknown) { + super(`CultCache store ${filePath} is not readable: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }); + this.name = "StoreUnreadableError"; + this.filePath = filePath; + } } /** @@ -59,3 +144,10 @@ export function requireV1RecordSlots(record: unknown[]): void { ); } } + +/** Refuses a store array with more or fewer slots than the header, the schema catalog and the records; a header alone is not a store. */ +export function requireStoreSlots(decoded: unknown[]): void { + if (decoded.length !== STORE_SLOTS) { + throw new Error(`CultCache store has ${decoded.length} top-level slots; this runtime reads ${STORE_SLOTS} (header, schema catalog, records).`); + } +} diff --git a/packages/cultcache-ts/test/browser-entry.test.ts b/packages/cultcache-ts/test/browser-entry.test.ts new file mode 100644 index 00000000..bbf2e2c4 --- /dev/null +++ b/packages/cultcache-ts/test/browser-entry.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { join } from "node:path"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; +import { build } from "esbuild"; + +// dist-test/test/ -> dist-test/src/ +const srcDir = join(__dirname, "..", "src"); + +async function bundleForBrowser(entry: string): Promise { + const result = await build({ + entryPoints: [join(srcDir, entry)], + bundle: true, + platform: "browser", + format: "iife", + globalName: "cultcache", + write: false, + logLevel: "silent", + }); + return result.outputFiles[0].text; +} + +test("the browser entry bundles for platform=browser and runs without node", async () => { + const code = await bundleForBrowser("browser.js"); + assert.ok(!/node:fs|koffi/.test(code), "no node-only store in the browser bundle"); + // A context with no require/process/Buffer: only what a browser page has. + const sandbox: Record = { TextEncoder, TextDecoder }; + runInNewContext(code + "\nglobalThis.__api = cultcache;", sandbox); + const api = sandbox.__api as Record; + assert.equal(typeof api.CultCache, "function"); + assert.equal(typeof api.defineDocumentType, "function"); + assert.equal(api.SingleFileMessagePackBackingStore, undefined); +}); + +test("the node entry still exports the single-file store and everything the browser entry has", async () => { + const node = await import(join(srcDir, "index.js")); + const browser = await import(join(srcDir, "browser.js")); + assert.equal(typeof node.SingleFileMessagePackBackingStore, "function"); + assert.equal(typeof node.readSingleFileStore, "function"); + for (const name of Object.keys(browser)) assert.ok(name in node, `node entry lost ${name}`); +}); + +test("the package root keeps the node-only store out of the browser build", async () => { + await assert.rejects(bundleForBrowser("index.js"), /node:fs|node:path|koffi/); +}); diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 86d4a090..02324273 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { exec, execFile } from "node:child_process"; import { EventEmitter } from "node:events"; import { existsSync } from "node:fs"; -import { access, lstat, mkdir, mkdtemp, readdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { access, lstat, mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { dirname, join, resolve, delimiter } from "node:path"; import { test } from "node:test"; @@ -14,8 +14,9 @@ import { z } from "zod"; import { CultCache } from "../src/cult-cache"; import { inspectCultCacheBytes } from "../src/cult-cache-inspector"; import { defineDocumentRegistry, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; -import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store"; -import type { CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types"; +import { SingleFileMessagePackBackingStore, readStoreBytes } from "../src/single-file-messagepack-backing-store"; +import { SchemaConflictError, StoreUnreadableError, refuseUnreadStoreBytes } from "../src/store-format"; +import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types"; const execFileAsync = promisify(execFile); const execAsync = promisify(exec); @@ -460,7 +461,7 @@ test("SingleFileMessagePackBackingStore reads CultCache v1 snapshots by schema i assert.equal(cache.getRequiredEnvelope(noteDocument, "record-1").schemaId, "schema-1"); }); -test("SingleFileMessagePackBackingStore recovers schema-stamped records missing catalog entries", async () => { +test("SingleFileMessagePackBackingStore refuses a record whose schema the catalog does not publish, whatever its payload opens with", async () => { const stampedDocument = defineDocumentType({ type: "runtime-policy", schema: z.tuple([ @@ -500,16 +501,7 @@ test("SingleFileMessagePackBackingStore recovers schema-stamped records missing .withGenericStore(new SingleFileMessagePackBackingStore(storePath)) .build(); - await cache.pullAllBackingStores(); - assert.deepEqual(cache.getRequired(stampedDocument, "record-1"), [ - "tests.schema_stamped_entry.v1", - "schema-stamped", - "still readable", - ]); - assert.equal( - cache.getRequiredEnvelope(stampedDocument, "record-1").schemaId, - "sha256:stale-schema-id-from-cold-record", - ); + await assert.rejects(() => cache.pullAllBackingStores(), StoreUnreadableError); }); test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was persisted as an object", async () => { @@ -1193,6 +1185,250 @@ test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Pyth } }); +// Records and catalog entries a runtime does not own survive its writes. C# writes a store and each other runtime writes its own +// note into it: C# reads its note back, and its record and catalog entry are as C# wrote them. C# then writes again, and the other +// runtime's record and catalog entry are as that runtime wrote them. Identity is compared on the decoded MessagePack values, every +// slot of the entry and of its members included. +test("A runtime's writes lay back another runtime's records and catalog entries unchanged", async () => { + await buildInteropPeers(); + const tempDir = await mkdtemp(join(tmpdir(), "cultcache-lay-back-")); + const csharpWrite = (file: string, runtimeId: string) => + runJsonCommand("csharp-write", dotnetCommand, [csharpInteropDll, "write", "--file", file, "--runtime-id", runtimeId], cultLibRoot); + const others = [ + { + name: "rust", + write: (file: string) => runJsonCommand("rust-write", rustInteropBinary, ["write", "--file", file, "--runtime-id", "rust-writer"], cultcacheRsRoot), + }, + { name: "ts", write: (file: string) => writeTsInteropStore(file, "ts-writer") }, + { + name: "python", + write: (file: string) => runJsonCommand("python-write", pythonCommand, [ + "-m", "cultcache_py.interop", "write", "--file", file, "--runtime-id", "python-writer", + ], cultcachePyRoot, { PYTHONPATH: cultcachePySrc }), + }, + ]; + const stored = async (file: string, key: string) => { + const [, catalog, records] = decode(await readFile(file)) as [string, unknown[][], unknown[][]]; + const record = records.find((candidate) => candidate[0] === key); + assert.ok(record, `${file} holds no record ${key}`); + const entry = catalog.find((candidate) => candidate[0] === record[1]); + assert.ok(entry, `${file} publishes no entry owning ${String(record[1])}`); + return { record, entry }; + }; + + for (const other of others) { + const file = join(tempDir, `${other.name}.cc`); + await csharpWrite(file, "csharp-writer"); + const csharp = await stored(file, "note:csharp-writer"); + + const written = await other.write(file); + assert.deepEqual(await stored(file, "note:csharp-writer"), csharp, `${other.name} rewrote the C# record or its catalog entry`); + const read = await runJsonCommand("csharp-read", dotnetCommand, [csharpInteropDll, "read", "--file", file], cultLibRoot); + assert.ok(read.documentId === "note:csharp-writer" || read.documentId === written.documentId, `C# failed to read after ${other.name}`); + const theirs = await stored(file, written.documentId); + assert.notEqual(theirs.record[1], csharp.record[1], `${other.name} writes under its own id`); + + await csharpWrite(file, "csharp-again"); + assert.deepEqual(await stored(file, written.documentId), theirs, `C# rewrote the ${other.name} record or its catalog entry`); + assert.deepEqual(await stored(file, "note:csharp-writer"), csharp); + } +}); + +// A schema renamed under a stable id: the record's schema id names its type, and the name its catalog entry now carries is metadata. +// A reader that resolved the record by that name would not find its type, so every runtime that can hold the stable id opens it. The +// C# reader is not among them: its schema ids are content hashes, which this file does not carry. +test("CultCache v1 stores stay readable across TS, Rust and Python when a schema is renamed under a stable id", async () => { + await buildInteropPeers(); + const file = join(await mkdtemp(join(tmpdir(), "cultcache-rename-interop-")), "renamed.msgpack"); + const written = await writeTsInteropStore(file, "ts-before-rename"); + await new SingleFileMessagePackBackingStore(file).push({ + key: "note:ts-after-rename", + type: "cultcache.interop-note.renamed", + schemaId: interopNoteDocument.schemaId, + catalogEntry: { + schemaId: interopNoteDocument.schemaId!, + schemaName: "cultcache.interop-note.renamed", + schemaVersion: interopNoteDocument.schemaVersion!, + contentHash: "renamed", + canonicalSchemaJson: interopNoteDocument.canonicalSchemaJson!, + compatibleSchemaIds: [interopNoteDocument.schemaId!], + members: interopNoteDocument.members, + }, + storedAt: new Date().toISOString(), + payload: documentPayloadFromBytes(encode([ + written.schemaVersion, + "note:ts-after-rename", + "ts-after-rename", + "ts wrote a note under the renamed schema", + "The v1 store format is the contract.", + ["interop"], + ])), + }); + const catalog = decode(await readFile(file)) as unknown[][]; + assert.deepEqual((catalog[1] as unknown[][]).map((entry) => [entry[0], entry[1]]), [["cultcache.interop-note", "cultcache.interop-note.renamed"]]); + + const readers = [ + { name: "ts", read: async () => readTsInteropStore(file) }, + { name: "rust", read: async () => runJsonCommand("rust-read", rustInteropBinary, ["read", "--file", file], cultcacheRsRoot) }, + { + name: "python", + read: async () => + runJsonCommand("python-read", pythonCommand, ["-m", "cultcache_py.interop", "read", "--file", file], cultcachePyRoot, { + PYTHONPATH: cultcachePySrc, + }), + }, + ]; + for (const reader of readers) { + const read = await reader.read(); + assert.ok( + ["note:ts-before-rename", "note:ts-after-rename"].includes(read.documentId), + `${reader.name} opened the renamed store and read a note (${read.documentId})`, + ); + assert.equal(read.body, "The v1 store format is the contract."); + } +}); + +// A record TS wrote under an older schema id and another schema name. The C# note type declares that id compatible, so C# opens the +// record, stamps it with its own id when it writes it back, and every runtime opens the result. +test("A C# type that declares a compatible schema id opens a TypeScript record under it and rewrites it under its own id", async () => { + await buildInteropPeers(); + const legacyId = "cultcache.interop-note.legacy-id"; + const file = join(await mkdtemp(join(tmpdir(), "cultcache-compat-interop-")), "legacy.msgpack"); + await new SingleFileMessagePackBackingStore(file).push({ + key: "note:ts-legacy", + type: "cultcache.interop-note.legacy", + schemaId: legacyId, + catalogEntry: { + schemaId: legacyId, + schemaName: "cultcache.interop-note.legacy", + schemaVersion: interopNoteDocument.schemaVersion!, + contentHash: "legacy", + canonicalSchemaJson: interopNoteDocument.canonicalSchemaJson!, + compatibleSchemaIds: [legacyId], + members: interopNoteDocument.members, + }, + storedAt: new Date().toISOString(), + payload: documentPayloadFromBytes(encode([ + "cultcache.interop_note.v1", + "note:ts-legacy", + "ts-legacy", + "ts wrote a note under an older schema id", + "The v1 store format is the contract.", + ["interop"], + ])), + }); + const before = decode(await readFile(file)) as unknown[][]; + assert.equal((before[2] as unknown[][])[0]![1], legacyId); + + const rewritten = await runJsonCommand("csharp-rewrite", dotnetCommand, [csharpInteropDll, "rewrite", "--file", file], cultLibRoot); + assert.equal(rewritten.documentId, "note:ts-legacy"); + const after = decode(await readFile(file)) as unknown[][]; + const stampedId = (after[2] as unknown[][])[0]![1] as string; + assert.notEqual(stampedId, legacyId, "the rewrite stamped the record with the registered id"); + const entry = (after[1] as unknown[][]).find((candidate) => candidate[0] === stampedId) as unknown[]; + assert.ok(entry, "the catalog publishes the id the record now carries"); + assert.ok((entry[5] as string[]).includes(legacyId), "the registered entry still lists the older id"); + + const readers = [ + { name: "csharp", read: async () => runJsonCommand("csharp-read", dotnetCommand, [csharpInteropDll, "read", "--file", file], cultLibRoot) }, + { name: "ts", read: async () => readTsInteropStore(file) }, + { name: "rust", read: async () => runJsonCommand("rust-read", rustInteropBinary, ["read", "--file", file], cultcacheRsRoot) }, + { + name: "python", + read: async () => + runJsonCommand("python-read", pythonCommand, ["-m", "cultcache_py.interop", "read", "--file", file], cultcachePyRoot, { + PYTHONPATH: cultcachePySrc, + }), + }, + ]; + for (const reader of readers) { + const read = await reader.read(); + assert.equal(read.documentId, "note:ts-legacy", `${reader.name} opened the rewritten store`); + assert.equal(read.body, "The v1 store format is the contract."); + } +}); + +test("CultCache element ids cross C#, TypeScript, Python and Rust as ordinary members", async () => { + await buildInteropPeers(); + const tempDir = await mkdtemp(join(tmpdir(), "cultcache-deck-")); + const file = join(tempDir, "deck.cc"); + const written = await runJsonCommand("csharp-write-deck", dotnetCommand, [ + csharpInteropDll, "write-deck", "--file", file, + ], cultLibRoot); + assert.equal(written.ids.length, 3); + assert.equal(new Set(written.ids).size, 3, "two look-alike marks got distinct ids"); + for (const id of written.ids) assert.match(id, /^[0-9a-f]{12}$/u); + + // TypeScript: the id is slot 1 of each mark, read from the raw payload. + const store = decode(await readFile(file)) as any[]; + const payload = decode(store[2][0][3]) as any[]; + assert.deepEqual(payload[1].map((mark: any[]) => mark[1]), written.ids); + + // Python and Rust read the same store. + const python = await runJsonCommand("python-deck", pythonCommand, [ + "-c", + "import msgpack,json,sys;s=msgpack.unpackb(open(sys.argv[1],'rb').read(),raw=False);p=msgpack.unpackb(s[2][0][3],raw=False);print(json.dumps({'ids':[m[1] for m in p[1]]}))", + file, + ], cultcachePyRoot); + assert.deepEqual(python.ids, written.ids); + const rust = await runJsonCommand("rust-read-deck", rustInteropBinary, ["read-deck", "--file", file], cultcacheRsRoot); + assert.deepEqual(rust.ids, written.ids); + + // TypeScript writes the ids; C# keeps them. + const tsIds = ["a00000000001", "a00000000002", "a00000000003"]; + store[2][0][3] = encode(["deck:csharp", [["twin", tsIds[0]], ["twin", tsIds[1]], ["other", tsIds[2]]]]); + const tsFile = join(tempDir, "ts-deck.cc"); + await writeFile(tsFile, encode(store)); + const back = await runJsonCommand("csharp-read-deck", dotnetCommand, [ + csharpInteropDll, "read-deck", "--file", tsFile, + ], cultLibRoot); + assert.deepEqual(back.ids, tsIds); +}); + +// A C# record edited in place is written as edited, however the consumer reached it: a decoded snapshot record's payload bytes, a +// variant override's value bytes, a collection element or a nested object of the document the cache holds. Rust and Python read the +// edit through their own readers; the variant and the nested object are read by decoding the file with Python's msgpack. +test("C# writes an edit made in place, and Rust and Python read the new value", async () => { + await buildInteropPeers(); + const tempDir = await mkdtemp(join(tmpdir(), "cultcache-edit-in-place-")); + const csharp = (name: string, args: string[]) => runJsonCommand(name, dotnetCommand, [csharpInteropDll, ...args], cultLibRoot); + const edit = (file: string, kind: string) => csharp(`csharp-edit-${kind}`, ["edit-in-place", "--file", file, "--kind", kind]); + const readers = (file: string) => [ + runJsonCommand("rust-read", rustInteropBinary, ["read", "--file", file], cultcacheRsRoot), + runJsonCommand("python-read", pythonCommand, ["-m", "cultcache_py.interop", "read", "--file", file], cultcachePyRoot, { PYTHONPATH: cultcachePySrc }), + ]; + const dump = (file: string) => runJsonCommand("python-dump", pythonCommand, [ + "-c", + "import msgpack,json,sys\n" + + "s=msgpack.unpackb(open(sys.argv[1],'rb').read(),raw=False)\n" + + "print(json.dumps([{'key':r[0],'overrides':[o[3] for o in r[4][1]]} if len(r)>4 else {'key':r[0],'payload':msgpack.unpackb(r[3],raw=False)} for r in s[2]]))", + file, + ], cultcachePyRoot); + + const payload = join(tempDir, "payload.cc"); + await csharp("csharp-write", ["write", "--file", payload, "--runtime-id", "csharp-writer"]); + await edit(payload, "payload"); + for (const read of await Promise.all(readers(payload))) assert.equal(read.title, "csharp-writer WROTE a CultCache note"); + + const collection = join(tempDir, "collection.cc"); + await csharp("csharp-write", ["write", "--file", collection, "--runtime-id", "csharp-writer"]); + await edit(collection, "collection"); + for (const read of await Promise.all(readers(collection))) assert.deepEqual(read.tags, ["EDITED", "csharp", "interop"]); + + const override = join(tempDir, "override.cc"); + await csharp("csharp-write", ["write", "--file", override, "--runtime-id", "csharp-writer"]); + await edit(override, "override"); + const variant = (await dump(override)).find((record: any) => record.key === "note:variant"); + assert.deepEqual(variant.overrides, ["note:variant", "VARIANT title"]); + + const object = join(tempDir, "object.cc"); + await csharp("csharp-write-deck", ["write-deck", "--file", object]); + await edit(object, "object"); + const deck = (await dump(object)).find((record: any) => record.key === "deck:csharp"); + assert.equal(deck.payload[1][0][0], "EDITED"); + assert.equal(deck.payload[1][1][0], "twin", "the neighbouring mark is untouched"); +}); + test("CultCache interop reader accepts missing compatible trailing slots and rejects mismatched slots", async () => { const tempDir = await mkdtemp(join(tmpdir(), "cultcache-interop-")); const compatible = join(tempDir, "compatible.msgpack"); @@ -1235,40 +1471,386 @@ test("CultCache inspector decodes v1 store catalog and record payloads from byte ]); }); -test("CultCache inspector recovers schema-stamped records missing catalog entries", async () => { +test("CultCache inspector refuses a record whose schema the catalog does not publish, and a store of the wrong slot count", async () => { const tempDir = await mkdtemp(join(tmpdir(), "cultcache-inspector-")); - const file = join(tempDir, "missing-catalog.cc"); + const record = [ + "record-1", + "sha256:stale-schema-id-from-cold-record", + "2026-06-25T12:00:00Z", + encode(["tests.schema_stamped_entry.v1", "schema-stamped", "still readable"]), + ]; + assert.throws( + () => inspectCultCacheBytes("missing-catalog.cc", encode(["cultcache.store.v1", [], [record]])), + /references missing schema id/u, + ); + assert.throws(() => inspectCultCacheBytes("header-only.cc", encode(["cultcache.store.v1"])), /top-level slots/u); + assert.throws( + () => inspectCultCacheBytes("extra-slot.cc", encode(["cultcache.store.v1", [], [], 0])), + /top-level slots/u, + ); + await rm(tempDir, { recursive: true, force: true }); +}); + +// A record written under an id its schema only lists as compatible is stamped with the registered id, and the store the write +// leaves opens and takes the next write. The envelope carries a catalog entry under the foreign id, as a replicated one does. +const foreignIdDocument = defineDocumentType({ + type: "tests.foreign-id", + schema: z.object({ name: z.string() }), + schemaId: "tests.foreign-id.current", + schemaName: "tests.foreign-id", + schemaVersion: "tests.foreign_id.v1", + compatibleSchemaIds: ["tests.foreign-id.current", "tests.foreign-id.older"], +}); + +async function reopenForeignIdStore(file: string): Promise { + const cache = CultCache.builder() + .withDocumentType(foreignIdDocument) + .withGenericStore(new SingleFileMessagePackBackingStore(file)) + .build(); + await cache.pullAllBackingStores(); + return cache; +} + +function schemaIdsOf(bytes: Uint8Array): { record: string; catalog: string[] } { + const decoded = decode(bytes) as [string, unknown[][], unknown[][]]; + return { record: decoded[2][0]![1] as string, catalog: decoded[1].map((entry) => entry[0] as string) }; +} + +test("putEnvelope under a compatible schema id writes a store that reopens and takes the next write", async () => { + const file = join(await mkdtemp(join(tmpdir(), "cultcache-foreign-id-")), "store.msgpack"); + const cache = await reopenForeignIdStore(file); + const seed = await cache.put(foreignIdDocument, "seed", { name: "seed" }); + const payload = cache.getRequiredEnvelope(foreignIdDocument, "seed").payload; + + await cache.putEnvelope(foreignIdDocument, { + key: "foreign", + type: "tests.foreign-id", + schemaId: "tests.foreign-id.older", + payload, + storedAt: "2026-09-30T00:00:00.0000000Z", + catalogEntry: { + schemaId: "tests.foreign-id.older", + schemaName: "tests.foreign-id", + schemaVersion: "tests.foreign_id.v1", + contentHash: "tests.foreign-id.older", + canonicalSchemaJson: "", + compatibleSchemaIds: ["tests.foreign-id.older"], + members: [], + }, + }); + + const ids = schemaIdsOf(await readFile(file)); + assert.deepEqual(ids, { record: "tests.foreign-id.current", catalog: ["tests.foreign-id.current"] }); + const reopened = await reopenForeignIdStore(file); + assert.deepEqual(reopened.getRequired(foreignIdDocument, "foreign"), seed); + await reopened.put(foreignIdDocument, "next", { name: "next" }); + assert.deepEqual((await reopenForeignIdStore(file)).getRequired(foreignIdDocument, "next"), { name: "next" }); +}); + +test("the store publishes a record's schema id even when the envelope's catalog entry does not", async () => { + const file = join(await mkdtemp(join(tmpdir(), "cultcache-store-publishes-")), "store.msgpack"); + const store = new SingleFileMessagePackBackingStore(file); + await store.push({ + key: "k", + type: "tests.store-publishes", + schemaId: "tests.store-publishes.record", + payload: documentPayloadFromBytes(encode({ name: "k" })), + storedAt: "2026-09-30T00:00:00.0000000Z", + catalogEntry: { + schemaId: "tests.store-publishes.other", + schemaName: "tests.store-publishes", + schemaVersion: "tests.store_publishes.v1", + contentHash: "tests.store-publishes.other", + canonicalSchemaJson: "", + compatibleSchemaIds: ["tests.store-publishes.other"], + members: [], + }, + }); + assert.deepEqual(schemaIdsOf(await readFile(file)), { record: "tests.store-publishes.record", catalog: ["tests.store-publishes.record"] }); + assert.equal((await new SingleFileMessagePackBackingStore(file).pullAll()).length, 1); +}); + +// The catalog a write leaves is derived from its records: one entry per carried id, chosen and never merged, the same whatever order +// the records arrive in. A registered (supplied) entry wins over an arrived (read back) entry with the same own id. +const writerEntry = (schemaId: string, schemaName: string, contentHash: string, compatibleSchemaIds: string[]) => ({ + schemaId, + schemaName, + schemaVersion: `${schemaName}.v1`, + contentHash, + canonicalSchemaJson: "", + compatibleSchemaIds, + members: [], +}); +const writerRecord = (key: string, type: string, schemaId: string, catalogEntry?: ReturnType) => ({ + key, + type, + schemaId, + payload: documentPayloadFromBytes(encode({ key })), + storedAt: "2026-09-30T00:00:00.0000000Z", + ...(catalogEntry ? { catalogEntry } : {}), +}); +const rawCatalogEntry = (entry: ReturnType) => [ + entry.schemaId, + entry.schemaName, + entry.schemaVersion, + entry.contentHash, + entry.canonicalSchemaJson, + entry.compatibleSchemaIds, + [], +]; +const catalogOf = async (file: string): Promise => + (decode(await readFile(file)) as [string, unknown[][], unknown[][]])[1]; + +test("a registered entry is written as it is, over the arrived entries with the same own id, in either order", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-registered-wins-")); + const stale1 = writerEntry("tests.x", "tests.n", "a-stale-1", ["tests.x", "old"]); + const stale2 = writerEntry("tests.x", "tests.n", "a-stale-2", ["tests.x"]); + const registered = writerRecord("a", "tests.n", "tests.x", writerEntry("tests.x", "tests.n", "fresh", ["tests.x"])); + + for (const [name, entries] of [["12", [stale1, stale2]], ["21", [stale2, stale1]]] as const) { + const file = join(dir, `${name}.msgpack`); + await writeFile( + file, + encode(["cultcache.store.v1", entries.map(rawCatalogEntry), [["b", "tests.x", "2026-09-30T00:00:00.0000000Z", encode({ b: 1 })]]]), + ); + await new SingleFileMessagePackBackingStore(file).push(registered); + const catalog = await catalogOf(file); + assert.equal(catalog.length, 1, name); + assert.equal(catalog[0]![3], "fresh", `${name}: the registered entry's hash is written`); + assert.deepEqual(catalog[0]![5], ["tests.x"], `${name}: written as chosen, no union with the arrived entries' ids`); + } +}); + +test("a rename with a stable schema id: a whole-view write names the registered schema", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-rename-")); + const file = join(dir, "rename.msgpack"); + await writeFile( + file, + encode(["cultcache.store.v1", [rawCatalogEntry(writerEntry("tests.x", "tests.old", "stale", ["tests.x"]))], [["a", "tests.x", "2026-09-30T00:00:00.0000000Z", encode({ a: 1 })]]]), + ); + await new SingleFileMessagePackBackingStore(file).pushAll([writerRecord("a", "tests.new", "tests.x", writerEntry("tests.x", "tests.new", "fresh", ["tests.x"]))]); + assert.deepEqual((await catalogOf(file)).map((entry) => [entry[1], entry[3]]), [["tests.new", "fresh"]]); +}); + +test("two entries of one tier that share an own id and disagree on the schema name refuse the write, typed", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-disagree-")); + const first = writerRecord("b", "tests.first", "tests.x", writerEntry("tests.x", "tests.first", "h1", ["tests.x"])); + const second = writerRecord("a", "tests.first", "tests.x", writerEntry("tests.x", "tests.second", "h2", ["tests.x"])); + for (const [name, order] of [["fs", [first, second]], ["sf", [second, first]]] as const) { + const file = join(dir, `${name}.msgpack`); + await assert.rejects( + () => new SingleFileMessagePackBackingStore(file).pushAll([...order]), + (error) => + error instanceof SchemaConflictError && + error.schemaId === "tests.x" && + error.recordKey === "a" && + [...error.schemaNames].sort().join() === "tests.first,tests.second", + name, + ); + } +}); + +test("records of different types under one schema id refuse the write, typed, and no record changes type", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-retype-")); + const a = writerRecord("a", "tests.n", "tests.y", writerEntry("tests.x", "tests.n", "h1", ["tests.x", "tests.y"])); + const b = writerRecord("b", "tests.m", "tests.y", writerEntry("tests.y", "tests.m", "h2", ["tests.y"])); + + for (const [name, order] of [["ab", [a, b]], ["ba", [b, a]]] as const) { + const file = join(dir, `${name}.msgpack`); + await assert.rejects( + () => new SingleFileMessagePackBackingStore(file).pushAll([...order]), + (error) => error instanceof SchemaConflictError && error.schemaId === "tests.y" && error.schemaNames.join() === "tests.m,tests.n", + name, + ); + } +}); + +test("an entry that owns an id is chosen over one that lists it, and an entry no record needs is not written", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-own-first-")); + const owner = writerRecord("a", "tests.n", "tests.y", writerEntry("tests.y", "tests.n", "h1", ["tests.y"])); + const lister = writerRecord("b", "tests.n", "tests.x", writerEntry("tests.x", "tests.n", "h2", ["tests.x", "tests.y"])); + const onlyOwner = join(dir, "own.msgpack"); + await new SingleFileMessagePackBackingStore(onlyOwner).pushAll([owner]); + assert.deepEqual((await catalogOf(onlyOwner)).map((entry) => entry[0]), ["tests.y"]); + + // The lister carried only its own id: its list of y is not needed, and the owner's entry publishes y. + for (const [name, order] of [["lo", [lister, owner]], ["ol", [owner, lister]]] as const) { + const file = join(dir, `${name}.msgpack`); + await new SingleFileMessagePackBackingStore(file).pushAll([...order]); + const catalog = await catalogOf(file); + assert.deepEqual(catalog.map((entry) => [entry[0], entry[5]]).sort(), [["tests.x", ["tests.x", "tests.y"]], ["tests.y", ["tests.y"]]], name); + } +}); + +test("entries of one tier that tie are taken in one fixed order, and a registered entry that lists an id is chosen over an arrived one", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-tie-")); + // The content hash orders them against the order their compatible ids would give: h1 sorts first, its list sorts last. + const one = writerRecord("a", "tests.n", "tests.x", writerEntry("tests.x", "tests.n", "h2", ["tests.x"])); + const two = writerRecord("b", "tests.n", "tests.x", writerEntry("tests.x", "tests.n", "h1", ["tests.x", "tests.y"])); + for (const [name, order] of [["12", [one, two]], ["21", [two, one]]] as const) { + const file = join(dir, `${name}.msgpack`); + await new SingleFileMessagePackBackingStore(file).pushAll([...order]); + assert.deepEqual((await catalogOf(file)).map((entry) => entry[3]), ["h1"], name); + } + + // z sits under old, which the arrived entry (id x) lists; the supplied entry (id r) lists it too: the registered one publishes it. + const file = join(dir, "lister.msgpack"); await writeFile( file, encode([ "cultcache.store.v1", - [], - [ - [ - "record-1", - "sha256:stale-schema-id-from-cold-record", - "2026-06-25T12:00:00Z", - encode([ - "tests.schema_stamped_entry.v1", - "schema-stamped", - "still readable", - ]), - ], - ], + [rawCatalogEntry(writerEntry("tests.x", "tests.n", "stale", ["tests.x", "old"]))], + [["z", "old", "2026-09-30T00:00:00.0000000Z", encode({ z: 1 })]], + ]), + ); + await new SingleFileMessagePackBackingStore(file).push(writerRecord("a", "tests.n", "tests.r", writerEntry("tests.r", "tests.n", "fresh", ["tests.r", "old"]))); + assert.deepEqual((await catalogOf(file)).map((entry) => entry[0]), ["tests.r"]); +}); + +// A registered entry that owns an id keeps it against an arrived entry that owns it too and lists the id another record sits under, +// whichever of the two ids sorts first: the arrived entry's list is not merged in, so the record under the listed id is published by +// no chosen entry. +for (const [ownId, listedId] of [["tests.a", "tests.b"], ["tests.b", "tests.a"]] as const) { + test(`an arrived entry that owns a registered id does not replace it nor publish what it lists (${ownId})`, async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-guard-")); + const file = join(dir, "guard.msgpack"); + await writeFile( + file, + encode([ + "cultcache.store.v1", + [rawCatalogEntry(writerEntry(ownId, "tests.arrived", "stale", [ownId, listedId]))], + [["k2", listedId, "2026-09-30T00:00:00.0000000Z", encode({ k: 2 })]], + ]), + ); + const before = await readFile(file); + await assert.rejects( + () => new SingleFileMessagePackBackingStore(file).push(writerRecord("k1", "tests.registered", ownId, writerEntry(ownId, "tests.registered", "fresh", [ownId]))), + (error) => error instanceof SchemaConflictError && error.schemaId === listedId && error.recordKey === "k2", + ); + assert.ok(before.equals(await readFile(file)), "the file is left as it was"); + }); +} + +// Ids are taken in sorted order, so which entry survives, and so which id the refusal names, does not depend on the order the records +// arrive in. +test("the refusal names the same id whatever order the records arrive in", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-sorted-")); + const one = writerRecord("r1", "tests.n", "tests.y", writerEntry("tests.x", "tests.n", "h1", ["tests.x", "tests.y"])); + const two = writerRecord("r2", "tests.n", "tests.z", writerEntry("tests.x", "tests.n", "h2", ["tests.x", "tests.z"])); + for (const [name, order] of [["yz", [one, two]], ["zy", [two, one]]] as const) { + await assert.rejects( + () => new SingleFileMessagePackBackingStore(join(dir, `${name}.msgpack`)).pushAll([...order]), + (error) => error instanceof SchemaConflictError && error.schemaId === "tests.y" && error.recordKey === "r1", + name, + ); + } +}); + +// A write may not retype records already in the file: across tiers the id keeps the type its records resolve to, unless the entries of +// both tiers own it, which is a rename under a stable id and the registered descriptor wins. +test("a write that would change the type an existing record resolves to refuses, typed, and leaves the file", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-cross-tier-")); + const at = "2026-09-30T00:00:00.0000000Z"; + const cases = [ + { + name: "registered entry lists the id, arrived entry owns it", + catalog: writerEntry("tests.x", "tests.arrived", "h1", ["tests.x"]), + registered: writerRecord("a", "tests.registered", "tests.x", writerEntry("tests.r", "tests.registered", "h2", ["tests.r", "tests.x"])), + }, + { + name: "registered entry owns the id, arrived entry lists it", + catalog: writerEntry("tests.r", "tests.arrived", "h1", ["tests.r", "tests.x"]), + registered: writerRecord("a", "tests.registered", "tests.x", writerEntry("tests.x", "tests.registered", "h2", ["tests.x"])), + }, + ]; + for (const { name, catalog, registered } of cases) { + const file = join(dir, `${name}.msgpack`); + await writeFile(file, encode(["cultcache.store.v1", [rawCatalogEntry(catalog)], [["z", "tests.x", at, encode({ z: 1 })]]])); + const before = await readFile(file); + await assert.rejects( + () => new SingleFileMessagePackBackingStore(file).push(registered), + (error) => + error instanceof SchemaConflictError && + error.schemaId === "tests.x" && + [...error.schemaNames].sort().join() === "tests.arrived,tests.registered", + name, + ); + assert.ok(before.equals(await readFile(file)), `${name}: the file is left as it was`); + } + + // The same registered record renames the schema when both tiers own the id: it writes, and the reader finds every record. + const file = join(dir, "rename.msgpack"); + await writeFile(file, encode(["cultcache.store.v1", [rawCatalogEntry(writerEntry("tests.x", "tests.arrived", "h1", ["tests.x"]))], [["z", "tests.x", at, encode({ z: 1 })]]])); + await new SingleFileMessagePackBackingStore(file).push(writerRecord("a", "tests.registered", "tests.x", writerEntry("tests.x", "tests.registered", "h2", ["tests.x"]))); + assert.deepEqual((await catalogOf(file)).map((entry) => [entry[0], entry[1]]), [["tests.x", "tests.registered"]]); + assert.equal((await new SingleFileMessagePackBackingStore(file).pullAll()).length, 2); +}); + +test("a record no chosen entry publishes refuses the write, typed, and leaves the file", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-orphan-")); + const file = join(dir, "orphan.msgpack"); + // z sits under old, which only the arrived entry (id x) lists; the supplied entry owns x and does not. + await writeFile( + file, + encode([ + "cultcache.store.v1", + [rawCatalogEntry(writerEntry("tests.x", "tests.legacy", "stale", ["tests.x", "old"]))], + [["z", "old", "2026-09-30T00:00:00.0000000Z", encode({ z: 1 })]], + ]), + ); + const before = await readFile(file); + await assert.rejects( + () => new SingleFileMessagePackBackingStore(file).push(writerRecord("a", "tests.legacy", "tests.x", writerEntry("tests.x", "tests.legacy", "fresh", ["tests.x"]))), + (error) => error instanceof SchemaConflictError && error.schemaId === "old" && error.recordKey === "z", + ); + assert.ok(before.equals(await readFile(file)), "the file is left as it was"); +}); + +test("an id an entry owns names that entry, not one that lists it as compatible", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-own-id-")); + for (const vector of ["own-id-over-compatible-v3.bin", "compatible-before-owner-v3.bin"]) { + const file = await copyVector(dir, join(c2aVectors, "readability", vector), "own.msgpack"); + const envelopes = await new SingleFileMessagePackBackingStore(file).pullAll(); + assert.deepEqual(envelopes.map((envelope) => envelope.type), ["vectors.item", "vectors.item"], vector); + const inspection = inspectCultCacheBytes("own.msgpack", await readFile(file)); + assert.deepEqual(inspection.records.map((record) => record.schemaName), ["vectors.item", "vectors.item"], vector); + } +}); + +// The inspector reads what the reader reads: a record under an id its catalog lists only as compatible is inspected. +test("CultCache inspector reads a record whose schema the catalog publishes only as a compatible id", async () => { + const bytes = await readFile(join(c2aVectors, "readability", "compatible-id-only-v3.bin")); + const inspection = inspectCultCacheBytes("compatible.msgpack", bytes); + assert.deepEqual(inspection.records.map((record) => record.schemaName), ["vectors.item", "vectors.item"]); +}); + +test("a record loaded under a compatible schema id is written back under the id its catalog entry carries", async () => { + const file = join(await mkdtemp(join(tmpdir(), "cultcache-foreign-id-")), "store.msgpack"); + const payload = encode({ name: "old" }); + await writeFile( + file, + encode([ + "cultcache.store.v1", + [[ + "tests.foreign-id.current", + "tests.foreign-id", + "tests.foreign_id.v1", + "tests.foreign-id.current", + "", + ["tests.foreign-id.current", "tests.foreign-id.older"], + [], + ]], + [["old", "tests.foreign-id.older", "2026-09-30T00:00:00.0000000Z", payload]], ]), ); - const inspection = inspectCultCacheBytes(file, await readFile(file)); - assert.equal(inspection.catalog.length, 1); - assert.equal(inspection.catalog[0]?.schemaId, "sha256:stale-schema-id-from-cold-record"); - assert.equal(inspection.catalog[0]?.schemaName, "tests.schema_stamped_entry"); - assert.equal(inspection.records[0]?.schemaName, "tests.schema_stamped_entry"); - assert.deepEqual(inspection.records[0]?.payloadPreview, [ - "tests.schema_stamped_entry.v1", - "schema-stamped", - "still readable", - ]); + const cache = await reopenForeignIdStore(file); + assert.equal(cache.getRequiredEnvelope(foreignIdDocument, "old").schemaId, "tests.foreign-id.current"); + await cache.put(foreignIdDocument, "next", { name: "next" }); + const reopened = await reopenForeignIdStore(file); + assert.deepEqual(reopened.getRequired(foreignIdDocument, "old"), { name: "old" }); + assert.deepEqual(reopened.getRequired(foreignIdDocument, "next"), { name: "next" }); }); interface InteropNote { @@ -1481,6 +2063,152 @@ test("SingleFileMessagePackBackingStore refusals say what was found without blam ); }); +test("SingleFileMessagePackBackingStore refusal says what was found and names every format it reads", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-refusal-")); + const file = join(dir, "store.cc"); + const reads = 'it reads "cultcache.store.v1" and "cultcache.store.v3" only.'; + for (const [header, found] of [ + ["cultcache.store.v12", '"cultcache.store.v12"'], + ["cultcache.store.SECRET", "an unrecognised cultcache.store.* header of 22 bytes"], + ["cultcache.store.v", "an unrecognised cultcache.store.* header of 17 bytes"], + // Digits are ASCII only: two Arabic-Indic digits are four bytes and are not the known shape. + ["cultcache.store.v١٢", "an unrecognised cultcache.store.* header of 21 bytes"], + ] as const) { + await writeFile(file, encode([header, [], []])); + await assert.rejects( + () => new SingleFileMessagePackBackingStore(file).pullAll(), + (error: Error) => error.message.includes(`CultCache store format ${found} is not one this runtime reads; ${reads}`), + header, + ); + } +}); + +function headerRefused(bytes: Uint8Array): boolean { + try { + refuseUnreadStoreBytes(bytes); + return false; + } catch (error) { + assert.match((error as Error).message, /not one this runtime reads/u); + return true; + } +} + +function storeStart(arrayLead: number[], text: string, strForm: "fix" | "8" | "16" | "32"): Uint8Array { + const body = Buffer.from(text, "utf8"); + const n = body.length; + const tag = + strForm === "fix" ? [0xa0 | n] + : strForm === "8" ? [0xd9, n] + : strForm === "16" ? [0xda, n >> 8, n & 0xff] + : [0xdb, 0, 0, n >> 8, n & 0xff]; + return new Uint8Array([...arrayLead, ...tag, ...body]); +} + +test("a store's header is judged from its first bytes, in every MessagePack array and string form", () => { + const leads = [[0x91], [0x9f], [0xdc, 0x00, 0x01], [0xdc, 0x01, 0x00], [0xdd, 0x00, 0x00, 0x00, 0x01], [0xdd, 0x00, 0x01, 0x00, 0x00]]; + for (const lead of leads) { + for (const form of ["fix", "8", "16", "32"] as const) { + const v4 = storeStart(lead, "cultcache.store.v4", form); + assert.equal(headerRefused(v4), true, `${lead} ${form} v4`); + assert.equal(headerRefused(v4.subarray(0, v4.length - 1)), false, `${lead} ${form} v4 one byte short`); + assert.equal(headerRefused(storeStart(lead, "cultcache.store.v1", form)), false, `${lead} ${form} v1`); + assert.equal(headerRefused(storeStart(lead, "cultcache.store.v3", form)), false, `${lead} ${form} v3`); + assert.equal(headerRefused(storeStart(lead, "other.store.v4", form)), false, `${lead} ${form} other`); + assert.equal(headerRefused(storeStart(lead, "", form)), false, `${lead} ${form} empty`); + } + } + // The longest fixstr, and a string long enough to need str16. + assert.equal(headerRefused(storeStart([0x91], `cultcache.store.v4${"x".repeat(13)}`, "fix")), true); + assert.equal(headerRefused(storeStart([0x91], `cultcache.store.v4${"x".repeat(300)}`, "16")), true); + assert.equal(headerRefused(storeStart([0x91], `cultcache.store.v4${"x".repeat(300)}`, "32")), true); + // A view into a larger buffer is read from its own start, in every form that reads a length through the DataView. + for (const lead of leads) { + for (const form of ["fix", "8", "16", "32"] as const) { + const padded = new Uint8Array([7, 7, 7, ...storeStart(lead, "cultcache.store.v4", form)]); + assert.equal(padded.subarray(3).byteOffset, 3); + assert.equal(headerRefused(padded.subarray(3)), true, `${lead} ${form} v4 at a byte offset`); + } + } + // The stamp is judged as written: a stamp with a space is not the stamp this runtime reads, and not one it ignores. + assert.equal(headerRefused(storeStart([0x91], "cultcache.store.v3 ", "fix")), true, "a v3 stamp with a trailing space is a header this runtime does not read"); + assert.equal(headerRefused(storeStart([0x91], " cultcache.store.v4", "fix")), false, "a leading space makes it no cultcache.store.* header"); +}); + +test("bytes with no readable store stamp are not refused", () => { + const stamp = [...Buffer.from("cultcache.store.v4", "utf8")]; + const cases: Record = { + empty: [], + "empty array": [0x90, 0xb2, ...stamp], + "empty array16": [0xdc, 0x00, 0x00, 0xb2, ...stamp], + "empty array32": [0xdd, 0x00, 0x00, 0x00, 0x00, 0xb2, ...stamp], + "array16 cut before its count": [0xdc, 0x00], + "array32 cut before its count": [0xdd, 0x00, 0x00, 0x00], + "array with no element": [0x91], + "first element is not a string": [0x92, 0x01, 0xb2, ...stamp], + "a bare string": [0xb2, ...stamp], + "a map": [0x81, 0xb2, ...stamp], + "str8 cut before its length": [0x91, 0xd9], + "str16 cut before its length": [0x91, 0xda, 0x00], + "str32 cut before its length": [0x91, 0xdb, 0x00, 0x00, 0x00], + }; + for (const [name, bytes] of Object.entries(cases)) { + assert.equal(headerRefused(new Uint8Array(bytes)), false, name); + } +}); + +test("a newer store is refused by name whatever its body: truncated, undecodable or trailing, by a read, a whole write and the inspector", async () => { + const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" }; + const whole = encode(["cultcache.store.v4", [], [["k", "s", "t", new Uint8Array([0x90])]]]); + const bodies: Record = { + truncated: whole.subarray(0, whole.length - 3), + "header only": whole.subarray(0, 20), + undecodable: Uint8Array.of(...whole.subarray(0, 20), 0xc1), + trailing: Uint8Array.of(...whole, 0xff), + whole, + }; + for (const [name, bytes] of Object.entries(bodies)) { + const file = join(await mkdtemp(join(tmpdir(), "cultcache-newer-")), "store.msgpack"); + await writeFile(file, bytes); + const store = new SingleFileMessagePackBackingStore(file); + await assert.rejects(() => store.pullAll(), /not one this runtime reads/u, `${name} read`); + await assert.rejects(() => store.pushAll([envelope]), /not one this runtime reads/u, `${name} pushAll`); + await assert.rejects(() => store.pushAll([envelope], { soft: true }), /not one this runtime reads/u, `${name} soft pushAll`); + assert.throws(() => inspectCultCacheBytes(file, bytes), /not one this runtime reads/u, `${name} inspect`); + assert.deepEqual([...(await readFile(file))], [...bytes], `${name} left as it was`); + } +}); + +test("a stamp this runtime reads, followed by bytes that are not UTF-8, gets the same refusal from a read and from a whole write", async () => { + const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" }; + for (const header of ["cultcache.store.v1", "cultcache.store.v3"]) { + const stamp = [...Buffer.from(header, "utf8")]; + const forms = { fixstr: [0x91, 0xa0 | (stamp.length + 1), ...stamp, 0xff], str8: [0x91, 0xd9, stamp.length + 1, ...stamp, 0xff] }; + for (const [name, bytes] of Object.entries(forms)) { + const file = join(await mkdtemp(join(tmpdir(), "cultcache-stamp-")), "store.msgpack"); + await writeFile(file, new Uint8Array(bytes)); + const store = new SingleFileMessagePackBackingStore(file); + await assert.rejects(() => store.pullAll(), /not one this runtime reads/u, `${header} ${name} read`); + await assert.rejects(() => store.pushAll([envelope]), /not one this runtime reads/u, `${header} ${name} pushAll`); + assert.deepEqual([...(await readFile(file))], bytes, `${header} ${name} left as it was`); + } + } +}); + +test("pushAll leaves a store it reads alone when soft, and replaces one it reads otherwise", async () => { + const vector = await readFile(resolve(cultLibRoot, "tests", "vectors", "document-variants-c2a", "v3-base.msgpack")); + const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" }; + const existing = join(await mkdtemp(join(tmpdir(), "cultcache-pushall-soft-")), "store.msgpack"); + await writeFile(existing, vector); + await new SingleFileMessagePackBackingStore(existing).pushAll([envelope], { soft: true }); + assert.equal(Buffer.compare(await readFile(existing), vector), 0); + + const own = join(await mkdtemp(join(tmpdir(), "cultcache-pushall-own-")), "store.msgpack"); + const store = new SingleFileMessagePackBackingStore(own); + await store.pushAll([envelope]); + await store.pushAll([{ ...envelope, key: "second" }]); + assert.deepEqual((await store.pullAll()).map((entry) => entry.key), ["second"]); +}); + test("SingleFileMessagePackBackingStore reads only a missing store as empty", async () => { const dir = await mkdtemp(join(tmpdir(), "cultcache-absent-")); assert.deepEqual(await new SingleFileMessagePackBackingStore(join(dir, "store.cc")).pullAll(), []); @@ -1497,6 +2225,66 @@ test("SingleFileMessagePackBackingStore reads only a missing store as empty", as assert.deepEqual(await new SingleFileMessagePackBackingStore(legacyEmpty).pullAll(), []); }); +test("reading a store that is not there writes nothing, and a read of a store that is readable does not rewrite it", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-read-writes-nothing-")); + const missing = join(dir, "store.cc"); + assert.deepEqual(await new SingleFileMessagePackBackingStore(missing).pullAll(), []); + assert.equal(existsSync(missing), false); + assert.deepEqual(await readdir(dir), []); + + const written = join(dir, "written.cc"); + const envelope = { key: "k", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }; + await new SingleFileMessagePackBackingStore(written).push(envelope); + const before = await readFile(written); + await new SingleFileMessagePackBackingStore(written).pullAll(); + assert.deepEqual(await readFile(written), before); +}); + +test("a soft pushAll writes where nothing is, leaves a readable store alone, and refuses what the reader refuses", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-soft-")); + const envelope = { key: "k", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }; + const other = { key: "other", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x91, 0x01)), storedAt: "2026-09-30T00:00:00Z" }; + + // Absent: the soft write seeds the store. + const absent = join(dir, "absent.cc"); + await new SingleFileMessagePackBackingStore(absent).pushAll([envelope], { soft: true }); + assert.deepEqual((await new SingleFileMessagePackBackingStore(absent).pullAll()).map((entry) => entry.key), ["k"]); + + // Readable: the soft write is skipped, byte for byte. + const seeded = await readFile(absent); + await new SingleFileMessagePackBackingStore(absent).pushAll([other], { soft: true }); + assert.deepEqual(await readFile(absent), seeded); + + // Unreadable: refused, as pullAll refuses it, and the file is left exactly as it was. + const refused: Array<[string, Uint8Array]> = [ + ["zero-byte", new Uint8Array()], + ["unknown-format", encode(["cultcache.store.v9", [], []])], + ["bin-header", Uint8Array.of(0x93, 0xc4, 0x01, 0x76, 0x90, 0x90)], + ["truncated", seeded.subarray(0, seeded.length - 1)], + ["garbage", Uint8Array.of(0xc1)], + ]; + for (const [name, bytes] of refused) { + const file = join(dir, `${name}.cc`); + await writeFile(file, bytes); + await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pullAll(), StoreUnreadableError, name); + await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pushAll([other], { soft: true }), StoreUnreadableError, name); + assert.deepEqual(await readFile(file), Buffer.from(bytes), name); + } +}); + +test("a header that does not start at the store prefix is not echoed whole, though it ends like a version", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-forged-header-")); + const file = join(dir, "store.cc"); + for (const header of ["cultcache.store.SECRETcultcache.store.v9", "cultcache.store.\ncultcache.store.v9", "SECRETcultcache.store.v9"]) { + await writeFile(file, encode([header, [], []])); + await assert.rejects( + () => new SingleFileMessagePackBackingStore(file).pullAll(), + (error: Error) => error instanceof StoreUnreadableError && !error.message.includes("SECRET") && !error.message.includes(header), + header, + ); + } +}); + test("SingleFileMessagePackBackingStore reports a store it cannot reach as an I/O error", { skip: process.platform === "win32" }, async () => { const dir = await mkdtemp(join(tmpdir(), "cultcache-unreachable-")); const loop = join(dir, "loop.cc"); @@ -1545,13 +2333,39 @@ test("SingleFileMessagePackBackingStore refuses a dangling symlink at its path a await symlink(join(volume, "store.cc"), file); const store = new SingleFileMessagePackBackingStore(file); const envelope = { key: "k", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }; - await assert.rejects(() => store.pullAll(), (error: NodeJS.ErrnoException) => error.code === "ENOENT"); + await assert.rejects( + () => store.pullAll(), + (error: NodeJS.ErrnoException) => error.code === "ENOENT" && error.message.includes("symbolic link") && error.message.includes(file), + ); await assert.rejects(() => store.push(envelope), (error: NodeJS.ErrnoException) => error.code === "ENOENT"); await assert.rejects(() => store.pushAll([envelope], { soft: true }), (error: NodeJS.ErrnoException) => error.code === "ENOENT"); assert.ok((await lstat(file)).isSymbolicLink()); assert.deepEqual(await readdir(volume), []); }); +// The second look of the read, staged: nothing is at the path only when the read and the lstat both find nothing. A path +// that changed between them (a link in a parent swapped for a file, a store that appeared) is never an empty store. +test("readStoreBytes reports nothing at the path only when the lstat finds nothing too", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-second-look-")); + const file = join(dir, "store.cc"); + const failing = (code: string) => () => { + throw Object.assign(new Error(code), { code }); + }; + assert.equal(readStoreBytes(file, failing("ENOENT")), undefined); + for (const code of ["ENOTDIR", "EACCES", "ELOOP"]) { + assert.throws(() => readStoreBytes(file, failing(code)), (error: NodeJS.ErrnoException) => error.code === code, code); + } + + assert.throws( + () => readStoreBytes(file, () => ({ isSymbolicLink: () => true })), + (error: NodeJS.ErrnoException) => error.code === "ENOENT" && error.message.includes("symbolic link"), + ); + assert.throws( + () => readStoreBytes(file, () => ({ isSymbolicLink: () => false })), + (error: NodeJS.ErrnoException) => error.code === "ENOENT" && error.message.includes("appeared while it was being read"), + ); +}); + test("SingleFileMessagePackBackingStore record refusal names only a string key", async () => { const dir = await mkdtemp(join(tmpdir(), "cultcache-record-key-")); const file = join(dir, "store.cc"); @@ -1609,6 +2423,242 @@ test("a v1 store written at the base commit still reads byte for byte", async () assert.deepEqual([...documentPayloadBytes(envelopes[1]!.payload)], [0x92, 0xa4, 0x62, 0x65, 0x74, 0x61, 0x02]); }); +// The element-id marker: tests/vectors/document-variants-c2a/v3-base.msgpack is v1-base with its header replaced. +const c2aVectors = resolve(cultLibRoot, "tests", "vectors", "document-variants-c2a"); + +async function copyVector(directory: string, source: string, name: string): Promise { + const file = join(directory, name); + await writeFile(file, await readFile(source)); + return file; +} + +test("SingleFileMessagePackBackingStore pushAll applies the header the file on disk carries", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-v3-pushall-")); + const header = async (file: string) => (decode(await readFile(file)) as unknown[])[0]; + + // A store that never read the file still keeps a marked file marked. + const v3 = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "v3.msgpack"); + const envelopes = await new SingleFileMessagePackBackingStore(v3).pullAll(); + await new SingleFileMessagePackBackingStore(v3).pushAll(envelopes); + assert.equal(await header(v3), "cultcache.store.v3"); + + // A store that read a marked file, whose file is then gone, writes unmarked: the disk decides, not the last read. + const reader = new SingleFileMessagePackBackingStore(v3); + await reader.pullAll(); + await rm(v3); + await reader.pushAll(envelopes); + assert.equal(await header(v3), "cultcache.store.v1"); + + const v1 = await copyVector(dir, join(variantVectors, "v1-base.msgpack"), "v1.msgpack"); + await new SingleFileMessagePackBackingStore(v1).pushAll(envelopes); + assert.equal(await header(v1), "cultcache.store.v1"); +}); + +test("SingleFileMessagePackBackingStore push after a read of a marked file that is gone writes v1", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-v3-gone-")); + const v3 = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "v3.msgpack"); + const store = new SingleFileMessagePackBackingStore(v3); + const envelopes = await store.pullAll(); + await rm(v3); + await store.push(envelopes[0]!); + assert.equal((decode(await readFile(v3)) as unknown[])[0], "cultcache.store.v1"); +}); + +test("SingleFileMessagePackBackingStore reads a v3 element-id store and a rewrite keeps the marker", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-v3-")); + const v3 = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "v3.msgpack"); + const store = new SingleFileMessagePackBackingStore(v3); + const envelopes = await store.pullAll(); + assert.deepEqual(envelopes.map((entry) => entry.key), ["alpha", "beta"]); + assert.deepEqual([...documentPayloadBytes(envelopes[0]!.payload)], [0x92, 0xa5, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x01]); + await store.push({ ...envelopes[0]!, storedAt: "2026-09-30T00:00:00.0000000Z" }); + assert.equal((decode(await readFile(v3)) as unknown[])[0], "cultcache.store.v3"); + + // A v1 store stays v1: the marker is carried, never invented. + const v1 = await copyVector(dir, join(variantVectors, "v1-base.msgpack"), "v1.msgpack"); + const plain = new SingleFileMessagePackBackingStore(v1); + const plainEnvelopes = await plain.pullAll(); + await plain.push({ ...plainEnvelopes[0]!, storedAt: "2026-09-30T00:00:00.0000000Z" }); + assert.equal((decode(await readFile(v1)) as unknown[])[0], "cultcache.store.v1"); + + const inspection = inspectCultCacheBytes("v3.msgpack", await readFile(join(c2aVectors, "v3-base.msgpack"))); + assert.equal(inspection.format, "cultcache.store.v3"); +}); + +test("SingleFileMessagePackBackingStore pushAll refuses a store whose header it cannot read and leaves the file untouched", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-pushall-refused-")); + const envelopes = await new SingleFileMessagePackBackingStore( + await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "seed.msgpack"), + ).pullAll(); + for (const vector of [ + join(variantVectors, "unknown-header.msgpack"), + join(variantVectors, "variant-v2.msgpack"), + resolve(cultLibRoot, "tests", "vectors", "document-variants-c1", "variant-store.msgpack"), + ]) { + const file = await copyVector(dir, vector, "store.msgpack"); + const before = await readFile(file); + await assert.rejects( + () => new SingleFileMessagePackBackingStore(file).pushAll(envelopes), + (error) => error instanceof StoreUnreadableError && /is not readable/u.test(error.message), + vector, + ); + assert.deepEqual(await readFile(file), before, `${vector} was rewritten`); + } +}); + +// A file is replaced by a rewrite exactly when this runtime's own reader opens it: one verdict per file, asked by pullAll, +// pushAll, soft pushAll, push and delete alike; a soft flush leaves the files that read as they are. The bytes and every runtime's verdict: tests/vectors/document-variants-c2a/readability. +test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", async () => { + const root = join(c2aVectors, "readability"); + const rows = (await readFile(join(root, "manifest.txt"), "utf8")) + .split(/\r?\n/u) + .filter((line) => line.length > 0 && !line.startsWith("#")) + .map((line) => line.split(/\s+/u)); + // Every vector in the folder has a manifest row. + const listed = rows.map(([vector]) => vector!).filter((vector) => !vector.startsWith("..")).sort(); + assert.deepEqual((await readdir(root)).filter((name) => name.endsWith(".bin")).sort(), listed); + const dir = await mkdtemp(join(tmpdir(), "cultcache-readability-")); + const seed = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "seed.msgpack"); + const envelopes = await new SingleFileMessagePackBackingStore(seed).pullAll(); + for (const [vector, , , typescript] of rows) { + const reads = typescript === "reads"; + const bytes = await readFile(join(root, vector)); + for (const operation of ["pullAll", "softPushAll", "pushAll", "push", "delete"] as const) { + const file = join(dir, `${vector.replace(/[^a-z0-9.-]/giu, "_")}.${operation}.msgpack`); + await writeFile(file, bytes); + const store = new SingleFileMessagePackBackingStore(file); + const run = () => + operation === "pullAll" + ? store.pullAll() + : operation === "softPushAll" + ? store.pushAll(envelopes, { soft: true }) + : operation === "pushAll" + ? store.pushAll(envelopes) + : operation === "push" + ? store.push(envelopes[0]!) + : store.delete(envelopes[0]!); + if (reads) { + await run(); + if (operation === "pullAll") { + continue; + } + + // A soft flush leaves a store it reads byte for byte: nothing is written where something is. + if (operation === "softPushAll") { + assert.ok(bytes.equals(await readFile(file)), `${vector} ${operation} rewrote a store it reads`); + continue; + } + + // The replaced file is a store: it carries the header its old content decides (a v3 store keeps its marker, all else + // is v1), and it holds what the operation wrote. + assert.equal((decode(await readFile(file)) as unknown[])[0], vector.includes("v3") ? "cultcache.store.v3" : "cultcache.store.v1", `${vector} ${operation}`); + const keys = (await new SingleFileMessagePackBackingStore(file).pullAll()).map((entry) => entry.key); + if (operation === "pushAll") { + assert.deepEqual(keys, envelopes.map((entry) => entry.key).sort(), `${vector} ${operation}`); + } else if (operation === "push") { + assert.ok(keys.includes(envelopes[0]!.key), `${vector} ${operation}`); + } else { + assert.ok(!keys.includes(envelopes[0]!.key), `${vector} ${operation}`); + } + } else { + await assert.rejects( + run, + (error) => error instanceof StoreUnreadableError && error.cause !== undefined, + `${vector} ${operation}`, + ); + assert.ok(bytes.equals(await readFile(file)), `${vector} ${operation} rewrote a file it cannot read`); + } + } + } +}); + +// 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. +const ownsId = defineDocumentType({ type: "tests.owns", schema: z.object({ name: z.string() }), schemaId: "id.owned", schemaName: "tests.owns" }); +const listsId = defineDocumentType({ + type: "tests.lists", + schema: z.object({ name: z.string() }), + schemaId: "id.lister", + schemaName: "tests.lists", + compatibleSchemaIds: ["id.owned"], +}); +const alsoListsId = defineDocumentType({ + type: "tests.also-lists", + schema: z.object({ name: z.string() }), + schemaId: "id.also-lister", + schemaName: "tests.also-lists", + compatibleSchemaIds: ["id.owned"], +}); + +async function storeUnder(records: [string, string][]): Promise { + const seed = CultCache.builder().withDocumentType(ownsId).build(); + await seed.put(ownsId, "seed", { name: "n" }); + const payload = seed.getRequiredEnvelope(ownsId, "seed").payload; + const file = join(await mkdtemp(join(tmpdir(), "cultcache-owner-lister-")), "store.msgpack"); + const ids = [...new Set(records.map(([, schemaId]) => schemaId))]; + await writeFile(file, encode(["cultcache.store.v1", + ids.map((schemaId) => [schemaId, schemaId, `${schemaId}.v1`, schemaId, "", [schemaId], []]), + records.map(([key, schemaId]) => [key, schemaId, "2026-09-30T00:00:00.0000000Z", payload])])); + return file; +} + +async function openWith(file: string, ...definitions: AnyCultCacheDocumentDefinition[]): Promise { + let builder = CultCache.builder(); + for (const definition of definitions) builder = builder.withDocumentType(definition); + const cache = builder.withGenericStore(new SingleFileMessagePackBackingStore(file)).build(); + await cache.pullAllBackingStores(); + return cache; +} + +test("a definition listing an id registers beside the one owning it in either order, and the owner wins", async () => { + const file = await storeUnder([["o", "id.owned"], ["l", "id.lister"]]); + for (const order of [[ownsId, listsId], [listsId, ownsId]]) { + const cache = await openWith(file, ...order); + assert.deepEqual(cache.getRequired(ownsId, "o"), { name: "n" }); + assert.equal(cache.get(listsId, "o"), undefined); + assert.deepEqual(cache.getRequired(listsId, "l"), { name: "n" }); + } + const listerOnly = await openWith(await storeUnder([["o", "id.owned"]]), listsId); + assert.deepEqual(listerOnly.getRequired(listsId, "o"), { name: "n" }); +}); + +test("two definitions listing one id register, and a record under it needs its owner", async () => { + const file = await storeUnder([["k", "id.owned"]]); + for (const listers of [[listsId, alsoListsId], [alsoListsId, listsId]]) { + await assert.rejects(openWith(file, ...listers), (error: unknown) => { + assert.ok(error instanceof SchemaConflictError); + assert.equal(error.schemaId, "id.owned"); + assert.equal(error.recordKey, "k"); + assert.deepEqual(error.schemaNames, ["tests.also-lists", "tests.lists"]); + return true; + }); + for (const order of [[...listers, ownsId], [ownsId, ...listers]]) { + assert.deepEqual((await openWith(file, ...order)).getRequired(ownsId, "k"), { name: "n" }); + } + } +}); + +test("every registration refusal is a SchemaConflictError naming the id and both schema names", () => { + const sameId = defineDocumentType({ type: "tests.owns-too", schema: z.object({ name: z.string() }), schemaId: "id.owned", schemaName: "tests.owns-too" }); + const sameName = defineDocumentType({ type: "tests.named-too", schema: z.object({ name: z.string() }), schemaId: "id.other", schemaName: "tests.owns" }); + const sameType = defineDocumentType({ type: "tests.owns", schema: z.object({ name: z.string() }), schemaId: "id.third", schemaName: "tests.third" }); + for (const [claimant, schemaId, names] of [ + [sameId, "id.owned", ["tests.owns", "tests.owns-too"]], + [sameName, "id.other", ["tests.owns", "tests.owns"]], + [sameType, "id.third", ["tests.owns", "tests.third"]], + ] as const) { + for (const order of [[ownsId, claimant], [claimant, ownsId]]) { + assert.throws(() => CultCache.builder().withDocumentType(order[0]).withDocumentType(order[1]).build(), (error: unknown) => { + assert.ok(error instanceof SchemaConflictError, String(error)); + const first = order[0] === ownsId; + assert.equal(error.schemaId, first ? schemaId : ownsId.schemaId); + assert.deepEqual(error.schemaNames, first ? names : [...names].reverse()); + return true; + }); + } + } +}); + // --------------------------------------------------------------------------------------------- // Cross-runtime store lock: each runtime's writer excludes the others on `.lock`. diff --git a/packages/cultcache-ts/test/first-call.test.ts b/packages/cultcache-ts/test/first-call.test.ts new file mode 100644 index 00000000..3c0e22e5 --- /dev/null +++ b/packages/cultcache-ts/test/first-call.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +// The TypeScript first-call snippet that docs/start/README.md shows: define a typed document, store it +// in a .cc file, read it back through a second cache. Keep it small; it is the front door. The docs show +// the region between the `snippet` markers verbatim, except that the package import reads +// "@gamecult/cultcache-ts" where this file reads "../src/index"; the docs test below fails when they differ. +// snippet:begin +import { z } from "zod"; +import { + CultCache, + SingleFileMessagePackBackingStore, + defineDocumentRegistry, + defineDocumentType, +} from "../src/index"; + +const itemDocument = defineDocumentType({ + type: "item", + schemaId: "gamecult.first_call_item.v1", + schemaName: "gamecult.first_call_item", + schemaVersion: "gamecult.first_call_item.v1", + schema: z.object({ name: z.string(), category: z.string(), value: z.number().int() }), + name: "name", + indexes: { category: "category" }, +}); +const registry = defineDocumentRegistry(itemDocument); + +async function firstCall(path: string) { + const cache = CultCache.builder() + .withRegistry(registry) + .withGenericStore(new SingleFileMessagePackBackingStore(path)) + .build(); + await cache.pullAllBackingStores(); + await cache.put(itemDocument, "item:potion", { name: "Potion", category: "Consumable", value: 50 }); + + const reopened = CultCache.builder() + .withRegistry(registry) + .withGenericStore(new SingleFileMessagePackBackingStore(path)) + .build(); + await reopened.pullAllBackingStores(); + const potion = reopened.get(itemDocument, "item:potion"); + const byName = reopened.getByName(itemDocument, "Potion"); + return { potion, byName }; +} +// snippet:end + +test("define, store and read back a typed document", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-first-call-")); + try { + const { potion, byName } = await firstCall(join(dir, "first-call.cc")); + assert.equal(potion?.value, 50); + assert.equal(byName?.category, "Consumable"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("docs show the tested snippet", async () => { + // Compiled to dist-test/test/first-call.test.js, so the package root is two levels up. + const source = await readFile(join(__dirname, "..", "..", "test", "first-call.test.ts"), "utf8"); + const snippet = source + .split("// snippet:begin\n")[1] + .split("// snippet:end\n")[0] + .replace('"../src/index"', '"@gamecult/cultcache-ts"'); + const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n"); + assert.ok(docs.includes("```ts\n" + snippet + "```"), "docs/start/README.md no longer shows test/first-call.test.ts"); +}); diff --git a/packages/cultcache-ts/test/payload-depth.test.ts b/packages/cultcache-ts/test/payload-depth.test.ts index 02e55ab3..382f0672 100644 --- a/packages/cultcache-ts/test/payload-depth.test.ts +++ b/packages/cultcache-ts/test/payload-depth.test.ts @@ -20,7 +20,6 @@ import { } from "../src/document"; import * as codecs from "../src/codec"; import { checkPayloadDepth } from "../src/payload-depth"; -import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store"; const L = MAX_PAYLOAD_DEPTH; const CANARY = "CANARY-payload-depth-91d3"; @@ -175,11 +174,11 @@ test("the limit and its error are exported from the root, and the walk itself is assert.equal("checkPayloadDepth" in root, false); }); -// A v1 store whose one record carries `payload` as bin and no catalog entry, so the record's schema is inferred from the -// payload's first element. +// A v1 store whose one record carries `payload` as bin, under a catalog entry that names its schema. async function storeWith(payload: Uint8Array): Promise { const file = join(await mkdtemp(join(tmpdir(), "cultcache-payload-depth-")), "deep.cc"); - await writeFile(file, encode(["cultcache.store.v1", [], [["record-1", "sha256:stale", "2026-06-25T12:00:00Z", payload]]])); + const entry = ["sha256:stale", "tests.schema_stamped_entry", "tests.schema_stamped_entry.v1", "stale", "", [], []]; + await writeFile(file, encode(["cultcache.store.v1", [entry], [["record-1", "sha256:stale", "2026-06-25T12:00:00Z", payload]]])); return file; } const stamped = (containersInsideTheStamp: number) => @@ -189,25 +188,16 @@ test("the inspector previews a payload at the limit and reports one past it, or const atLimit = await storeWith(stamped(L - 1)); const previewed = inspectCultCacheBytes(atLimit, await readFile(atLimit)).records[0]!; assert.equal(previewed.payloadDecodeError, undefined); - assert.equal(previewed.schemaName, "tests.schema_stamped_entry", "a payload the walk accepts yields its stamp"); + assert.equal(previewed.schemaName, "tests.schema_stamped_entry"); for (const inside of [L, 100_000]) { const file = await storeWith(stamped(inside)); const record = inspectCultCacheBytes(file, await readFile(file)).records[0]!; assert.equal(record.payloadDecodeError, "document payload nests 257 MessagePack containers deep; the limit is 256."); - assert.equal(record.schemaName, "", "the stamp of a payload the walk refuses is not recovered"); + assert.equal(record.schemaName, "tests.schema_stamped_entry"); } }); -test("the single-file store infers a legacy record's schema from a payload at the limit and from none past it", async () => { - const atLimit = await storeWith(stamped(L - 1)); - const [read] = await new SingleFileMessagePackBackingStore(atLimit).pullAll(); - assert.equal(read.catalogEntry?.schemaVersion, "tests.schema_stamped_entry.v1"); - - const past = await storeWith(stamped(L)); - await assert.rejects(() => new SingleFileMessagePackBackingStore(past).pullAll(), /missing schema id/u); -}); - // 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); diff --git a/packages/cultcache-ts/test/union-parity.test.ts b/packages/cultcache-ts/test/union-parity.test.ts index f3e79b37..46dcb42f 100644 --- a/packages/cultcache-ts/test/union-parity.test.ts +++ b/packages/cultcache-ts/test/union-parity.test.ts @@ -48,16 +48,23 @@ type Node = | { 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 }; + | { 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: (Outcome | null)[]; + outcomes: (GoldenOutcome | null)[]; part: Item | null; crafted: CraftedItem | null; missing: Node | null; @@ -71,7 +78,7 @@ const node: Codec = union("Node", { 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 }>({ 0: ["inner", lazy(() => node)] })], + 6: ["Wrap", slots<{ inner: Node | null; id: string }>({ 0: ["inner", lazy(() => node)], 1: ["id", string] })], }, `${CS}Node`); const outcome = union("Outcome", { 0: ["Pending", slots<{ message: string }>({ 0: ["message", string] })], @@ -80,6 +87,14 @@ const outcome = union("Outcome", { [-2147483648]: ["Floor", unit], 2147483647: ["Ceiling", unit], }, `${CS}Outcome`); +const idOnly = slots<{ id: string }>({ 0: ["id", string] }); +const goldenOutcome = union("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", { @@ -92,7 +107,7 @@ const surfaceCodec = slots({ 0: ["id", string], 1: ["version", int32], 2: ["root", node], - 3: ["outcomes", list(outcome)], + 3: ["outcomes", list(goldenOutcome)], 4: ["part", item], 5: ["crafted", craftedItem], 6: ["missing", node, null], @@ -111,7 +126,7 @@ const nodeSchema: z.ZodType = z.lazy(() => 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 }), + z.object({ kind: z.literal("Wrap"), inner: nodeSchema, id: z.string() }), ]), ])); const surfaceSchema = z.object({ @@ -121,11 +136,11 @@ const surfaceSchema = z.object({ outcomes: z.array(z.union([ z.null(), z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("Pending"), message: z.string() }), - z.object({ kind: z.literal("Completed"), sourceVersion: z.number() }), - z.object({ kind: z.literal("Denied") }), - z.object({ kind: z.literal("Floor") }), - z.object({ kind: z.literal("Ceiling") }), + 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", [ @@ -157,29 +172,33 @@ const golden: Surface = { version: 3, root: { kind: "Box", - id: "root", + id: "0000000000c0", children: [ - { kind: "Text", id: "t1", content: "hello" }, + { kind: "Text", id: "0000000000c1", content: "hello" }, { kind: "Use", - id: "u1", + id: "0000000000c2", composition: { name: "comp", body: { kind: "Box", - id: "inner", + id: "0000000000d0", children: [ - { kind: "Text", id: "deep", content: "x" }, - { kind: "Image", id: "img", uri: "uri://a", alt: "alt" }, + { kind: "Text", id: "0000000000d1", content: "x" }, + { kind: "Image", id: "0000000000d2", uri: "uri://a", alt: "alt" }, ], }, }, - slots: [{ kind: "Text", id: "s1", content: "slot" }, null, { kind: "Box", id: "b2", children: [] }], + slots: [{ kind: "Text", id: "0000000000e1", content: "slot" }, null, { kind: "Box", id: "0000000000e2", children: [] }], }, - { kind: "Image", id: "i2", uri: "uri://b", alt: "" }, + { kind: "Image", id: "0000000000c3", uri: "uri://b", alt: "" }, ], }, - outcomes: [{ kind: "Pending", message: "wait" }, { kind: "Completed", sourceVersion: 7 }, { kind: "Denied" }], + 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, @@ -200,8 +219,13 @@ test("the union surface vector decodes to the constructed value and re-encodes b assert.equal(hex(writeWire(surfaceCodec.encode(golden))), hex(committed)); }); -test("a unit arm is written [key, []] and its payload must be an array", () => { - assert.deepEqual(outcome.encode({ kind: "Denied" }), [2, []]); +test("a unit arm is written as the C# reference writes it, and its payload must be an array", async () => { + // The bytes are tests/vectors/union-parity/unit-arm-*.msgpack, written by the reference, not spelled here. + for (const [file, kind] of [["denied", "Denied"], ["floor", "Floor"], ["ceiling", "Ceiling"]] as const) { + const reference = await read(`unit-arm-${file}.msgpack`); + assert.deepEqual(outcome.decode(readWire(reference)), { kind }, file); + assert.equal(hex(writeWire(outcome.encode({ kind }))), hex(reference), file); + } assert.throws(() => outcome.decode([2, null]), /Union Outcome/u); }); @@ -814,14 +838,14 @@ test("a bytes slot reads a MessagePack str as its bytes and writes bin, and refu const wrapChain = (wraps: number, leaf: Node): Node => { let node: Node = leaf; - for (let index = 0; index < wraps; index += 1) node = { kind: "Wrap", inner: node }; + for (let index = 0; index < wraps; index += 1) node = { kind: "Wrap", inner: node, id: "0000000000b1" }; return node; }; // A Wrap adds 2 containers, a Box with no children 3, a Text leaf 2, and the outermost array is 1 (the C# test's arithmetic). const AT_LIMIT_WRAPS = (MAX_PAYLOAD_DEPTH - 1 - 3) / 2; const PAST_LIMIT_WRAPS = (MAX_PAYLOAD_DEPTH + 1 - 1 - 2) / 2; -const atLimitValue: Surface = { ...golden, root: wrapChain(AT_LIMIT_WRAPS, { kind: "Box", id: "leaf", children: [] }) }; -const pastLimitValue: Surface = { ...golden, root: wrapChain(PAST_LIMIT_WRAPS, { kind: "Text", id: "leaf", content: CANARY }) }; +const atLimitValue: Surface = { ...golden, root: wrapChain(AT_LIMIT_WRAPS, { kind: "Box", id: "0000000000f0", children: [] }) }; +const pastLimitValue: Surface = { ...golden, root: wrapChain(PAST_LIMIT_WRAPS, { kind: "Text", id: "0000000000f0", content: CANARY }) }; async function boundsEntries() { const raw = decode(await read("bounds-outcomes.msgpack")) as unknown[][]; diff --git a/packages/cultmath/README.md b/packages/cultmath/README.md index 381b194a..b144c56d 100644 --- a/packages/cultmath/README.md +++ b/packages/cultmath/README.md @@ -29,7 +29,8 @@ shader semantics. `cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, `erf`, `erfinv` (scalar only), exponential `decay`/`damp`, intercept/segment helpers (`distance_to_segment` in 2D and 3D, with and without `out closest`, and `segment_closest`), Catmull-Rom, Bezier curves, and - deterministic value-noise primitives + deterministic value-noise primitives, and `fbm(p)` / `fbm(p, octaves, lacunarity, gain)`, the value-only octave sum + of `snoise` (`cultmath_fbm` in HLSL and GLSL) - Site cells: `power_cells` and `multiplicative_cells` return the two nearest sites of a point as `CultSiteCells`, `soft_gabriel_gate` gates a pair of sites by the empty-disc margin. Each is a loop over the shader steps `cultmath_power_score` / `cultmath_multiplicative_score`, `cultmath_site_cells_empty`, `cultmath_site_cells_insert`, diff --git a/packages/cultmath/docs/design.md b/packages/cultmath/docs/design.md index 3b63f4d2..d9f06b5f 100644 --- a/packages/cultmath/docs/design.md +++ b/packages/cultmath/docs/design.md @@ -451,6 +451,26 @@ together, laid out as `float4(gradient.xyz, value.w)`, with no value-only twin nearly cancels. The total weight `W` is positive for every `pcg3d` input (the smallest found by hill-climbing is 0.056); `W = 0`, and a NaN output, needs adversarial jitter no hash yields. + Lipschitz bound of the `(cosine, sine)` field in `p` (one cell is 1 unit): the + largest singular value of its 2×3 Jacobian is at most + `max(10, 1.25 |side|) / (1 - normalization)` for `normalization` in `[0, 0.9]` and + `|side|` in `[0.25, 25]`. A scalar use of `cosine` or `sine` alone is bounded by the same number (its gradient norm is + one row of the Jacobian, never above the largest singular value). Method (`tools/phacelle-lipschitz`): + random `p` (a 64-unit cube), a uniformly random `side` direction and offset, the + exact gradient at each, 4 million samples per (normalization, |side|); then + hill-climbing from the 256 best samples over `p`, `side` direction and offset + (10 000 steps each); then value differences around the climbed argmax to confirm the + gradient is the field's slope (agreement within 3%). Random sampling alone + undershoots the climbed maximum by up to 4×, because the maxima sit at near-cancellation + points of the blend (`1/|I|`); quote the climbed number. The climbed sup is 0.016 to 0.88 of + the envelope over the sweep: tightest (within 1.15×) at `normalization` 0 and `|side| >= 8`, + and 1.5 to 60× loose at small `|side|` or large `normalization`, where the + envelope's constant 10 is the worst case over all `|side|`. The typical gradient (99.99th percentile of the + samples) is 2 to 5× below the sup, so a sphere trace steps safely at `distance / L` + and can step farther with the returned gradient. The bound is not a proof (a + search found it, not an argument) and it does not hold as `normalization` approaches 1: + the 1/|I| factor is unbounded there (sampled 1335 at `normalization` 1, `|side|` 2Ï€, and + rising with the sample count). If the caller scales `p` by `f` the bound scales by `f`. Cells whose per-axis lower bound `Σ max(0, |local - g| - 0.5)²` is at least 2.25 lie outside the support and have weight exactly 0, so they are skipped, which changes no output bit. Cells whose bound sits just under 2.25 can still diff --git a/packages/cultmath/shaders/CultMath.glsl b/packages/cultmath/shaders/CultMath.glsl index 6ebf3f36..529ad6e8 100644 --- a/packages/cultmath/shaders/CultMath.glsl +++ b/packages/cultmath/shaders/CultMath.glsl @@ -342,6 +342,26 @@ vec4 cultmath_fbm_grad(vec3 p, int octaves, float lacunarity, float gain) return vec4(gradient, value); } +// Value-only octave sum of cultmath_snoise; see the C# math.fbm comment. The one-argument form is four octaves, +// lacunarity 2, gain 0.5. +float cultmath_fbm(vec3 p, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + float value = 0.0; + for (int i = 0; i < octaves; i++) + { + value += amplitude * cultmath_snoise(p * frequency); + frequency *= lacunarity; + amplitude *= gain; + } + + return value; +} + +float cultmath_fbm(vec3 value) { return cultmath_fbm(value, 4, 2.0, 0.5); } + // Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad // comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. vec4 cultmath_ridged_grad(vec3 p, int octaves, float lacunarity, float gain) diff --git a/packages/cultmath/shaders/CultMath.hlsl b/packages/cultmath/shaders/CultMath.hlsl index d3eea396..fc9f7a70 100644 --- a/packages/cultmath/shaders/CultMath.hlsl +++ b/packages/cultmath/shaders/CultMath.hlsl @@ -342,6 +342,26 @@ float4 cultmath_fbm_grad(float3 p, int octaves, float lacunarity, float gain) return float4(gradient, value); } +// Value-only octave sum of cultmath_snoise; see the C# math.fbm comment. The one-argument form is four octaves, +// lacunarity 2, gain 0.5. +float cultmath_fbm(float3 p, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + float value = 0.0; + for (int i = 0; i < octaves; i++) + { + value += amplitude * cultmath_snoise(p * frequency); + frequency *= lacunarity; + amplitude *= gain; + } + + return value; +} + +float cultmath_fbm(float3 value) { return cultmath_fbm(value, 4, 2.0, 0.5); } + // Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad // comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. float4 cultmath_ridged_grad(float3 p, int octaves, float lacunarity, float gain) diff --git a/packages/cultmath/src/CultMath/CultMath.csproj b/packages/cultmath/src/CultMath/CultMath.csproj index 333fb20a..ed99c004 100644 --- a/packages/cultmath/src/CultMath/CultMath.csproj +++ b/packages/cultmath/src/CultMath/CultMath.csproj @@ -10,7 +10,7 @@ GameCult.Math MIT AND MPL-2.0 AND Apache-2.0 - 0.4.1 + 0.5.0 Clean-room C# math shaped for HLSL-style renderer code. GameCult README.md diff --git a/packages/cultmath/src/CultMath/math.cs b/packages/cultmath/src/CultMath/math.cs index 0d22dc4c..a7c41f48 100644 --- a/packages/cultmath/src/CultMath/math.cs +++ b/packages/cultmath/src/CultMath/math.cs @@ -823,6 +823,29 @@ public static partial class math return new float4(gradient, value); } + // Value-only fractional Brownian motion: the octave sum of snoise, octave i at frequency lacunarity^i and + // amplitude gain^i. It is fbm_grad(p, octaves, lacunarity, gain).w without the gradient work (to float + // rounding: snoise and snoise_grad.w order their arithmetic differently), so a loop that only wants the height + // (a sphere trace) does not pay for the gradient. octaves clamps to [0, 16] as fbm_grad's does. The one-argument + // form is the stock four octaves, lacunarity 2, gain 1/2: its range is within +-1.875 times snoise's. + public static float fbm(float3 p, int octaves, float lacunarity, float gain) + { + octaves = clamp(octaves, 0, 16); + var amplitude = 1.0f; + var frequency = 1.0f; + var value = 0.0f; + for (var i = 0; i < octaves; i++) + { + value += amplitude * snoise(p * frequency); + frequency *= lacunarity; + amplitude *= gain; + } + + return value; + } + + public static float fbm(float3 value) => fbm(value, 4, 2.0f, 0.5f); + // Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16; the operator's dune term, // 2026-09-25): each octave folds snoise_grad about zero, value = Sum a_i*(1 - |n_i|), gradient = // -Sum a_i*f_i*sign(n_i)*grad(n_i), the same per-octave frequency scaling as fbm_grad's chain diff --git a/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs b/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs index e4afbc74..86ccbfc8 100644 --- a/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs @@ -19,11 +19,23 @@ namespace CultMath.Tests; /// public sealed class CyclesOracleTests { - // The ulp distance a function is allowed from the reference. Empty: the fixtures are the SSE4.2 kernel's results - // and every function equals them, power and logarithm included (glibc's powf and logf, 0 ulp from MathF on Linux). + // The ulp distance a function is allowed from the reference; a function not named is held to 0. The fixtures are + // the SSE4.2 kernel's results and almost every function equals them, power and logarithm included (glibc's powf and + // logf, 0 ulp from MathF on Linux). asin, acos and atan2 are the exception: the fixtures' glibc and the libm these + // tests run on round them one ulp apart (glsl-parity.json families 95-97 are the same three, platform families + // there too), so each is allowed 1 ulp, never more (TolerancesAreCappedAtOneUlpAndStayOnTheDriftingFunctions). // An entry needs a measured case on the reference named in it (a failing case's bits and ulp); other builds' // tolerances belong to the class table in tools/cycles-oracle/README.md, not here. - private static readonly Dictionary Tolerances = new(); + private const int MaxToleratedUlp = 1; + + private static readonly Dictionary Tolerances = new() + { + ["cycles_arcsine"] = (1, "case 3f4dd9af -> 3f6f256c, C# 3f6f256d"), + ["cycles_arccosine"] = (1, "case 3f027192 -> 3f84a03c, C# 3f84a03b"), + ["cycles_arctan2"] = (1, "case 3f000000 bdcccccd -> 3fe2541c, C# 3fe2541d"), + }; + + private static int Allowed(string function) => Tolerances.TryGetValue(function, out var tolerance) ? tolerance.Ulp : 0; private const string CSharp = "C#"; private const string Mirror = "HLSL mirror"; @@ -120,7 +132,7 @@ public sealed class CyclesOracleTests var name = (string)entry!["function"]!; var method = Method(name); var clamped = (string?)entry["then"] is not null; - var allowed = Tolerances.TryGetValue(name, out var tolerance) ? tolerance.Ulp : 0; + var allowed = Allowed(name); var worst = 0L; string? worstCase = null; var cases = entry["cases"]!.AsArray(); @@ -158,6 +170,24 @@ public sealed class CyclesOracleTests Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures)); } + [Fact] + public void TolerancesAreCappedAtOneUlpAndStayOnTheDriftingFunctions() + { + Assert.Equal(new[] { "cycles_arccosine", "cycles_arcsine", "cycles_arctan2" }, Tolerances.Keys.Order().ToArray()); + Assert.All(Tolerances.Values, t => Assert.InRange(t.Ulp, 1, MaxToleratedUlp)); + Assert.All(Tolerances.Values, t => Assert.NotEmpty(t.Measured)); + + // A result two ulps from the reference is past every allowance, and one ulp is within the drifting functions' only. + foreach (var function in Tolerances.Keys.Append("cycles_power")) + { + var want = 0.7f; + var twoAway = MathF.BitIncrement(MathF.BitIncrement(want)); + Assert.Equal(2, Distance(want, twoAway)); + Assert.True(Distance(want, twoAway) > Allowed(function), $"{function}: a 2 ulp miss would pass"); + Assert.Equal(function != "cycles_power", Distance(want, MathF.BitIncrement(want)) <= Allowed(function)); + } + } + private static JsonNode RampEntry(string interpolation) => Load("ramp")["functions"]!.AsArray().Single(e => e!["node"]!.ToString() == "ShaderNodeValToRGB " + interpolation)!; diff --git a/packages/cultmath/tests/CultMath.Tests/FbmTests.cs b/packages/cultmath/tests/CultMath.Tests/FbmTests.cs new file mode 100644 index 00000000..89ca4dc9 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/FbmTests.cs @@ -0,0 +1,73 @@ +using System; +using CultMath; +using Xunit; + +namespace CultMath.Tests; + +public sealed class FbmTests +{ + private static float3 Point(System.Random r) => new(r.NextSingle() * 20.0f - 10.0f, r.NextSingle() * 20.0f - 10.0f, r.NextSingle() * 20.0f - 10.0f); + + [Fact] + public void OneOctaveIsSnoise() + { + var random = new System.Random(0xFB01); + for (var i = 0; i < 64; i++) + { + var p = Point(random); + Assert.Equal(math.snoise(p), math.fbm(p, 1, 1.0f + random.NextSingle() * 3.0f, 0.1f + random.NextSingle() * 0.8f)); + } + } + + [Fact] + public void ZeroOrNegativeOctavesSumNothing() + { + var p = new float3(0.3f, 1.7f, -2.2f); + Assert.Equal(0.0f, math.fbm(p, 0, 2.0f, 0.5f)); + Assert.Equal(0.0f, math.fbm(p, -5, 2.0f, 0.5f)); + } + + [Fact] + public void OctavesCompoundGainAndLacunarityIndependently() + { + // gain != lacunarity and neither is 1, so applying either once, or swapping them, changes the sum. + const float lacunarity = 2.3f; + const float gain = 0.4f; + var random = new System.Random(0xFB02); + for (var i = 0; i < 64; i++) + { + var p = Point(random); + var expected = math.snoise(p) + gain * math.snoise(p * lacunarity) + gain * gain * math.snoise(p * (lacunarity * lacunarity)); + Assert.Equal(expected, math.fbm(p, 3, lacunarity, gain), 5); + } + } + + [Fact] + public void ValueIsFbmGradsValue() + { + var random = new System.Random(0xFB03); + for (var i = 0; i < 128; i++) + { + var p = Point(random); + Assert.Equal(math.fbm_grad(p, 5, 2.0f, 0.55f).w, math.fbm(p, 5, 2.0f, 0.55f), 4); + } + } + + [Fact] + public void OctaveCountClampsToSixteenLikeFbmGrad() + { + var p = new float3(0.3f, 1.7f, -2.2f); + Assert.Equal(math.fbm(p, 16, 1.1f, 0.9f), math.fbm(p, 1000, 1.1f, 0.9f)); + } + + [Fact] + public void OneArgumentFormIsFourOctavesLacunarityTwoGainHalf() + { + var random = new System.Random(0xFB04); + for (var i = 0; i < 64; i++) + { + var p = Point(random); + Assert.Equal(math.fbm(p, 4, 2.0f, 0.5f), math.fbm(p)); + } + } +} diff --git a/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs b/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs index c2b604f6..874ace67 100644 --- a/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/GlslMirrorTests.cs @@ -904,6 +904,7 @@ static int4 clamp(int4 x, int4 a, int4 b) { if (a.x > b.x || a.y > b.y || a.z > // The site-array functions: a fixed-capacity array and a count, killed on the device (the lattice at rings up to 17). new Family($"vec4 cultmath_power_border(vec3, vec4[{SiteArrays.Capacity}], int)", "ulp-bounded", SiteArrays.PowerBorderArguments), new Family($"vec4 cultmath_weighted_lattice_cells(vec2, float, vec4[{SiteArrays.Capacity}], int, float, float, float, float, float, vec2, float)", "ulp-bounded", SiteArrays.LatticeArguments), + new Family("float cultmath_fbm(vec3, int, float, float)", "ulp-bounded", r => new object[] { Point3(r), 4, 2.0f, 0.5f }), }).ToArray(); // A point of magnitude m, m log-uniform in [lo, hi]: one component at +-m, the other two uniform in [-m, m], @@ -974,6 +975,7 @@ static int4 clamp(int4 x, int4 a, int4 b) { if (a.x > b.x || a.y > b.y || a.z > var json = new StringBuilder(); json.Append("{\n"); json.Append(" \"generator\": \"GlslMirrorTests.GoldenFixtureMatchesCSharp (packages/cultmath/tests/CultMath.Tests); regenerate with CULTMATH_WRITE_GLSL=1\",\n"); + json.Append(" \"recorded_on\": \"platform families: Yggdrasil's mcr.microsoft.com/dotnet/sdk:10.0 image (Debian GNU/Linux 13 trixie, .NET 10.0.14); lavapipe device bounds: fixtures/device-bounds-lavapipe.txt (llvmpipe LLVM 20.1.2, mesa-vulkan-drivers 25.2.8-0ubuntu0.24.04.4, ubuntu:24.04 probe image)\",\n"); json.Append($" \"seed\": {Seed},\n"); json.Append($" \"points\": {Points},\n"); json.Append(" \"encoding\": \"each case is 'arguments -> result', every scalar a float32 or 32-bit integer bit pattern in hex, vectors and structs flattened in field order\",\n"); diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs index 2c994be8..821ba94a 100644 --- a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs @@ -47,6 +47,8 @@ public sealed class HlslSourceCompatibilityTests Assert.NotNull(ShaderFunction(shader, "cultmath_snoise_hessian", typeof(float3))); Assert.NotNull(ShaderFunction(shader, "cultmath_integral_clamp_linear", typeof(float), typeof(float), typeof(float), typeof(float), typeof(float), typeof(float))); Assert.NotNull(ShaderFunction(shader, "cultmath_fbm_grad", typeof(float3), typeof(int), typeof(float), typeof(float))); + Assert.NotNull(ShaderFunction(shader, "cultmath_fbm", typeof(float3), typeof(int), typeof(float), typeof(float))); + Assert.NotNull(ShaderFunction(shader, "cultmath_fbm", typeof(float3))); Assert.NotNull(ShaderFunction(shader, "cultmath_ridged_grad", typeof(float3), typeof(int), typeof(float), typeof(float))); Assert.NotNull(ShaderFunction(shader, "cultmath_phacelle", typeof(float3), typeof(float3), typeof(float), typeof(float))); Assert.NotNull(ShaderFunction(shader, "cultmath_iv_snoise_ball", typeof(float3), typeof(float))); @@ -205,7 +207,7 @@ public sealed class HlslSourceCompatibilityTests Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}"); // The three site-array functions are compared by SiteArrayMirrorsMatchCSharp, which draws their counts and arrays. - Assert.Equal(140, compared.Count); + Assert.Equal(141, compared.Count); } /// diff --git a/packages/cultmath/tests/CultMath.Tests/PhacelleRowOrderTests.cs b/packages/cultmath/tests/CultMath.Tests/PhacelleRowOrderTests.cs new file mode 100644 index 00000000..b465d75a --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/PhacelleRowOrderTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Linq; +using CultMath; +using Xunit; + +namespace CultMath.Tests; + +/// +/// A ring-buffer terrain (Voxel Space) fills height rows in whatever order the camera uncovers them, so a +/// Phacelle row must be a pure function of world x, z and the seed: the same row, bit for bit, whether the +/// rows around it were generated forward, backward or shuffled. C# phacelle mirrors cultmath_phacelle in +/// shaders/CultMath.Phacelle.glsl; GlslMirrorTests pins the two together, and this pins that neither keeps +/// state between calls. +/// +public sealed class PhacelleRowOrderTests +{ + private const int Rows = 24; + private const int Columns = 48; + private const int Seed = 7919; + + // One height sample: world (x, z) and the seed alone choose the point, the stripe direction and the phase. + private static int[] Sample(int worldX, int worldZ, int seed) + { + var p = new float3(worldX * 0.173f, (seed & 0xff) * 0.37f, worldZ * 0.173f); + var angle = (seed >> 8) * 0.61f; + var side = new float3(math.cos(angle), 0.0f, math.sin(angle)) * (math.TAU * 0.9f); + var phasor = math.phacelle(p, side, (seed & 0xf) / 16.0f, 0.5f); + return new[] + { + Bits(phasor.cosine.x), Bits(phasor.cosine.y), Bits(phasor.cosine.z), Bits(phasor.cosine.w), + Bits(phasor.sine.x), Bits(phasor.sine.y), Bits(phasor.sine.z), Bits(phasor.sine.w), + }; + } + + private static int Bits(float value) => BitConverter.SingleToInt32Bits(value); + + private static int[][] Row(int worldZ, int seed) => + Enumerable.Range(-Columns / 2, Columns).Select(x => Sample(x, worldZ, seed)).ToArray(); + + private static int[][][] Generate(int[] order, int seed) + { + var rows = new int[Rows][][]; + foreach (var z in order) rows[z] = Row(z - Rows / 2, seed); + return rows; + } + + private static void AssertSameRows(int[][][] expected, int[][][] actual) + { + for (var z = 0; z < Rows; z++) + for (var x = 0; x < Columns; x++) + Assert.True(expected[z][x].SequenceEqual(actual[z][x]), $"row {z} column {x} differs bit for bit"); + } + + [Fact] + public void RowsAreBitIdenticalForwardBackwardAndShuffled() + { + var forward = Enumerable.Range(0, Rows).ToArray(); + var backward = forward.Reverse().ToArray(); + var shuffled = (int[])forward.Clone(); + var random = new System.Random(20261104); + for (var i = shuffled.Length - 1; i > 0; i--) + { + var j = random.Next(i + 1); + (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]); + } + + Assert.False(shuffled.SequenceEqual(forward) || shuffled.SequenceEqual(backward)); + var reference = Generate(forward, Seed); + AssertSameRows(reference, Generate(backward, Seed)); + AssertSameRows(reference, Generate(shuffled, Seed)); + } + + [Fact] + public void ARowIsTheSameWhenRegeneratedAfterAnyOtherSeedAndRow() + { + var first = Row(5, Seed); + _ = Row(-3, Seed + 1); + _ = Row(5, Seed + 1); + var again = Row(5, Seed); + for (var x = 0; x < Columns; x++) Assert.True(first[x].SequenceEqual(again[x])); + } + + // Guards the test itself: a different seed or a different row must change the bits, or the equalities + // above would hold for a function that ignores its inputs. + [Fact] + public void RowsDependOnSeedAndWorldRow() + { + Assert.False(Row(5, Seed).SelectMany(s => s).SequenceEqual(Row(5, Seed + 1).SelectMany(s => s))); + Assert.False(Row(5, Seed).SelectMany(s => s).SequenceEqual(Row(6, Seed).SelectMany(s => s))); + } +} diff --git a/packages/cultmath/tests/CultMath.Tests/PhacelleTests.cs b/packages/cultmath/tests/CultMath.Tests/PhacelleTests.cs index 71f5b452..e9c3377d 100644 --- a/packages/cultmath/tests/CultMath.Tests/PhacelleTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/PhacelleTests.cs @@ -337,4 +337,33 @@ public sealed class PhacelleTests Assert.Equal(BitConverter.SingleToInt32Bits(expected.z), BitConverter.SingleToInt32Bits(actual.z)); Assert.Equal(BitConverter.SingleToInt32Bits(expected.w), BitConverter.SingleToInt32Bits(actual.w)); } + + // The Lipschitz envelope of the (cosine, sine) field in p, per docs/design.md ("Lipschitz bound"): the largest + // singular value of its 2x3 Jacobian never exceeds max(10, 1.25 |side|) / (1 - normalization), for normalization + // in [0, 0.9] and |side| in [0.25, 25]. tools/phacelle-lipschitz measures it by sampling and hill-climbing; this + // pins that no ordinary sample passes it (the climbed maximum is at most 0.88 of the envelope). + [Theory] + [InlineData(0.0f)] + [InlineData(0.5f)] + [InlineData(0.75f)] + [InlineData(0.9f)] + public void GradientNormStaysUnderTheDocumentedLipschitzEnvelope(float normalization) + { + var random = new System.Random(0x11B5); + foreach (var sideLength in new[] { 0.25f, 1.0f, 1.5f, 4.0f, 12.5f, 25.0f }) + { + var envelope = Math.Max(10.0, 1.25 * sideLength) / (1.0 - normalization); + for (var i = 0; i < 20000; i++) + { + var p = RandomPoint(random, 32.0f); + var side = math.normalize(RandomSide(random)) * sideLength; + var phasor = math.phacelle(p, side, random.NextSingle(), normalization); + var a = (double)math.dot(phasor.cosine.xyz, phasor.cosine.xyz); + var d = (double)math.dot(phasor.sine.xyz, phasor.sine.xyz); + var b = (double)math.dot(phasor.cosine.xyz, phasor.sine.xyz); + var norm = Math.Sqrt((a + d) / 2.0 + Math.Sqrt(Math.Max(0.0, (a + d) * (a + d) / 4.0 - (a * d - b * b)))); + Assert.True(norm <= envelope, $"|J| {norm} passes the envelope {envelope} at p {p}, side {side}, normalization {normalization}"); + } + } + } } diff --git a/packages/cultmath/tests/CultMath.Tests/fixtures/device-bounds-lavapipe.txt b/packages/cultmath/tests/CultMath.Tests/fixtures/device-bounds-lavapipe.txt index 42706157..f383cc3d 100644 --- a/packages/cultmath/tests/CultMath.Tests/fixtures/device-bounds-lavapipe.txt +++ b/packages/cultmath/tests/CultMath.Tests/fixtures/device-bounds-lavapipe.txt @@ -96,9 +96,9 @@ lavapipe-glsl 91 vec3 cultmath_cycles_mix_val(float, vec3, vec3) ulp-bounded 256 lavapipe-glsl 92 vec3 cultmath_cycles_mix_color(float, vec3, vec3) ulp-bounded 256 0 0 00067b1567bcd6c07a81fa5002f145dfca22437a3f3df5389b2665334d14909b lavapipe-glsl 93 vec3 cultmath_cycles_mix_soft(float, vec3, vec3) ulp-bounded 157 1.9750388068880254 0 ed7c5508ca0543cc20a5f0c5624e75e3ca2a2d6044d481816eeda2edc50c0592 lavapipe-glsl 94 vec3 cultmath_cycles_mix_linear(float, vec3, vec3) ulp-bounded 256 0 0 672435cc30bcbbb253cce7316ee0712b1a18f06924bc89cea2d3388dd164a15d -lavapipe-glsl 95 float cultmath_cycles_arcsine(float) ulp-bounded, platform asin 154 2734.286809678319 0 f28f3ed0a53ffeebdfa84865d2d3d41590e6b46fc1578d40e1215e2a9e1af585 +lavapipe-glsl 95 float cultmath_cycles_arcsine(float) ulp-bounded, platform asin 155 2734.286809678319 0 f28f3ed0a53ffeebdfa84865d2d3d41590e6b46fc1578d40e1215e2a9e1af585 lavapipe-glsl 96 float cultmath_cycles_arccosine(float) ulp-bounded, platform acos 79 1046.8674409774287 0 22a2c38ab5a232cdbb33cf0c133362581a398f64a4d0d5d6dd89dc5af06bd890 -lavapipe-glsl 97 float cultmath_cycles_arctan2(float, float) ulp-bounded, platform atan 6 25.5 0 2c60447d80186f6dc9726f19c09fa96ec3f66d8f5688968da533d9195ea463c4 +lavapipe-glsl 97 float cultmath_cycles_arctan2(float, float) ulp-bounded, platform atan 7 25.5 0 2c60447d80186f6dc9726f19c09fa96ec3f66d8f5688968da533d9195ea463c4 lavapipe-glsl 98 mat3 cultmath_snoise_hessian(vec3) ulp-bounded 30 1358.4375 0 62bbafb8420ed5aaa0ce11d1470ca98945e690b186c33a1e7cf4d71478396465 lavapipe-glsl 99 mat3 cultmath_snoise_hessian(vec3) ulp-bounded, on the simplex tie set 110 1317.4754224467295 0 0efd9b3a637b46033ed0351f64de274db4d9ce05bb83dd1ce57afb3ceaea26bc lavapipe-glsl 100 float cultmath_integral_clamp_linear(float, float, float, float, float, float) ulp-bounded 256 0 0 84bf9c9a2126b3114a692507cc54f58d313afc33de8a1cac83969cc508433cbe @@ -126,6 +126,7 @@ lavapipe-glsl 121 float cultmath_lattice_distance(vec2, vec2, float, vec2, float lavapipe-glsl 122 float cultmath_lattice_smooth_step(float, float, float, float) ulp-bounded 256 0 0 6cbeddde536b12e83873f2e5ac60d16bef22049d2a7dea61af0ff1e0d5b84740 lavapipe-glsl 123 vec4 cultmath_power_border(vec3, vec4[128], int) ulp-bounded 188 65.72121683891783 0 8427c2b99109a4f59ff745fdb87f261deb1cbae34ce99bc1ae05ebb7f9963b4d lavapipe-glsl 124 vec4 cultmath_weighted_lattice_cells(vec2, float, vec4[128], int, float, float, float, float, float, vec2, float) ulp-bounded 179 3 0 fd3b58c4e2ba8a7bf801a1e6cdff82468abcb368442bc98b2583fda9d2e6f823 +lavapipe-glsl 125 float cultmath_fbm(vec3, int, float, float) ulp-bounded 43 190.375 0 8f852b55293bd75a4e2e64cd1d1f5083605c2ba2d0877cdf4e8e507c72890135 lavapipe-hlsl 0 float cultmath_snoise(vec3) ulp-bounded 94 122.6875 0 5d41c14241a13124f2e1b49f230f5df0c48d0d69fb1be9ef2f33dc1702bf0b0a lavapipe-hlsl 1 float cultmath_snoise(vec2) ulp-bounded 204 1 0 81d9c62726afdccbbe4ffef173b5c92731eb6bf5927b3979d653e50dbd5d4064 lavapipe-hlsl 2 vec4 cultmath_snoise_grad(vec3) ulp-bounded 71 617.5 0 f7e9ed1707cdb015efaba0e0150193309da0d4c125349518f8b7c60a17b25790 @@ -221,9 +222,9 @@ lavapipe-hlsl 91 vec3 cultmath_cycles_mix_val(float, vec3, vec3) ulp-bounded 256 lavapipe-hlsl 92 vec3 cultmath_cycles_mix_color(float, vec3, vec3) ulp-bounded 256 0 0 00067b1567bcd6c07a81fa5002f145dfca22437a3f3df5389b2665334d14909b lavapipe-hlsl 93 vec3 cultmath_cycles_mix_soft(float, vec3, vec3) ulp-bounded 157 1.9750388068880254 0 ed7c5508ca0543cc20a5f0c5624e75e3ca2a2d6044d481816eeda2edc50c0592 lavapipe-hlsl 94 vec3 cultmath_cycles_mix_linear(float, vec3, vec3) ulp-bounded 256 0 0 672435cc30bcbbb253cce7316ee0712b1a18f06924bc89cea2d3388dd164a15d -lavapipe-hlsl 95 float cultmath_cycles_arcsine(float) ulp-bounded, platform asin 154 2734.286809678319 0 f28f3ed0a53ffeebdfa84865d2d3d41590e6b46fc1578d40e1215e2a9e1af585 +lavapipe-hlsl 95 float cultmath_cycles_arcsine(float) ulp-bounded, platform asin 155 2734.286809678319 0 f28f3ed0a53ffeebdfa84865d2d3d41590e6b46fc1578d40e1215e2a9e1af585 lavapipe-hlsl 96 float cultmath_cycles_arccosine(float) ulp-bounded, platform acos 79 1046.8674409774287 0 22a2c38ab5a232cdbb33cf0c133362581a398f64a4d0d5d6dd89dc5af06bd890 -lavapipe-hlsl 97 float cultmath_cycles_arctan2(float, float) ulp-bounded, platform atan 6 25.5 0 2c60447d80186f6dc9726f19c09fa96ec3f66d8f5688968da533d9195ea463c4 +lavapipe-hlsl 97 float cultmath_cycles_arctan2(float, float) ulp-bounded, platform atan 7 25.5 0 2c60447d80186f6dc9726f19c09fa96ec3f66d8f5688968da533d9195ea463c4 lavapipe-hlsl 98 mat3 cultmath_snoise_hessian(vec3) ulp-bounded 33 2377.8799856935966 0 a64853839f394cc7c367ba5e107caa8bb396f07cf202263aa917095dd1c939f7 lavapipe-hlsl 99 mat3 cultmath_snoise_hessian(vec3) ulp-bounded, on the simplex tie set 132 1317.4754224467295 0 9066a49e50731881583ffca16c949171bb61578a1dfd497c54dafe773ab49488 lavapipe-hlsl 100 float cultmath_integral_clamp_linear(float, float, float, float, float, float) ulp-bounded 256 0 0 84bf9c9a2126b3114a692507cc54f58d313afc33de8a1cac83969cc508433cbe @@ -251,3 +252,4 @@ lavapipe-hlsl 121 float cultmath_lattice_distance(vec2, vec2, float, vec2, float lavapipe-hlsl 122 float cultmath_lattice_smooth_step(float, float, float, float) ulp-bounded 256 0 0 6cbeddde536b12e83873f2e5ac60d16bef22049d2a7dea61af0ff1e0d5b84740 lavapipe-hlsl 123 vec4 cultmath_power_border(vec3, vec4[128], int) ulp-bounded 188 65.72121683891783 0 8427c2b99109a4f59ff745fdb87f261deb1cbae34ce99bc1ae05ebb7f9963b4d lavapipe-hlsl 124 vec4 cultmath_weighted_lattice_cells(vec2, float, vec4[128], int, float, float, float, float, float, vec2, float) ulp-bounded 179 3 0 fd3b58c4e2ba8a7bf801a1e6cdff82468abcb368442bc98b2583fda9d2e6f823 +lavapipe-hlsl 125 float cultmath_fbm(vec3, int, float, float) ulp-bounded 49 190.375 0 cd02f850f1d01ff77a3cb1c49af2bb49e51bb18c395936703cb37b7729ad2c88 diff --git a/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json b/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json index f2ab1f19..a3e68668 100644 --- a/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json +++ b/packages/cultmath/tests/CultMath.Tests/fixtures/glsl-parity.json @@ -1,5 +1,6 @@ { "generator": "GlslMirrorTests.GoldenFixtureMatchesCSharp (packages/cultmath/tests/CultMath.Tests); regenerate with CULTMATH_WRITE_GLSL=1", + "recorded_on": "platform families: Yggdrasil's mcr.microsoft.com/dotnet/sdk:10.0 image (Debian GNU/Linux 13 trixie, .NET 10.0.14); lavapipe device bounds: fixtures/device-bounds-lavapipe.txt (llvmpipe LLVM 20.1.2, mesa-vulkan-drivers 25.2.8-0ubuntu0.24.04.4, ubuntu:24.04 probe image)", "seed": 20261002, "points": 256, "encoding": "each case is 'arguments -> result', every scalar a float32 or 32-bit integer bit pattern in hex, vectors and structs flattened in field order", @@ -6820,7 +6821,7 @@ { "glsl": "vec2 cultmath_iv_exp(vec2)", "tolerance": "ulp-bounded, platform exp", - "platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", + "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14", "cases": [ "41202b2a 4120314e -> 46ade79e 46ae2a6b", "c0d1a016 bfb9db96 -> 3abb4cbc 3e6fb77f", @@ -7083,7 +7084,7 @@ { "glsl": "CultPhasor cultmath_phacelle(vec3, vec3, float, float)", "tolerance": "ulp-bounded, platform exp, sin, cos; across platforms |diff| <= 2^-20 max(|v|, 1); on a device |diff| <= 2^-18 max(|v|, 1)", - "platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", + "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14", "cases": [ "c01d8630 41b2cd8c 41ea274c bfa19894 40aeb6ce 40918978 3e9be843 3f000000 -> bf74d9fb 3fb71956 bf35fe4e 3f21280a c01277e2 403b5fe4 40464bbd bd5b4455", "c2234cf7 4237b112 419ae0c0 c0acbcd4 bf60f9a0 bfeb2b08 3ebf0a10 3f000000 -> c0d0048f 3f228da3 bfc85f60 3edaf41b c0171c80 be137f8c be1c92a3 bf5747bf", @@ -12591,7 +12592,7 @@ { "glsl": "float cultmath_cycles_power(float, float)", "tolerance": "ulp-bounded, platform pow", - "platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", + "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14", "cases": [ "bfb8bc1c 00000000 -> 3f800000", "3f7fc090 3fa15ed0 -> 3f7fb009", @@ -12854,7 +12855,7 @@ { "glsl": "float cultmath_cycles_logarithm(float, float)", "tolerance": "ulp-bounded, platform log", - "platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", + "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14", "cases": [ "416175a4 418f2e6d -> 3f6ac789", "40d2d3ad 416ecd13 -> 3f328da7", @@ -24907,7 +24908,7 @@ { "glsl": "float cultmath_cycles_arcsine(float)", "tolerance": "ulp-bounded, platform asin", - "platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", + "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14", "cases": [ "3f74439c -> 3fa22776", "beaa06dc -> bead5166", @@ -24919,27 +24920,27 @@ "bf44c25b -> bf606b2a", "be9326d8 -> be9541be", "bf047a79 -> bf0b3e4e", - "3f6fb444 -> 3f9b2514", + "3f6fb444 -> 3f9b2513", "bec3bc1c -> bec8d8b0", "3ed94bc8 -> 3ee069a8", "be732cb0 -> be75852c", "bf525772 -> bf76dbfc", "3f5f4508 -> 3f87a01e", - "3f5243a0 -> 3f76b93a", + "3f5243a0 -> 3f76b939", "3f6f8480 -> 3f9ae13d", "bf43af08 -> bf5ebe7a", "3fb52362 -> 3fc90fdb", "3eae8380 -> 3eb2152a", "bf3ff8fb -> bf590ffc", "3f7bb06c -> 3fb189e5", - "3dee93e0 -> 3def1ede", + "3dee93e0 -> 3def1edd", "bfb0b96e -> bfc90fdb", "bf454824 -> bf613cb1", "3f23fb40 -> 3f31f9c7", "be9b0000 -> be9d788d", "3fb7c110 -> 3fc90fdb", "bf0c30e4 -> bf145b51", - "3e855c0c -> 3e86ea5c", + "3e855c0c -> 3e86ea5d", "bf0e2123 -> bf16ae2c", "3f72c708 -> 3f9fbe32", "3f98afd8 -> 3fc90fdb", @@ -24970,7 +24971,7 @@ "3f81d10a -> 3fc90fdb", "bf033714 -> bf09c506", "3fa3a04a -> 3fc90fdb", - "3f1281b8 -> 3f1bfaf2", + "3f1281b8 -> 3f1bfaf3", "bf34447a -> bf480038", "bf9eb144 -> bfc90fdb", "3f38d780 -> 3f4e86c5", @@ -25014,7 +25015,7 @@ "3f039538 -> 3f0a32b8", "3f4cd500 -> 3f6d70e3", "3ee655e4 -> 3eeee961", - "3ef23d20 -> 3efc5474", + "3ef23d20 -> 3efc5473", "3e0c9370 -> 3e0d0572", "3f9f079c -> 3fc90fdb", "3e4b1770 -> 3e4c7278", @@ -25038,7 +25039,7 @@ "3f982f94 -> 3fc90fdb", "3ef183b4 -> 3efb8213", "bed4d500 -> bedb7e63", - "bf49041d -> bf672e44", + "bf49041d -> bf672e45", "bf986f2a -> bfc90fdb", "3eeeeb04 -> 3ef89167", "bf05421c -> bf0c27dd", @@ -25072,7 +25073,7 @@ "3f48bf60 -> 3f66bf61", "3f953182 -> 3fc90fdb", "3e21e6d0 -> 3e229577", - "bf043606 -> bf0aee58", + "bf043606 -> bf0aee57", "3d94f740 -> 3d9518f5", "3c15e400 -> 3c15e489", "bfb51e92 -> bfc90fdb", @@ -25103,7 +25104,7 @@ "3f4133b0 -> 3f5aedab", "3f25a7cc -> 3f3429d9", "3f4f5e44 -> 3f71b78b", - "3f795244 -> 3fabc288", + "3f795244 -> 3fabc287", "3de214a0 -> 3de28ad3", "3e8fe798 -> 3e91deba", "bf9af793 -> bfc90fdb", @@ -25127,7 +25128,7 @@ "3ecad4fc -> 3ed08d21", "3e05a058 -> 3e06022f", "beb65270 -> beba69cf", - "3f5058c0 -> 3f7364a8", + "3f5058c0 -> 3f7364a7", "3fa6360c -> 3fc90fdb", "3f466ed4 -> 3f630d19", "3f700570 -> 3f9b993a", @@ -25144,12 +25145,12 @@ "beaaf660 -> beae4f6a", "3f92651c -> 3fc90fdb", "3f8dddfa -> 3fc90fdb", - "3f2ed97c -> 3f407a92", + "3f2ed97c -> 3f407a93", "be5e0a78 -> be5fd19b", "bf794a5d -> bfabb123", "bf166554 -> bf20c10e", "3ec7db68 -> 3ecd50d0", - "3f0f3a38 -> 3f1800b6", + "3f0f3a38 -> 3f1800b5", "bf9d2433 -> bfc90fdb", "be1b6e70 -> be1c08d6", "3f8cf9f4 -> 3fc90fdb", @@ -25170,7 +25171,7 @@ { "glsl": "float cultmath_cycles_arccosine(float)", "tolerance": "ulp-bounded, platform acos", - "platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", + "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14", "cases": [ "3da419e0 -> 3fbecb6b", "bccc7dc0 -> 3fcc41e7", @@ -25185,7 +25186,7 @@ "3f99f728 -> 00000000", "3efe6a30 -> 3f867f99", "bf8e3141 -> 40490fdb", - "be1ccb60 -> 3fdcbd18", + "be1ccb60 -> 3fdcbd17", "bf6312e3 -> 402a57f6", "beca9b18 -> 3ffd2360", "bfa261f2 -> 40490fdb", @@ -25221,7 +25222,7 @@ "3e8561b8 -> 3fa753cc", "bf888ac6 -> 40490fdb", "3fbb7d54 -> 00000000", - "3ec05018 -> 3f97c656", + "3ec05018 -> 3f97c657", "bf8c0f8c -> 40490fdb", "bf72b2f0 -> 40345736", "3f244db8 -> 3f5fba77", @@ -25250,7 +25251,7 @@ "3f8f186c -> 00000000", "3f30b1e8 -> 3f4f1b53", "3d953310 -> 3fbfba8c", - "bde0dee0 -> 3fd7250e", + "bde0dee0 -> 3fd7250d", "3f7b8300 -> 3e4007f6", "3f35601c -> 3f488ecf", "bf9a050a -> 40490fdb", @@ -25272,7 +25273,7 @@ "bfb4c70e -> 40490fdb", "3e93057c -> 3fa3c820", "3f2b8624 -> 3f56293a", - "bf51e010 -> 40220aae", + "bf51e010 -> 40220aaf", "bf8aa06c -> 40490fdb", "3f9839c8 -> 00000000", "3f28eed8 -> 3f59a156", @@ -25297,7 +25298,7 @@ "3e8aea00 -> 3fa5e480", "3ed35888 -> 3f9298c3", "3f567510 -> 3f13e3ce", - "3e9761c8 -> 3fa2a464", + "3e9761c8 -> 3fa2a463", "3fbc3ca0 -> 00000000", "bf5814bd -> 4024d6ee", "3f807f68 -> 00000000", @@ -25306,7 +25307,7 @@ "3f1a4008 -> 3f6c92ef", "3fa491d4 -> 00000000", "3f385e00 -> 3f44484f", - "3e9ac620 -> 3fa1c0e6", + "3e9ac620 -> 3fa1c0e5", "3e9541ec -> 3fa3329f", "bf647da0 -> 402b1eac", "3dc1c080 -> 3fbcef2e", @@ -25351,7 +25352,7 @@ "beab7798 -> 3ff4c5fc", "3f0df694 -> 3f7ba4b1", "3f94dda6 -> 00000000", - "bf5c7d84 -> 4026f34c", + "bf5c7d84 -> 4026f34d", "3f9296ba -> 00000000", "bfbada1e -> 40490fdb", "3f8cf40a -> 00000000", @@ -25364,7 +25365,7 @@ "bf6b7605 -> 402f4022", "bfad549a -> 40490fdb", "3faa5d8c -> 00000000", - "3e8b7c88 -> 3fa5be6e", + "3e8b7c88 -> 3fa5be6f", "3f3fa580 -> 3f398dc6", "bf61690a -> 4029746e", "bf73a264 -> 403516b7", @@ -25427,13 +25428,13 @@ "3f930316 -> 00000000", "bf6bfdd5 -> 402f9730", "3fa53312 -> 00000000", - "bf1a0e9a -> 400ddba4" + "bf1a0e9a -> 400ddba5" ] }, { "glsl": "float cultmath_cycles_arctan2(float, float)", "tolerance": "ulp-bounded, platform atan", - "platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", + "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14", "cases": [ "c08cbc16 c1549681 -> c0349aac", "418c8108 41948858 -> 3f41f3ee", @@ -25442,7 +25443,7 @@ "c1853a3e 4183660c -> bf4ad4c8", "40cc3750 c18864ad -> 4032239a", "c1841fce 414eb7b4 -> bf682df1", - "3f7f11c0 41915dcc -> 3d605f59", + "3f7f11c0 41915dcc -> 3d605f58", "c1190690 40822148 -> bf959a14", "41235546 414a8e7c -> 3f2db91b", "c173027e 4157ae48 -> bf584be7", @@ -25451,12 +25452,12 @@ "41504298 c1432d3c -> 4014b8bf", "41976732 4165afb0 -> 3f6bff17", "c12faa83 c19c5692 -> c0284d67", - "c0aa038c c0cb2194 -> c01c7634", + "c0aa038c c0cb2194 -> c01c7633", "c0df2a78 c0a67120 -> c00d8b25", "4144767c c11a39a5 -> 400f2006", "c00c8aa0 4194be56 -> bdf0c4af", "3f876310 c1937378 -> 404564aa", - "41636814 c1172a84 -> 400a13e2", + "41636814 c1172a84 -> 400a13e1", "3f81e2e0 bd4f6800 -> 3fcf71a7", "41309010 c08f8f1c -> 3ffa7c90", "40982a9c 3f904760 -> 3fab4541", @@ -25467,23 +25468,23 @@ "4192314a 4086f2c0 -> 3fac080a", "411b37ca c171da02 -> 40248b70", "c1664f18 c130242c -> c00e512f", - "40eced38 40bd9198 -> 3f655f42", - "4104386c 411d5130 -> 3f32ed70", + "40eced38 40bd9198 -> 3f655f43", + "4104386c 411d5130 -> 3f32ed71", "bdd98800 c15cd654 -> c04891c6", "c161bae2 c19f6675 -> c021a16c", "c15bccb4 4139d89a -> bf5e709e", "c1065de6 c1860dc7 -> c02b5428", "419a2318 416c82f8 -> 3f6a973f", - "bf911740 3eafb040 -> bfa36fa4", + "bf911740 3eafb040 -> bfa36fa5", "41961b7c c01fd768 -> 3fd9fffb", "4042f5d8 c00cea20 -> 400c95f1", - "c12f053d 418e307c -> bf0d3c4a", + "c12f053d 418e307c -> bf0d3c4b", "403d4400 c11dc866 -> 40366a62", "c15831c7 412e7716 -> bf644dcc", "c0d29330 41698de0 -> bed8d810", "c0fd3872 415963f8 -> bf0702af", "c131bb1f c074e670 -> bff386a5", - "417f6828 40a71880 -> 3fa09868", + "417f6828 40a71880 -> 3fa09869", "40497b80 41717390 -> 3e529a28", "413af82e c196608f -> 40257713", "4179eb80 bfb8a2f0 -> 3fd4d959", @@ -25492,8 +25493,8 @@ "414f2488 c180a1ba -> 401dad4f", "4036b5f0 c15671f1 -> 403ba162", "bf97a3d0 c0046bb8 -> c027c818", - "40ae84d8 c0f9a4b6 -> 402203ec", - "3feb4440 40f9d6e0 -> 3e6cc1c5", + "40ae84d8 c0f9a4b6 -> 402203eb", + "3feb4440 40f9d6e0 -> 3e6cc1c6", "419f7088 411a15a2 -> 3f8f723b", "c188441a bfeff970 -> bfd71812", "40560bd0 410fd5de -> 3eb65b86", @@ -25508,10 +25509,10 @@ "414c9fc4 418893dc -> 3f249745", "c033b3b8 c18b7729 -> c03ed777", "c13f3667 c152d7a7 -> c019eb30", - "c086fb50 41627eb4 -> be9446ba", + "c086fb50 41627eb4 -> be9446b9", "c13dde75 41858b9e -> bf1e348f", "41655ddc c13d8347 -> 4010b9bb", - "40a95660 41584940 -> 3ebf0aa4", + "40a95660 41584940 -> 3ebf0aa3", "4141fbc4 c0ce1146 -> 4003c77a", "c0091050 c155ea5c -> c03ee580", "40b8f0c0 c18079b9 -> 4032f416", @@ -25524,17 +25525,17 @@ "40a7d388 c07a5f68 -> 400d8c45", "4186baee c161afee -> 4011273b", "c1988920 3f471480 -> bfc3d822", - "3f70c440 40be95d8 -> 3e2060cf", + "3f70c440 40be95d8 -> 3e2060d0", "417834e8 415d5098 -> 3f57b555", "c1775234 40c0f8f8 -> bf99734e", "c1974114 4198ffc8 -> bf479800", "410c0ec4 bff12930 -> 3fe432a6", "413a6dc4 c09ce414 -> 3ffc0b0b", "be987ac0 c097edfc -> c0450d7e", - "bfa53460 4194fa8e -> bd8db6e0", + "bfa53460 4194fa8e -> bd8db6e1", "40d38cc8 c19c290d -> 403429aa", "415e9230 bea6b840 -> 3fcc0ec1", - "c1612e94 4199eb08 -> bf21ad98", + "c1612e94 4199eb08 -> bf21ad99", "c102419c 3f3e0580 -> bfbd6c6a", "c171e10c 3e75cd00 -> bfc70799", "c159f7c3 c040e368 -> bfe4ee5c", @@ -25543,18 +25544,18 @@ "c0ebecc4 4188a552 -> bed0a0ee", "40b612c0 419e96ba -> 3e8f1b96", "c1392859 c184cf09 -> c02219d5", - "40f88828 c0379bc8 -> 3ff65a7b", + "40f88828 c0379bc8 -> 3ff65a7c", "4183657e 418c205a -> 3f40d57c", "3d64c400 c049c398 -> 4047eda0", - "c184e5e2 c0ba5c6c -> bff438b2", - "c13cf1d2 c09df406 -> bffbbd3f", + "c184e5e2 c0ba5c6c -> bff438b3", + "c13cf1d2 c09df406 -> bffbbd40", "41951ea0 40d3e958 -> 3f9d5e14", "4117a1c4 4182e3fa -> 3f0666f9", "c100421b 3fa11c00 -> bfb52067", "c0a4c048 3fd9b1a0 -> bfa0392a", "c0bce6f0 c0a756e0 -> c012ed67", - "410f31a8 4089ad2c -> 3f8fb3fa", - "beb17800 40836d64 -> bdac6ea3", + "410f31a8 4089ad2c -> 3f8fb3f9", + "beb17800 40836d64 -> bdac6ea4", "c0724d88 bee688c0 -> bfd836b1", "3fb83bc0 c1693dec -> 4042c367", "c18c3dd4 4027c610 -> bfb60fc6", @@ -25562,7 +25563,7 @@ "41231d74 404d91d0 -> 3fa1fe4e", "c0e92c5a c04e1b80 -> bffe543d", "404ffd08 c1396f46 -> 403790b4", - "bf50eb60 41702f48 -> bd5e7502", + "bf50eb60 41702f48 -> bd5e7501", "c0294f90 412b4d2a -> be780e6d", "c0fe6df0 41350240 -> bf1cd3f4", "bfd3b0d0 41824f24 -> bdcf3ab0", @@ -25571,15 +25572,15 @@ "400eab20 c180166a -> 4040354a", "40b741d8 415a8318 -> 3ecb4b31", "c0b19f72 c1844941 -> c03455ba", - "418bd268 c0f5d76c -> 3ffe1262", + "418bd268 c0f5d76c -> 3ffe1263", "3fa6a0a0 c19bf37a -> 4044cb60", "417687a4 418fb914 -> 3f357bee", - "c104b28d 40b23950 -> bf7abbaa", + "c104b28d 40b23950 -> bf7abba9", "40b00cb0 be7bb000 -> 3fcec6d5", "41836b9a c15bc2b2 -> 40111937", "40697890 417344c4 -> 3e7121a8", "4190121e c158f0df -> 400dd562", - "c18bdd8b bf6a2c00 -> bfcfc0bd", + "c18bdd8b bf6a2c00 -> bfcfc0be", "418b5438 4191f240 -> 3f431ff0", "c0f7e4e4 410571fe -> bf3f9e8f", "c163d334 bd98d600 -> bfc9bb97", @@ -25590,27 +25591,27 @@ "c133cbd1 c12c03ab -> c015618c", "bf32bb00 c1111506 -> c04424d8", "40e3794c bfa93e10 -> 3fe099e8", - "419cd3d2 bf1c60c0 -> 3fcd0c96", + "419cd3d2 bf1c60c0 -> 3fcd0c97", "bf800240 40cf58c0 -> be1ccef2", "c1630418 4194bc52 -> bf26e0ae", - "419ee38a c0d13810 -> 3ff1c4a8", + "419ee38a c0d13810 -> 3ff1c4a9", "c0554ad8 bf756f80 -> bfecea8c", "4155c1e0 c086c8b0 -> 3ff02782", "c19438df c155bc54 -> c00c82ae", "4181ed52 c072d0e0 -> 3fe66fdc", "41335fee c12dcd2f -> 4015c95e", "c1571882 c194fd20 -> c0210be4", - "c19a88b4 c0b6e8d0 -> bfede30f", - "c153621e c19b2864 -> c022ca69", - "40a5abdc 419f6cf2 -> 3e822341", + "c19a88b4 c0b6e8d0 -> bfede310", + "c153621e c19b2864 -> c022ca68", + "40a5abdc 419f6cf2 -> 3e822342", "40d30c58 419f1e6c -> 3ea3ee9c", "418c0490 c0b33c62 -> 3ff0b4e5", - "c0831f14 40e27288 -> bf065d6a", - "bf2ad860 4075b2b0 -> be303f57", + "c0831f14 40e27288 -> bf065d69", + "bf2ad860 4075b2b0 -> be303f56", "bf04a640 bffc8c00 -> c038a00e", "bf41b3a0 40b8c260 -> be056f50", "4021d860 c14d4bd9 -> 403c9ba3", - "3d83a000 40cb8cf8 -> 3c258915", + "3d83a000 40cb8cf8 -> 3c258914", "c178b13e c0fba980 -> c0028267", "c0108e98 40935624 -> bee9826e", "40833c68 40d08ed0 -> 3f0fc87b", @@ -25623,13 +25624,13 @@ "c1000d87 419fc51c -> bec32579", "c0814a50 c18dda43 -> c03ab943", "c186e3b7 c15b7765 -> c0103ce8", - "4183a496 c01779a8 -> 3fdb58d2", + "4183a496 c01779a8 -> 3fdb58d3", "40e51c20 bfe49ed0 -> 3fe85ad7", - "4187b382 c1690142 -> 4011ef14", - "40d37970 419b19ac -> 3ea83333", + "4187b382 c1690142 -> 4011ef13", + "40d37970 419b19ac -> 3ea83332", "3fa7f3f0 c0b5afc8 -> 403a86cd", "c122c32b 416a72a0 -> bf1b5a62", - "40374b70 c19b6a93 -> 403fb1c2", + "40374b70 c19b6a93 -> 403fb1c3", "c08588a0 c19f609e -> c03bd8f2", "419cbe90 c170df46 -> 400e75e0", "4182e2b4 c1643109 -> 40126ac4", @@ -25642,9 +25643,9 @@ "c1300d51 c108ac40 -> c00ec793", "410261e6 c1588362 -> 40265f3f", "419adcac 40054e48 -> 3fbb5784", - "41798160 c0ecc4b0 -> 4000e25e", + "41798160 c0ecc4b0 -> 4000e25f", "40639318 c1702fdd -> 403a2d2c", - "c1794797 c1829f3b -> c0184bca", + "c1794797 c1829f3b -> c0184bc9", "c0904192 c0035a40 -> bfffbf67", "41787da0 c19a2a35 -> 401da5e0", "4120e654 c1014c4b -> 400fdab9", @@ -25664,15 +25665,15 @@ "4112e32a c18e9f42 -> 402aa0a2", "bfaa2d50 40ef51f8 -> be3427dc", "c16ae95c 406f7cb0 -> bfa91e64", - "40d2db80 405037e8 -> 3f8e5b81", + "40d2db80 405037e8 -> 3f8e5b82", "be920280 419b18c2 -> bc70fbde", "41823e9c c10676ec -> 4003074a", "40fc01e0 40a9689c -> 3f7a9c4a", "416ade10 c15a3b2c -> 40147298", "c12b4626 419bd752 -> bf00a1ec", - "413a29aa c0cd07be -> 4004befe", + "413a29aa c0cd07be -> 4004beff", "c199d0c3 c1866487 -> c0127d6a", - "419e0288 c1425a56 -> 4007d1b2", + "419e0288 c1425a56 -> 4007d1b3", "4144a97c 4083fd0c -> 3f9f9ec3", "4104e2a2 3f327440 -> 3fbe57fd", "c10cd1b9 40378e30 -> bfa0bd4e", @@ -25685,7 +25686,7 @@ "3fc08c60 c177750c -> 4042db49", "c10a43a9 c0be1246 -> c00b121d", "4061f8c0 40e4294c -> 3eeb6b1a", - "c0aa3df6 4165c098 -> beb5a9ce", + "c0aa3df6 4165c098 -> beb5a9cf", "c13c904c c1220f4d -> c011f7c3", "4141fe18 c13691ef -> 4014db0f", "c043e348 40bf5874 -> bef239e5", @@ -32770,6 +32771,268 @@ "c486ccb7 4464268f 42b60000 44088700 42475598 00000000 43258de7 44372289 44df86e2 00000000 43904267 42f00000 c2b40000 40e00000 42b80000 43200000 c2f00000 40e00000 42ba0000 43480000 c3160000 40e00000 42bc0000 43700000 c3340000 40e00000 42be0000 438c0000 c3520000 40e00000 42c00000 43a00000 c3700000 40e00000 42c20000 43b40000 c3870000 40e00000 42c40000 43c80000 c3960000 40e00000 42c60000 43dc0000 c3a50000 40e00000 42c80000 43f00000 c3b40000 40e00000 42ca0000 44020000 c3c30000 40e00000 42cc0000 440c0000 c3d20000 40e00000 42ce0000 44160000 c3e10000 40e00000 42d00000 44200000 c3f00000 40e00000 42d20000 442a0000 c3ff0000 40e00000 42d40000 44340000 c4070000 40e00000 42d60000 443e0000 c40e8000 40e00000 42d80000 44480000 c4160000 40e00000 42da0000 44520000 c41d8000 40e00000 42dc0000 445c0000 c4250000 40e00000 42de0000 44660000 c42c8000 40e00000 42e00000 44700000 c4340000 40e00000 42e20000 447a0000 c43b8000 40e00000 42e40000 44820000 c4430000 40e00000 42e60000 44870000 c44a8000 40e00000 42e80000 448c0000 c4520000 40e00000 42ea0000 44910000 c4598000 40e00000 42ec0000 44960000 c4610000 40e00000 42ee0000 449b0000 c4688000 40e00000 42f00000 44a00000 c4700000 40e00000 42f20000 44a50000 c4778000 40e00000 42f40000 44aa0000 c47f0000 40e00000 42f60000 44af0000 c4834000 40e00000 42f80000 44b40000 c4870000 40e00000 42fa0000 44b90000 c48ac000 40e00000 42fc0000 44be0000 c48e8000 40e00000 42fe0000 44c30000 c4924000 40e00000 43000000 44c80000 c4960000 40e00000 43010000 44cd0000 c499c000 40e00000 43020000 44d20000 c49d8000 40e00000 43030000 44d70000 c4a14000 40e00000 43040000 44dc0000 c4a50000 40e00000 43050000 44e10000 c4a8c000 40e00000 43060000 44e60000 c4ac8000 40e00000 43070000 44eb0000 c4b04000 40e00000 43080000 44f00000 c4b40000 40e00000 43090000 44f50000 c4b7c000 40e00000 430a0000 44fa0000 c4bb8000 40e00000 430b0000 44ff0000 c4bf4000 40e00000 430c0000 45020000 c4c30000 40e00000 430d0000 45048000 c4c6c000 40e00000 430e0000 45070000 c4ca8000 40e00000 430f0000 45098000 c4ce4000 40e00000 43100000 450c0000 c4d20000 40e00000 43110000 450e8000 c4d5c000 40e00000 43120000 45110000 c4d98000 40e00000 43130000 45138000 c4dd4000 40e00000 43140000 45160000 c4e10000 40e00000 43150000 45188000 c4e4c000 40e00000 43160000 451b0000 c4e88000 40e00000 43170000 451d8000 c4ec4000 40e00000 43180000 45200000 c4f00000 40e00000 43190000 45228000 c4f3c000 40e00000 431a0000 45250000 c4f78000 40e00000 431b0000 45278000 c4fb4000 40e00000 431c0000 452a0000 c4ff0000 40e00000 431d0000 452c8000 c5016000 40e00000 431e0000 452f0000 c5034000 40e00000 431f0000 45318000 c5052000 40e00000 43200000 45340000 c5070000 40e00000 43210000 45368000 c508e000 40e00000 43220000 45390000 c50ac000 40e00000 43230000 453b8000 c50ca000 40e00000 43240000 453e0000 c50e8000 40e00000 43250000 45408000 c5106000 40e00000 43260000 45430000 c5124000 40e00000 43270000 45458000 c5142000 40e00000 43280000 45480000 c5160000 40e00000 43290000 454a8000 c517e000 40e00000 432a0000 454d0000 c519c000 40e00000 432b0000 454f8000 c51ba000 40e00000 432c0000 45520000 c51d8000 40e00000 432d0000 45548000 c51f6000 40e00000 432e0000 45570000 c5214000 40e00000 432f0000 45598000 c5232000 40e00000 43300000 455c0000 c5250000 40e00000 43310000 455e8000 c526e000 40e00000 43320000 45610000 c528c000 40e00000 43330000 45638000 c52aa000 40e00000 43340000 45660000 c52c8000 40e00000 43350000 45688000 c52e6000 40e00000 43360000 456b0000 c5304000 40e00000 43370000 456d8000 c5322000 40e00000 43380000 45700000 c5340000 40e00000 43390000 45728000 c535e000 40e00000 433a0000 45750000 c537c000 40e00000 433b0000 45778000 c539a000 40e00000 433c0000 457a0000 c53b8000 40e00000 433d0000 457c8000 c53d6000 40e00000 433e0000 457f0000 c53f4000 40e00000 433f0000 4580c000 c5412000 40e00000 43400000 45820000 c5430000 40e00000 43410000 45834000 c544e000 40e00000 43420000 45848000 c546c000 40e00000 43430000 4585c000 c548a000 40e00000 43440000 45870000 c54a8000 40e00000 43450000 45884000 c54c6000 40e00000 43460000 45898000 c54e4000 40e00000 43470000 458ac000 c5502000 40e00000 43480000 458c0000 c5520000 40e00000 43490000 458d4000 c553e000 40e00000 434a0000 458e8000 c555c000 40e00000 434b0000 458fc000 c557a000 40e00000 434c0000 45910000 c5598000 40e00000 434d0000 45924000 c55b6000 40e00000 434e0000 45938000 c55d4000 40e00000 434f0000 4594c000 c55f2000 40e00000 43500000 45960000 c5610000 40e00000 43510000 45974000 c562e000 40e00000 43520000 45988000 c564c000 40e00000 43530000 4599c000 c566a000 40e00000 43540000 459b0000 c5688000 40e00000 43550000 459c4000 c56a6000 40e00000 43560000 459d8000 c56c4000 40e00000 43570000 459ec000 c56e2000 40e00000 43580000 45a00000 c5700000 40e00000 43590000 00000002 43820000 3f333333 3fcccccd 42f00000 3eb33333 00000000 00000000 3f800000 -> 3ef8c434 3f32636d 3efbcd58 3e549b04", "c4b3e76c 444241a0 42500000 435b9579 4136dbcf 00000000 43945431 43041651 43830dcc 00000000 43652355 c48bf352 44d33444 00000000 434dc873 c3000002 c10833d3 00000000 433550ff c28b6e94 c30f7278 00000000 438d43a6 44a4de09 c4f12ff1 00000000 425a0d22 44d428d3 430976c5 00000000 434b973e 441d5713 44a7fc0b 00000000 438e7c40 c4ab19d7 44f2b987 00000000 41e038cc c455d2d5 c2a36170 00000000 43908b8d c3801d60 c40e138a 00000000 431363cc 43396792 c37f2156 00000000 437ac80c 439f85f6 420c7863 00000000 42150b58 4428e36e 44c2bb96 00000000 43837958 c47c7670 44a88b01 00000000 4294d48d c40641aa c285e8ba 00000000 4232d05c c40ca53f c4a8ca3a 00000000 4378763b 42cfefd3 c306ba40 00000000 430371e3 44b979fd 434ebfb9 00000000 43421a7a 42b44286 43616d19 00000000 43047467 c2892c2a 42ac8e8b 00000000 4332087f c4f373b1 c39604df 00000000 42b3908c c4071e02 c4b04c5b 00000000 4347459e 43fd006f c41a827e 00000000 4208fda6 44c75602 43869ead 00000000 42421b44 4351a83b 440ee820 00000000 42cc9909 c48345c8 449bb4d1 00000000 42125362 c4abe6d8 c37c9d2e 00000000 433a98a6 c43a8ecd c5050f9b 00000000 42b796d8 4488f948 c49dcc59 00000000 43638b9c 4518e99d 43f2fa58 00000000 4271681e 4436363e 450837cb 00000000 437c8686 c49f0216 44b1f01c 00000000 438026fe c4af2be6 c395b304 00000000 43814487 c3f349af c4befb18 00000000 428c07fa 4424afd0 c4330845 00000000 42decd7b 4513cf17 44073fe5 00000000 438a0efc 442d8bbd 450f5844 00000000 434b07a5 c493ef20 449c3dfb 00000000 437f587e c4222d07 c31e43fc 00000000 43128220 c4066f1d c4ea31b9 00000000 4340306c 43e81d81 c3ee2e59 00000000 4330577c 4514ae2d 441a3491 00000000 43869616 439ae709 448ea55a 00000000 43226a3e c4068e80 440626bb 00000000 430d975e c49c9b56 c3ac1805 00000000 42dea520 c4161f98 c5129034 00000000 42da3948 44400c36 c43a07ca 00000000 43826b94 44c6d8dc 43e6e324 00000000 438edae5 4396e0d8 449ca83c 00000000 43643dd5 c35d19fc 4350154a 00000000 430efc77 c485b6e4 c3a3a64d 00000000 4312ff68 c3cdea28 c4e43378 00000000 4385fba4 44224024 c4145936 00000000 435e4c43 43a49d73 42d3e418 00000000 438636f8 43ed8fd2 450d12ed 00000000 4210bc80 c2dcc99b 42c41aba 00000000 4299b2ee c50fdfe9 c44262e4 00000000 42fe5060 c3dac9b9 c50be58c 00000000 42f0366f 44ca1d48 c4ae6032 00000000 4382f138 4475cf34 43adfc47 00000000 420467d2 4383e7ac 44b69e10 00000000 42939fb6 c4a5b2bc 448ad7eb 00000000 41f43fa8 c461b848 c3a71faa 00000000 434a4234 c358c6a6 c4a37e3e 00000000 423c3b16 42ae5790 c28ddcb8 00000000 435f71b1 44586d7a 43a75f50 00000000 431dfe64 43126575 44726ffe 00000000 42e181b4 c3da9fe9 43acb878 00000000 436eefe3 c4efcdc3 c4416fe4 00000000 423c53dc c344a113 c4b46166 00000000 435ffd97 44553a65 c4238580 00000000 430092b5 4499e952 440156a4 00000000 430fbb25 42c9a36d 444f404d 00000000 4369e56c c43b5358 440b6b0e 00000000 41cf9cce c5087fe5 c46ebaa1 00000000 42caf529 c21191e4 c3aa0683 00000000 4344d73b 443ee95e c409dbcb 00000000 41f53bb8 44f6e532 44606df7 00000000 42aa10aa 433460d1 44f3d985 00000000 43582457 c48b83c0 44436f9c 00000000 4350ab77 c4098067 c381cd3a 00000000 41ad0134 c32c4d09 c50a24e7 00000000 4293fef2 44bb391d c47e4d73 00000000 432790ce 44ec7b40 4467a0ac 00000000 43470d71 420e374b 440c07c0 00000000 425deb6d c4911239 443effdb 00000000 4366654f c43619f1 c3b8eb31 00000000 434e068d c2a05b66 c4cc6faf 00000000 4291eb66 44b5dd54 c468003e 00000000 42acbd25 44cd3c28 4457e72d 00000000 433ac26f 41e50db8 444eeade 00000000 431d0e95 c3beb1b5 436b9aef 00000000 435bf745 c4afd8d7 c43f8161 00000000 435bc798 c10e3dc0 c3dc6413 00000000 42e0327b 448376b9 c41d3ced 00000000 439027f4 42ccb7fb 4266a7eb 00000000 42561b63 4107706a 44b7ae4c 00000000 4386c984 c49102da 4427d07c 00000000 43041b2c c48de2f6 c4254afa 00000000 42dc4d73 417fe3ca c4e73dfa 00000000 436e328f 44fdd280 c48e0708 00000000 435a40e7 44dfcd51 4486b7c0 00000000 437af18e c22ffde1 44ee8d5d 00000000 42ebac6a c445a9ae 43d5c9a6 00000000 43543326 c45fbee4 c40b1d1a 00000000 43507e5b 425680ee c4b2d793 00000000 432a871c 45066678 c48c65e4 00000000 438c4680 44501288 44059013 00000000 435e3b80 c2e08f5e 45072ba4 00000000 428609a6 c4a3143d 44246ade 00000000 4395e45b c4556a6b c40d5fe9 00000000 4321ff85 4206eb23 c3fe1841 00000000 42ab0018 43addb6e c3290c04 00000000 4308e930 43dc68f5 43969f8a 00000000 430610e6 c2126c9e 43e25da0 00000000 42ae86ef c4c4feae 4438935d 00000000 42ef05dd c486c228 c43def39 00000000 438f4904 4321a791 c4d3da54 00000000 433ab2f6 44c00026 c42d31f8 00000000 42db619b 44f338d9 44b0b3b2 00000000 42c2bff2 c2dd8512 447bddda 00000000 4313918c c505ea13 44685e96 00000000 4372ce6f 459b0000 c5688000 40e00000 43550000 459c4000 c56a6000 40e00000 43560000 459d8000 c56c4000 40e00000 43570000 459ec000 c56e2000 40e00000 43580000 45a00000 c5700000 40e00000 43590000 0000007b 43820000 3f333333 3fcccccd 42f00000 3eb33333 3f19999a 3f4ccccd 3f800000 -> 3f1796ad 3f2abc83 3f147ad8 3f6031e3" ] + }, + { + "glsl": "float cultmath_fbm(vec3, int, float, float)", + "tolerance": "ulp-bounded", + "cases": [ + "c11a9d90 c04202a0 c20bc826 00000004 40000000 3f000000 -> 3f27d6ca", + "41601e80 4005e0a0 41eba008 00000004 40000000 3f000000 -> be8cf3fd", + "407e0d30 c181cd2c 41cbe660 00000004 40000000 3f000000 -> 3eebda0d", + "41b7f3d4 c129244c c0fdb2f0 00000004 40000000 3f000000 -> bd3ada1f", + "407005a0 4118392c 41a44bb4 00000004 40000000 3f000000 -> beabbce1", + "c1830e64 c2048dab 422a3126 00000004 40000000 3f000000 -> 3e53b796", + "407894c0 c2091ce8 4243e388 00000004 40000000 3f000000 -> beff701d", + "c1f06795 c23c1b2b c2412342 00000004 40000000 3f000000 -> 3ec7b869", + "c1bdbda4 41dc0d48 c22f43c4 00000004 40000000 3f000000 -> bec760f6", + "408126b0 c1ea0445 c1a7655d 00000004 40000000 3f000000 -> 3e0a4e06", + "413dbdcc 422bc160 40f21a68 00000004 40000000 3f000000 -> bea53f12", + "c1484ebc 420439be 416f96b0 00000004 40000000 3f000000 -> 3e72cffa", + "c1ba67c0 408252b0 421a9588 00000004 40000000 3f000000 -> bf451a7c", + "c16b1c64 41a06cd4 c176d898 00000004 40000000 3f000000 -> 3f02099c", + "4120d64c 41de56d4 40b8cd88 00000004 40000000 3f000000 -> 3f2bebc0", + "c1ca675a c230dc20 c22de33d 00000004 40000000 3f000000 -> 3f1cd196", + "4137ed30 4146d32c c21ce93a 00000004 40000000 3f000000 -> 3da3b336", + "c08f7cb0 4226b458 41db68b4 00000004 40000000 3f000000 -> be182486", + "41f4cfe4 c1ece732 c0c027f0 00000004 40000000 3f000000 -> 3daec608", + "c212a4e3 c1446638 41c4aed0 00000004 40000000 3f000000 -> be06a746", + "41ba2790 4247bbfe c213e474 00000004 40000000 3f000000 -> 3f08da39", + "c065a220 c21b758b c060a740 00000004 40000000 3f000000 -> 3f0948cb", + "420a0656 c206ebf6 41d3a918 00000004 40000000 3f000000 -> be0f2d50", + "c231bfd1 c22c5352 414c9ffc 00000004 40000000 3f000000 -> 3d5ad4f0", + "c2386461 c1fb5f35 c08a9a88 00000004 40000000 3f000000 -> bf0307b4", + "416666b8 411e5a8c c1c68d2b 00000004 40000000 3f000000 -> bf3c2a7e", + "c081e798 c241ab24 41adc588 00000004 40000000 3f000000 -> 3ecf6bca", + "42469f7a 422a298a 41052448 00000004 40000000 3f000000 -> bd9e0e93", + "c1fd92e8 c206a0f6 c242f2f6 00000004 40000000 3f000000 -> bea9dec8", + "41f11de8 40dc22f8 414ab55c 00000004 40000000 3f000000 -> bd8bc474", + "c1e82aab c2128efd c2069c1b 00000004 40000000 3f000000 -> be7c0319", + "c20fc1e0 41409478 41a0f4d8 00000004 40000000 3f000000 -> 3da2f1d0", + "c1e3a366 c211704c 418541ec 00000004 40000000 3f000000 -> 3f3968b5", + "c222dbdb c11ce808 421e0fd6 00000004 40000000 3f000000 -> 3eb06c7c", + "c19f6f92 40bdd8e0 40b040e8 00000004 40000000 3f000000 -> 3d4ea228", + "c0011390 c1062cd4 41b8c7e4 00000004 40000000 3f000000 -> bed7e250", + "c191f0aa c20731bd 4127d710 00000004 40000000 3f000000 -> 3e0d1a30", + "c1f97cd1 418b3020 c15db9a8 00000004 40000000 3f000000 -> 3dfd58fc", + "c23c8b5e 41bce8a0 c22a6d8e 00000004 40000000 3f000000 -> 3c465804", + "409693d0 c1af215b 42332b6c 00000004 40000000 3f000000 -> bee9b32d", + "c098bbf0 c236889e 41163eac 00000004 40000000 3f000000 -> 3f0bd913", + "c210d8cb 420a5584 422e3bac 00000004 40000000 3f000000 -> bebb6492", + "c063be60 421f5d7e 40d15a40 00000004 40000000 3f000000 -> 3ebd15a8", + "41cc6488 c0e4a4f8 c06f9100 00000004 40000000 3f000000 -> 3db49ff4", + "410861d0 c155845c 41363b78 00000004 40000000 3f000000 -> bdcb6293", + "4200d2ee 419cae84 c1564644 00000004 40000000 3f000000 -> 3edb892a", + "c23cbf0a 41a28f8c 41c5d480 00000004 40000000 3f000000 -> bf2d47fb", + "c1aeadcf bfcf1700 c1152790 00000004 40000000 3f000000 -> be5972ba", + "c23f486c c23f56ed c0813868 00000004 40000000 3f000000 -> 3ee6dc4a", + "41f1a4a0 c21b3682 4219b0ce 00000004 40000000 3f000000 -> bf0eadc6", + "414b9fe8 4186b7a4 420e2062 00000004 40000000 3f000000 -> 3e2980bf", + "41aeeb18 4160ee88 c162c594 00000004 40000000 3f000000 -> bee70d01", + "c19effaa c206defe 41aacb30 00000004 40000000 3f000000 -> 3de761d6", + "413f31c0 c241d553 3fe2e9a0 00000004 40000000 3f000000 -> bde2b38a", + "c205c755 40c6ab38 41bac5c0 00000004 40000000 3f000000 -> 3ed8ad73", + "c20f112c 4124a490 c19a0db2 00000004 40000000 3f000000 -> bdc30b1c", + "41c60570 c1eabc98 c237e2f2 00000004 40000000 3f000000 -> bf4faa5b", + "41a64804 3ff230a0 40f93c68 00000004 40000000 3f000000 -> bee98cca", + "c1ef7da7 c203c097 40bf80d8 00000004 40000000 3f000000 -> 3db61265", + "c1fa4ce3 c219905e 4201ac72 00000004 40000000 3f000000 -> bf387f6a", + "c019d480 4181e398 41e52800 00000004 40000000 3f000000 -> be033562", + "4190f4f4 c214b5b7 41d01f1c 00000004 40000000 3f000000 -> 3d5e261c", + "412b9a38 4212a2e8 41b4c68c 00000004 40000000 3f000000 -> 3f336c18", + "c1c37f7b 40052c30 405363f0 00000004 40000000 3f000000 -> 3e9fce4c", + "41742360 c1122dec 41963a2c 00000004 40000000 3f000000 -> 3d369949", + "c1d85a1d c2198f93 4228b1e0 00000004 40000000 3f000000 -> 3e151611", + "418aa674 4217c8c0 c1020040 00000004 40000000 3f000000 -> 3d9e5382", + "c20b2034 c232306a 422227ca 00000004 40000000 3f000000 -> 3d21dba6", + "420993c0 4227c3f8 c20c35b2 00000004 40000000 3f000000 -> 3e254db8", + "c1b20e56 3fe68be0 c0403c60 00000004 40000000 3f000000 -> 3e00593c", + "41eebd00 c21a5963 c2354e9f 00000004 40000000 3f000000 -> bf023291", + "421bb928 c20e9648 c16b1c78 00000004 40000000 3f000000 -> bf0b8dcb", + "41b90698 bfc839c0 c0d3a708 00000004 40000000 3f000000 -> bf1723c8", + "423d5354 40608460 41bd0c74 00000004 40000000 3f000000 -> bcc26220", + "41025788 bf4f6280 41949de4 00000004 40000000 3f000000 -> beed4e99", + "3f39b3c0 c231e4d4 c21be301 00000004 40000000 3f000000 -> be0bb9b2", + "421c0a76 423c6e3a c2323104 00000004 40000000 3f000000 -> bf40bdcd", + "415cf8ec 3fb0caa0 c1d2b23c 00000004 40000000 3f000000 -> be851f93", + "c1174dfc c18cfa28 412c88d4 00000004 40000000 3f000000 -> 3f111826", + "42186c38 4206e6ba c1e8f35c 00000004 40000000 3f000000 -> bec21f9a", + "41311500 c188248e c17245d8 00000004 40000000 3f000000 -> 3d6acd72", + "c1c331e9 c088f1f0 c1154f68 00000004 40000000 3f000000 -> 3f32b2ec", + "bfaf6440 41d29f60 c1bbea67 00000004 40000000 3f000000 -> 3d96329a", + "c1843796 be305a00 4152c8e8 00000004 40000000 3f000000 -> bdce91e5", + "bf9b27e0 419fffcc c178fe1c 00000004 40000000 3f000000 -> 3e94636d", + "4191f36c 40e24f50 40c86908 00000004 40000000 3f000000 -> beb06eb9", + "c1e04e4e c182a61e 4211a8cc 00000004 40000000 3f000000 -> 3e4f51ee", + "c23852b1 c1344684 40963120 00000004 40000000 3f000000 -> 3f06fbc7", + "c1074b08 c207dfba 41208124 00000004 40000000 3f000000 -> 3f44398c", + "41f81500 bfce6b20 41ffcd98 00000004 40000000 3f000000 -> 3db207ed", + "41c42d58 420c1714 416b5de8 00000004 40000000 3f000000 -> be56c8b1", + "c1dbf666 c15bc7b4 c1ebcae6 00000004 40000000 3f000000 -> be987233", + "c133a140 c18ba254 c23a1f4c 00000004 40000000 3f000000 -> be6063b7", + "c1b0ba6b c23f9e0f c1a66906 00000004 40000000 3f000000 -> 3e71480d", + "420a1232 40b13200 c02e16b0 00000004 40000000 3f000000 -> bda67ee4", + "c0f719f8 c20be886 41fb0cd8 00000004 40000000 3f000000 -> be9018c6", + "421ce668 41b26594 41ca7208 00000004 40000000 3f000000 -> 3f33ea18", + "c235eaec 418126bc 3d672000 00000004 40000000 3f000000 -> bea45437", + "c17889e4 40e4bf88 4230ff5a 00000004 40000000 3f000000 -> beb68251", + "41f150cc 420839ce c1a27d07 00000004 40000000 3f000000 -> bcbd7004", + "41f517bc 418cf4bc c1b079ee 00000004 40000000 3f000000 -> bb24d74c", + "c0aedff8 410f3624 416ce240 00000004 40000000 3f000000 -> 3f0d6ce0", + "4241dac2 c1bdbc18 c1821c58 00000004 40000000 3f000000 -> bea85f48", + "c2105a14 c1a43bf4 c1cbc9d9 00000004 40000000 3f000000 -> 3ebe4d06", + "4032c6d0 42303610 c19369ff 00000004 40000000 3f000000 -> 3ee7e743", + "40e6ea70 c1f4c9c7 4204c81e 00000004 40000000 3f000000 -> be98d471", + "c22a5870 4245107c c243f735 00000004 40000000 3f000000 -> bed2576b", + "41e533b8 42272418 41d0b3b4 00000004 40000000 3f000000 -> 3e0d2bde", + "c22d5237 41a00334 c1f8f891 00000004 40000000 3f000000 -> 3e1a4a1f", + "c20d624e 42182746 41e8ec44 00000004 40000000 3f000000 -> bd73af1a", + "c1bdc99a 4109bd28 bfc15ee0 00000004 40000000 3f000000 -> bdd4a46b", + "41be3e70 c02e9130 c17861bc 00000004 40000000 3f000000 -> be9a49dc", + "c242f78d c1204738 423a58b8 00000004 40000000 3f000000 -> bd8f7f9e", + "4201e604 c0c5f000 421af586 00000004 40000000 3f000000 -> bd939ee0", + "c016b680 41ca80f8 c0e579d8 00000004 40000000 3f000000 -> be949954", + "3f477c00 3fdd1520 41b0ba9c 00000004 40000000 3f000000 -> 3f61641f", + "c1fc2271 41da0074 c1440cb0 00000004 40000000 3f000000 -> 3d22d4bd", + "c21bc8c4 41b601ac 420b2952 00000004 40000000 3f000000 -> bea1ff75", + "c1aa8e9f c2400dfe 41ceac90 00000004 40000000 3f000000 -> bf0ee9ae", + "3fed25c0 c1422724 421bcbf0 00000004 40000000 3f000000 -> 3e7b8260", + "c0825938 42073d10 c13c1548 00000004 40000000 3f000000 -> 3e8108b9", + "4093dd90 42168aba 41a6e578 00000004 40000000 3f000000 -> bf70dcc5", + "41d04c14 41e81a68 c04e0de0 00000004 40000000 3f000000 -> 3f169ba9", + "423c6370 40bea6d8 41eb59d4 00000004 40000000 3f000000 -> be27a3b6", + "42126f66 c1c87ce9 40ae73e0 00000004 40000000 3f000000 -> bdecf67a", + "c216ecbb c197f579 c2061c65 00000004 40000000 3f000000 -> bec0f5a1", + "c185edd2 40bc7ec8 c1fdce1e 00000004 40000000 3f000000 -> beaeea8a", + "c04d9980 4238aeec c21cd8f3 00000004 40000000 3f000000 -> bf4c5f9d", + "c10a1ac8 c20b1c08 420f6e74 00000004 40000000 3f000000 -> bca013bc", + "415b2edc c09ccbc8 c096d8b0 00000004 40000000 3f000000 -> be5536d7", + "410a4524 bf182e80 c23f8806 00000004 40000000 3f000000 -> 3ee6c4f3", + "c2297336 c1573b54 412013c4 00000004 40000000 3f000000 -> 3c3ab7a0", + "3efd6c00 422c18e8 4216b5c2 00000004 40000000 3f000000 -> beeca0ab", + "41afbc10 41c5efcc 41b5b6e8 00000004 40000000 3f000000 -> bf364b64", + "c1c583a7 c1e4e94f c1e5a945 00000004 40000000 3f000000 -> bf34f4f8", + "41053fa4 c1cc7056 c00c0b50 00000004 40000000 3f000000 -> bdda9df2", + "c1a54a53 c2065a94 41a7d97c 00000004 40000000 3f000000 -> 3e6ae330", + "4115cab4 c1fb3fd3 41ffccbc 00000004 40000000 3f000000 -> be2050ce", + "41a565fc 42448e70 42168e26 00000004 40000000 3f000000 -> bd95b845", + "c1b458be c212fd9c 41b34d84 00000004 40000000 3f000000 -> bde52094", + "420c044a c216a5ac c180b2a6 00000004 40000000 3f000000 -> 3ea3b257", + "c24205c8 c138b768 c22dca6a 00000004 40000000 3f000000 -> bf026e60", + "41da69e0 41e7c53c 42199060 00000004 40000000 3f000000 -> 3e296457", + "4235c98a 413c61b4 c1404e8c 00000004 40000000 3f000000 -> bd267211", + "c1a9a44f 4134ac38 41f92fec 00000004 40000000 3f000000 -> bd77a9b6", + "c08e1418 c1e42f89 41c64724 00000004 40000000 3f000000 -> 3f071a60", + "421dfbb0 42042964 42231c22 00000004 40000000 3f000000 -> be057b84", + "c13f435c c17a3c80 c1aa7dc8 00000004 40000000 3f000000 -> bece20fe", + "41c27058 42451e9c c19981a2 00000004 40000000 3f000000 -> be79305d", + "c1eedcc7 c15098d0 c1497518 00000004 40000000 3f000000 -> be9e1b90", + "41b2ed98 c1d7b191 41ee2d04 00000004 40000000 3f000000 -> 3d44b899", + "c20af100 4213d9c0 41467648 00000004 40000000 3f000000 -> 3ec0aa40", + "c1332ce4 c1d17816 4203b932 00000004 40000000 3f000000 -> be11a895", + "c20d0a38 41f835f0 c090d638 00000004 40000000 3f000000 -> be1498a4", + "4116bbac c180dd68 409467f0 00000004 40000000 3f000000 -> bf0fd158", + "c187ff3c c19b7655 40e9a840 00000004 40000000 3f000000 -> 3e89df12", + "c244b197 c1c714ff 41dd4c6c 00000004 40000000 3f000000 -> 3ead3f11", + "c214956d c140a03c c19e4e02 00000004 40000000 3f000000 -> 3ef4d00f", + "41fd1540 c042ae00 41dfd2e8 00000004 40000000 3f000000 -> 3d973e78", + "c2331a6e c1b67be9 c12d31d4 00000004 40000000 3f000000 -> be750957", + "41cdeccc 40e51a98 c0ffb978 00000004 40000000 3f000000 -> be253870", + "3f7b69c0 c1d75a82 c1343b2c 00000004 40000000 3f000000 -> 3dbf25d2", + "41035764 c211e128 41c2a2a0 00000004 40000000 3f000000 -> 3f151eee", + "c1fa0b38 c22cf74a 423ea448 00000004 40000000 3f000000 -> bd6e638c", + "42276a60 41c60bd0 c23ff195 00000004 40000000 3f000000 -> bf359aaa", + "c09648b8 41e5007c c246206c 00000004 40000000 3f000000 -> 3ec9a87b", + "41c06838 c10b20d4 4195b38c 00000004 40000000 3f000000 -> be8ff22f", + "41bc1450 c1f57998 c2189bb5 00000004 40000000 3f000000 -> 3e28b2f1", + "411bf210 411830e4 420c4f46 00000004 40000000 3f000000 -> be20e42c", + "bee13d80 c182f740 c11fed84 00000004 40000000 3f000000 -> bf6b6b32", + "4209b41c c1836194 423a81ea 00000004 40000000 3f000000 -> 3eebe724", + "c0c7d2c8 c1300278 c1d110b4 00000004 40000000 3f000000 -> 3edf09dc", + "c23a2398 c2263ebc c21cc173 00000004 40000000 3f000000 -> 3f347bc5", + "c2086436 41c65514 c203da03 00000004 40000000 3f000000 -> be4a016a", + "4203e3b8 42005406 c1fd266b 00000004 40000000 3f000000 -> 3e92d85b", + "41f003dc c1e7ff9b 41534824 00000004 40000000 3f000000 -> bf51dab7", + "c1ae6ad8 c15ab124 4192a9c8 00000004 40000000 3f000000 -> 3ea4064b", + "41dc13c0 c1969d16 420f8a40 00000004 40000000 3f000000 -> 3f0909f0", + "41a3b388 c23f13c1 c1427f80 00000004 40000000 3f000000 -> 3e6ede51", + "c19eb6f5 4224f120 41591a18 00000004 40000000 3f000000 -> 3e6ab4d3", + "406a1ef0 c22567ab 41ef34f0 00000004 40000000 3f000000 -> bf055e0e", + "c17acd40 c17ae6e4 41b97304 00000004 40000000 3f000000 -> be66940a", + "c243dda3 bff368e0 c163e5e4 00000004 40000000 3f000000 -> bd501bea", + "41e12d8c c10295fc 41a17df0 00000004 40000000 3f000000 -> 3dd3be72", + "421ea106 4102614c 41841a84 00000004 40000000 3f000000 -> bf09bbcc", + "3fd67940 42222670 c14c4344 00000004 40000000 3f000000 -> 3ee83191", + "422df782 41edf52c c137cfe8 00000004 40000000 3f000000 -> 3d22e603", + "c1e10584 c1e51a20 423d30aa 00000004 40000000 3f000000 -> 3f0369ee", + "41ad5f78 c22c55c3 c23e623f 00000004 40000000 3f000000 -> bd8b828e", + "c21d0856 41ba57c4 c153b374 00000004 40000000 3f000000 -> bf83804f", + "42101786 41ba6fc4 c2032fdd 00000004 40000000 3f000000 -> 3e0487d8", + "c0307f90 4143e76c 41812468 00000004 40000000 3f000000 -> bf30ffd4", + "c14dd1ec bfad1f00 c00f2420 00000004 40000000 3f000000 -> bdccd9c1", + "c09a65b8 41e7bf24 41b736f4 00000004 40000000 3f000000 -> 3f093490", + "c1b468fb 3d4f3800 4231f934 00000004 40000000 3f000000 -> 3c21555a", + "424095ea 421768d8 41b83244 00000004 40000000 3f000000 -> bece4789", + "c1f50604 c1f9b40a c2193a31 00000004 40000000 3f000000 -> be4a50c7", + "c2458f10 c1554bf0 c21a0974 00000004 40000000 3f000000 -> bf1acbb9", + "c1c78b00 c191b69e 42152566 00000004 40000000 3f000000 -> be957456", + "420269ce 414a73d0 c1b4c7ed 00000004 40000000 3f000000 -> 3e697510", + "41a63780 42212ab4 41c671ac 00000004 40000000 3f000000 -> 3f139976", + "4243f362 42287f3e 4194453c 00000004 40000000 3f000000 -> 3c50a516", + "403eed70 c0f2b800 42379e78 00000004 40000000 3f000000 -> beba9818", + "c24684f1 c20dd7bc c101c264 00000004 40000000 3f000000 -> 3d3cca45", + "421de172 bfd77900 c244e4fc 00000004 40000000 3f000000 -> 3f050db9", + "4179c230 4231b1ba 41aa7e40 00000004 40000000 3f000000 -> 3e3e0e04", + "c23cc88c 41bc33a8 c1f77d37 00000004 40000000 3f000000 -> 3d7074f3", + "c1a4f6c3 42257aac 40902f38 00000004 40000000 3f000000 -> 3cded1fb", + "c1c76558 420da696 c155d488 00000004 40000000 3f000000 -> bdb2d8a1", + "c2312669 c1de84f2 c19c55ac 00000004 40000000 3f000000 -> bf733e95", + "41e6d99c 40910dc0 420ffa14 00000004 40000000 3f000000 -> be87cefb", + "c1ed1c98 41c32f10 421ab4de 00000004 40000000 3f000000 -> beac3f5c", + "424062a4 c1ac4fc1 40fda488 00000004 40000000 3f000000 -> bed84bad", + "c1c052d0 c09ac768 c0920138 00000004 40000000 3f000000 -> 3e8a6d70", + "41d8f554 c1f7fc22 c2370249 00000004 40000000 3f000000 -> 3eb1ddd1", + "419e6cd0 c21b7082 421968ce 00000004 40000000 3f000000 -> be530987", + "c21d2479 411727cc c14a3428 00000004 40000000 3f000000 -> 3dd46038", + "c20819bc 41ee64b4 421c8320 00000004 40000000 3f000000 -> bdbaa7c4", + "c226a55b c109a378 4243b008 00000004 40000000 3f000000 -> 3da8d91f", + "c1ed9a23 41bf4c20 c22d2758 00000004 40000000 3f000000 -> be4a641a", + "c190d081 c02eac10 c1ee290f 00000004 40000000 3f000000 -> 3e49c3d4", + "41772540 c1e3e37f 41201a34 00000004 40000000 3f000000 -> be5c48b4", + "40bcaa98 4198df7c 41bf595c 00000004 40000000 3f000000 -> 3f1b99b9", + "c21799da 418c1bd0 417f6670 00000004 40000000 3f000000 -> bd1b2a24", + "c2127570 41d6c1b8 c1ad1a80 00000004 40000000 3f000000 -> 3d032504", + "41bd0318 40109f50 41b2f178 00000004 40000000 3f000000 -> be738350", + "3e841880 40a8b758 41c22e30 00000004 40000000 3f000000 -> be993b38", + "bfdbbdc0 4233fe10 414bce50 00000004 40000000 3f000000 -> be35bd5c", + "422c6234 414e1a28 41c5deb8 00000004 40000000 3f000000 -> 3eaa7016", + "423b76f4 c217e8f1 c187ff32 00000004 40000000 3f000000 -> 3ec9b4b3", + "421f04d6 41de5f6c 40919080 00000004 40000000 3f000000 -> be522d95", + "3f9b7d20 42303ed8 423f91d4 00000004 40000000 3f000000 -> beed15a0", + "c1c65740 c2194ea5 423df3ae 00000004 40000000 3f000000 -> be9b386d", + "c0b846b8 c2105eea c0ff01f8 00000004 40000000 3f000000 -> be6e4c9a", + "c20e43e2 42427560 4227bc0c 00000004 40000000 3f000000 -> 3eb83eb6", + "41ab32b4 c23da44d c23c2e83 00000004 40000000 3f000000 -> bf1c83e6", + "412c4184 c1749b9c 421c8d2c 00000004 40000000 3f000000 -> be315e50", + "c1bb9168 41a0557c c1fae1b8 00000004 40000000 3f000000 -> be39850e", + "c18c7cde 420675ae 401172f0 00000004 40000000 3f000000 -> bec5ccdb", + "40efbf28 41b2ce34 410a1ed8 00000004 40000000 3f000000 -> bf1e2c8b", + "420d1730 41107978 40e2c578 00000004 40000000 3f000000 -> 3eb26d1a", + "c1114ee0 c1dd3d62 421ede34 00000004 40000000 3f000000 -> 3db35cba", + "c21a5874 4142baa4 42032204 00000004 40000000 3f000000 -> bcff40fb", + "c19c3e28 bfca5a80 c1169d98 00000004 40000000 3f000000 -> bf81fcc7", + "41c70a18 c22feef8 c10a28ac 00000004 40000000 3f000000 -> bea49aff", + "c18e1152 c181341a 423988ae 00000004 40000000 3f000000 -> 3e720a62", + "41a66f10 c1e26e4d 4124ab60 00000004 40000000 3f000000 -> 3e3bbee8", + "41ae3bc0 c15e65b4 41da7bec 00000004 40000000 3f000000 -> 3e0672ef", + "c0c46630 4153362c c228fb08 00000004 40000000 3f000000 -> bf217d87", + "c1617454 c1f9cb51 c21e36d6 00000004 40000000 3f000000 -> be923997", + "c08c2ae0 418db8f4 c12ad700 00000004 40000000 3f000000 -> bef08fdd", + "c1325020 410f7fe4 409c15b8 00000004 40000000 3f000000 -> be423c5a", + "420bc714 41bc1fb0 41d22824 00000004 40000000 3f000000 -> 3e0456ea", + "c23f2228 411fc738 c1194bd4 00000004 40000000 3f000000 -> 3b981998", + "c1eea110 c19da117 42112bee 00000004 40000000 3f000000 -> beafb6e7", + "bfb96dc0 3fd647a0 416066d8 00000004 40000000 3f000000 -> be699b27" + ] } ] } diff --git a/packages/cultmath/tools/phacelle-lipschitz/Program.cs b/packages/cultmath/tools/phacelle-lipschitz/Program.cs new file mode 100644 index 00000000..c149f1d6 --- /dev/null +++ b/packages/cultmath/tools/phacelle-lipschitz/Program.cs @@ -0,0 +1,92 @@ +// Measures the Lipschitz constant of math.phacelle's (cosine, sine) field, the number docs/design.md quotes and +// PhacelleTests.GradientNormStaysUnderTheDocumentedLipschitzEnvelope pins. +// dotnet run -c Release --project packages/cultmath/tools/phacelle-lipschitz -- +// e.g. ... -- 4000000 0,0.5,0.75,0.9 0.25,0.5,1,1.5,2,4,8,12.5,25 +// Columns: sampledMax (random p), climbedMax (hill-climbed from the 256 best samples; the number to quote), secantMax +// (value differences around the climbed argmax, the check that the analytic gradient is the field's slope), +// scalarMax (the larger of |grad cosine|, |grad sine| over the samples), p99.99 (the typical tail), and climbed*(1-n)/|side|. +using System; +using System.Linq; +using System.Threading.Tasks; +using CultMath; + +// Lipschitz bound of math.phacelle's (cosine, sine) field: sup over p of the operator norm of its 2x3 Jacobian, +// found by (1) random sampling of the exact analytic gradient, (2) hill-climbing from the top samples over +// (p, off, side direction), (3) a secant check on the VALUES (|f(p+d) - f(p)| / |d|) so the gradient itself is not trusted. +static double Op(float3 p, float3 side, float off, float n, out double comp) +{ + var r = math.phacelle(p, side, off, n); + double a = r.cosine.x*r.cosine.x + r.cosine.y*r.cosine.y + r.cosine.z*r.cosine.z; + double d = r.sine.x*r.sine.x + r.sine.y*r.sine.y + r.sine.z*r.sine.z; + double b = r.cosine.x*r.sine.x + r.cosine.y*r.sine.y + r.cosine.z*r.sine.z; + double tr = a + d, det = a*d - b*b; + comp = Math.Sqrt(Math.Max(a, d)); + var op = Math.Sqrt(tr/2 + Math.Sqrt(Math.Max(0, tr*tr/4 - det))); + return double.IsFinite(op) ? op : 0; +} +static float3 Dir(double u, double th, double len) { var s = Math.Sqrt(Math.Max(0, 1-u*u)); return new float3((float)(s*Math.Cos(th)*len), (float)(s*Math.Sin(th)*len), (float)(u*len)); } + +float[]? norms = null, sides = null; +try { norms = args.Length == 3 ? args[1].Split(',').Select(float.Parse).ToArray() : null; sides = args.Length == 3 ? args[2].Split(',').Select(float.Parse).ToArray() : null; } +catch (FormatException) { } +if (args.Length != 3 || !long.TryParse(args[0], out var N) || N <= 0 || norms is null || sides is null) +{ + Console.Error.WriteLine("usage: phacelle-lipschitz "); + Console.Error.WriteLine(" e.g. phacelle-lipschitz 4000000 0,0.5,0.75,0.9 0.25,0.5,1,1.5,2,4,8,12.5,25"); + return 2; +} +Console.WriteLine("n\t|side|\tN\tsampledMax\tclimbedMax\tsecantMax\tscalarMax(cos|sin)\tp99.99\tclimbed*(1-n)/|side|"); +foreach (var n in norms) foreach (var sl in sides) +{ + int T = Environment.ProcessorCount; + var tops = new (double v, float3 p, double u, double th, float off)[T][]; + var allv = new double[T][]; var scal = new double[T]; + Parallel.For(0, T, t => + { + var rng = new System.Random(unchecked(99991 * t + (int)(n * 1000) * 131 + (int)(sl * 100))); + var top = new (double v, float3 p, double u, double th, float off)[16]; + var list = new double[N / T]; + double sc = 0; + for (long i = 0; i < N / T; i++) + { + var p = new float3((float)(rng.NextDouble()*64-32), (float)(rng.NextDouble()*64-32), (float)(rng.NextDouble()*64-32)); + double u = rng.NextDouble()*2-1, th = rng.NextDouble()*Math.PI*2; float off = (float)rng.NextDouble(); + var v = Op(p, Dir(u, th, sl), off, n, out var c); + list[i] = v; if (c > sc) sc = c; + int m = 0; for (int k = 1; k < 16; k++) if (top[k].v < top[m].v) m = k; + if (v > top[m].v) top[m] = (v, p, u, th, off); + } + tops[t] = top; allv[t] = list; scal[t] = sc; + }); + var all = allv.SelectMany(x => x).ToArray(); Array.Sort(all); + double sampled = all[^1], p9999 = all[(int)(all.Length * 0.9999)]; + // hill-climb the best 16*T by random local search + var seeds = tops.SelectMany(x => x).OrderByDescending(x => x.v).Take(256).ToArray(); + var climbed = new (double v, float3 p, double u, double th, float off)[seeds.Length]; + Parallel.For(0, seeds.Length, s => + { + var rng = new System.Random(s + 17); + var cur = seeds[s]; double step = 0.05; + for (int it = 0; it < 10000; it++) + { + var q = new float3(cur.p.x + (float)((rng.NextDouble()-.5)*step), cur.p.y + (float)((rng.NextDouble()-.5)*step), cur.p.z + (float)((rng.NextDouble()-.5)*step)); + var u = Math.Clamp(cur.u + (rng.NextDouble()-.5)*step, -1, 1); var th = cur.th + (rng.NextDouble()-.5)*step; var off = cur.off + (float)((rng.NextDouble()-.5)*step); + var v = Op(q, Dir(u, th, sl), off, n, out _); + if (v > cur.v) cur = (v, q, u, th, off); else if (it % 1000 == 999) step *= 0.5; + } + climbed[s] = cur; + }); + var best = climbed.OrderByDescending(x => x.v).First(); + // secant check around the climbed argmax: values only, tiny displacements in random directions + double secant = 0; var sr = new System.Random(5); + var side0 = Dir(best.u, best.th, sl); + for (int i = 0; i < 20000; i++) + { + double e = Math.Pow(10, -2 - 2*sr.NextDouble()); var d = Dir(sr.NextDouble()*2-1, sr.NextDouble()*6.2832, e); + var a = math.phacelle(best.p, side0, best.off, n); var b = math.phacelle(best.p + d, side0, best.off, n); + double dc = b.cosine.w - a.cosine.w, ds = b.sine.w - a.sine.w; var dl = Math.Sqrt((double)d.x*d.x + (double)d.y*d.y + (double)d.z*d.z); + secant = Math.Max(secant, Math.Sqrt(dc*dc + ds*ds) / dl); + } + Console.WriteLine($"{n}\t{sl}\t{N}\t{sampled:F3}\t{best.v:F3}\t{secant:F3}\t{scal.Max():F3}\t{p9999:F3}\t{best.v*(1-n)/sl:F3}"); +} +return 0; diff --git a/packages/cultmath/tools/phacelle-lipschitz/phacelle-lipschitz.csproj b/packages/cultmath/tools/phacelle-lipschitz/phacelle-lipschitz.csproj new file mode 100644 index 00000000..3b8629ae --- /dev/null +++ b/packages/cultmath/tools/phacelle-lipschitz/phacelle-lipschitz.csproj @@ -0,0 +1,4 @@ + + Exenet10.0enabletruetruefalse + + diff --git a/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md b/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md index d8b27dc3..7edfa1b2 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md +++ b/packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md @@ -7,12 +7,74 @@ backfilled here; see `docs/semver-policy.md` for why. ## [Unreleased] +### Added + +- `math.fbm(float3 p, int octaves, float lacunarity, float gain)` and `math.fbm(float3 p)` (four octaves, + lacunarity 2, gain 0.5): the value-only octave sum of `snoise`, the cost of `fbm_grad` without the gradient. + The shaders carry `cultmath_fbm` in HLSL and GLSL; octaves clamp to [0, 16] as `fbm_grad`'s do. + +## [0.5.0] + ### Breaking - `CultPhasor.cos` and `.sin` are now `.cosine` and `.sine` in C#, HLSL and GLSL, constructor parameters included, because SPIRV-Cross renames members that match - GLSL builtins. This is a minor bump on 0.y.z, so the next cultmath-unity release - is 0.5.0. + GLSL builtins. +- The `const` `math.SNOISE_HESSIAN` (and `CULTMATH_SNOISE_HESSIAN`) is re-pinned + from 56.050385 to 55.642143, measured on the analytic `snoise_hessian` instead of + central differences of `snoise_grad`, whose rounding read high + (`NoiseBoundTests`). A consumer compiled against 0.4.x keeps the old value + inlined until it recompiles. `af_snoise` and `af_fbm` read it, so their + remainder terms are slightly tighter. + +### Changed + +- The package license expression is `MIT AND MPL-2.0 AND Apache-2.0` (was + `MIT AND MPL-2.0`): the Cycles files below are Apache-2.0 (`LICENSE.md`, + `THIRD-PARTY-NOTICES.md`). + +### Added + +- Cycles node semantics (Apache-2.0, `math.Cycles.cs` and the new + `Shaders/CultMath.Cycles.hlsl`): `math.cycles_*` and `cultmath_cycles_*` compute + what Blender's Cycles computes for the Math node operations whose results differ + from a plain intrinsic (`cycles_divide`, `cycles_power`, `cycles_modulo`, + `cycles_wrap`, `cycles_pingpong`, `cycles_smooth_min`, `cycles_arctan2`, ...), + Map Range (`linear`, `stepped`, `smoothstep`, `smootherstep`), Clamp, Mix + (`cycles_mix_float`, `cycles_mix_vector` and every colour blend mode as + `cycles_mix_`), RGB to BW, HSV conversion and `cycles_ramp_index`. + `CyclesOracleTests` holds the C# and the HLSL mirror to fixtures recorded from + Blender 5.2.2's CPU Cycles. +- `CultMath.Expr`: an immutable scalar SSA expression graph (`ExprGraph`, built by + `ExprBuilder` with substitution, validated by `ExprGraph.Create` on build and on + decode), parameter bindings (`ExprBindings`), an evaluator (`ExprEvaluator`) and + the op table (`ExprOps`). The package carries no serializer for it. +- Site cells: `math.power_score`, `multiplicative_score`, the `CultSiteCells` + accumulator (`site_cells_empty`, `site_cells_insert`, `site_cells_border`), the + exact power cell border (`power_border`, `power_border_step`) and + `gabriel_margin`, each with an HLSL twin (`cultmath_` prefix); the shader + `cultmath_power_border` takes a fixed-capacity site array of + `CULTMATH_MAX_SITES` (128 unless defined before the include). `power_cells`, + `multiplicative_cells` and `soft_gabriel_gate` are C# only. +- Weighted lattice cells: `math.weighted_lattice_cells` with an exact ring-exit + search and its four public steps (`lattice_site`, `lattice_weight`, + `lattice_distance`, `lattice_smooth_step`), the range constants + `LatticeMaxBoost`, `LatticeMaxStretch`, `LatticeMaxSmoothing` and + `LatticeMaxRing`, and the HLSL twins (`cultmath_weighted_lattice_cells`, + `cultmath_lattice_cell_distance`). +- Span stages: `math.span_fbm` and `span_ridged` over a `SpanPath` and a + `SpanStage` return `SpanNoise` (field, curvature and fold), widened by float + rounding allowances, with HLSL twins; `cultmath_span_rounding` and + `cultmath_span_slack` own the allowances. +- `math.snoise_hessian(float3)` (`float3x3`), the next-order bound + `SNOISE_THIRD` (535.8009), and the snoise read rounding constants + `CULTMATH_SNOISE_READ_VALUE` and `CULTMATH_SNOISE_READ_GRADIENT` in HLSL. +- `math.integral_clamp_linear(a, b, lo, hi, t0, t1)`, the integral of + `clamp(a + b t, lo, hi)` over `[t0, t1]` in closed form, with its HLSL twin. It + is finite for an infinite clamp bound, and `lo` above `hi` clamps to `hi` + everywhere, as `clamp` does. +- `math.segment_closest` and the out-free `distance_to_segment` for `float2` + and `float3`, with HLSL twins. ## [0.4.1] diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll index 9fd2defd..205d3f9b 100644 Binary files a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll and b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll differ diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb index 0ac8b9ed..9f865c09 100644 Binary files a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb and b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb differ diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl index d3eea396..fc9f7a70 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl +++ b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl @@ -342,6 +342,26 @@ float4 cultmath_fbm_grad(float3 p, int octaves, float lacunarity, float gain) return float4(gradient, value); } +// Value-only octave sum of cultmath_snoise; see the C# math.fbm comment. The one-argument form is four octaves, +// lacunarity 2, gain 0.5. +float cultmath_fbm(float3 p, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + float value = 0.0; + for (int i = 0; i < octaves; i++) + { + value += amplitude * cultmath_snoise(p * frequency); + frequency *= lacunarity; + amplitude *= gain; + } + + return value; +} + +float cultmath_fbm(float3 value) { return cultmath_fbm(value, 4, 2.0, 0.5); } + // Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad // comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. float4 cultmath_ridged_grad(float3 p, int octaves, float lacunarity, float gain) diff --git a/packages/cultmath/unity/org.gamecult.cultmath/package.json b/packages/cultmath/unity/org.gamecult.cultmath/package.json index 04f90e59..20ffe196 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/package.json +++ b/packages/cultmath/unity/org.gamecult.cultmath/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.cultmath", "displayName": "CultMath", - "version": "0.4.1", + "version": "0.5.0", "unity": "2021.3", "description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.", "license": "MIT AND MPL-2.0 AND Apache-2.0", diff --git a/packages/cultmesh-browser/CHANGELOG.md b/packages/cultmesh-browser/CHANGELOG.md index 0093ccf0..ccda16a0 100644 --- a/packages/cultmesh-browser/CHANGELOG.md +++ b/packages/cultmesh-browser/CHANGELOG.md @@ -1,8 +1,14 @@ # Changelog -All notable changes to `cultmesh-browser` are recorded here, following +All notable changes to `@gamecult/cultmesh-browser` are recorded here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## Unreleased + +### Breaking + +- The package is renamed `cultmesh-browser` to `@gamecult/cultmesh-browser` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-browser` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`; the interop test uses `@gamecult/cultmesh-ts`. + ## [0.4.0] ### Added diff --git a/packages/cultmesh-browser/README.md b/packages/cultmesh-browser/README.md index ab29d65f..500b858b 100644 --- a/packages/cultmesh-browser/README.md +++ b/packages/cultmesh-browser/README.md @@ -1,6 +1,6 @@ # CultMesh Browser -`cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one +`@gamecult/cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one WebSocket session for a stable Verse/authority-runtime target, retains explicit raw document leases across route replacement, and correlates typed CultNet operations with provider receipts. @@ -95,7 +95,7 @@ WebCrypto `crypto.subtle`; without WebCrypto it fails with endpoint CultMesh resolved is the only host the reader trusts. ```ts -import { fetchVerifiedContent } from "cultmesh-browser"; +import { fetchVerifiedContent } from "@gamecult/cultmesh-browser"; const bytes = await fetchVerifiedContent(endpoint, manifest, { signal: AbortSignal.timeout(30_000), diff --git a/packages/cultmesh-browser/package.json b/packages/cultmesh-browser/package.json index 6d52c9bf..0984ae6d 100644 --- a/packages/cultmesh-browser/package.json +++ b/packages/cultmesh-browser/package.json @@ -1,5 +1,5 @@ { - "name": "cultmesh-browser", + "name": "@gamecult/cultmesh-browser", "version": "0.4.0", "description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.", "type": "module", @@ -41,12 +41,12 @@ "node": ">=20" }, "dependencies": { - "@gamecult/cultcache-ts": "^0.14.0", - "cultnet-ts": "^0.3.0" + "@gamecult/cultcache-ts": "^0.15.0", + "@gamecult/cultnet-ts": "^0.3.0" }, "devDependencies": { "@types/node": "^24.0.0", - "cultmesh-ts": "^0.2.0", + "@gamecult/cultmesh-ts": "^0.2.0", "typescript": "^5.9.0" } } diff --git a/packages/cultmesh-browser/src/content.ts b/packages/cultmesh-browser/src/content.ts index d9e09b4f..c41fc904 100644 --- a/packages/cultmesh-browser/src/content.ts +++ b/packages/cultmesh-browser/src/content.ts @@ -3,8 +3,8 @@ // The response's declared length and coding are never consulted (Fetch decodes codings; the bytes received are the evidence). // Hashing, hex rules, record keys, the loopback rule and manifest validation belong to cultnet-ts and are only called here. -import { EMPTY_SHA256, chunkRecordKey, parseSha256Hex, sha256Hex, validateManifest, type CultMeshCdnArtifactManifest } from "cultnet-ts/content"; -import { isLoopbackEndpoint } from "cultnet-ts/authority"; +import { EMPTY_SHA256, chunkRecordKey, parseSha256Hex, sha256Hex, validateManifest, type CultMeshCdnArtifactManifest } from "@gamecult/cultnet-ts/content"; +import { isLoopbackEndpoint } from "@gamecult/cultnet-ts/authority"; export type CultMeshContentErrorCode = | "manifest-invalid" | "too-large" | "insecure-endpoint" | "webcrypto-unavailable" | "network" diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts index beae9e36..3852615e 100644 --- a/packages/cultmesh-browser/src/index.ts +++ b/packages/cultmesh-browser/src/index.ts @@ -1,6 +1,6 @@ import { decodeDocumentPayload } from "@gamecult/cultcache-ts/document"; import type { AnyCultCacheDocumentDefinition, CultCacheDocumentValue } from "@gamecult/cultcache-ts"; -import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; +import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; import { encodeCultNetMessageForWire, parseCultNetMessage, @@ -16,7 +16,7 @@ import { type CultMeshVerseCatalogResponseMessage, type CultMeshSessionAcceptedMessage, type CultMeshSessionOpenMessage, -} from "cultnet-ts/contracts"; +} from "@gamecult/cultnet-ts/contracts"; import { base64ToBytes, bytesToBase64, @@ -31,7 +31,7 @@ import { type CultMeshAuthorityTrustMode, type CultMeshAuthorityTrustPolicy, type CultMeshP256PublicKey, -} from "cultnet-ts/authority"; +} from "@gamecult/cultnet-ts/authority"; export * from "./content.js"; export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity; diff --git a/packages/cultmesh-browser/test/content.test.ts b/packages/cultmesh-browser/test/content.test.ts index cc0bb322..d4ec4d6e 100644 --- a/packages/cultmesh-browser/test/content.test.ts +++ b/packages/cultmesh-browser/test/content.test.ts @@ -11,7 +11,7 @@ import { gzipSync } from "node:zlib"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; -import { EMPTY_SHA256, packContent, sha256Hex, type CultMeshCdnArtifactManifest } from "cultnet-ts/content"; +import { EMPTY_SHA256, packContent, sha256Hex, type CultMeshCdnArtifactManifest } from "@gamecult/cultnet-ts/content"; import { CultMeshContentError, fetchVerifiedChunk, fetchVerifiedContent, type CultMeshContentErrorCode } from "../src/content.js"; diff --git a/packages/cultmesh-browser/test/cultmesh-browser.test.ts b/packages/cultmesh-browser/test/cultmesh-browser.test.ts index 1206b32d..17ffbc02 100644 --- a/packages/cultmesh-browser/test/cultmesh-browser.test.ts +++ b/packages/cultmesh-browser/test/cultmesh-browser.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { codecs, defineDocumentType, documentPayloadFromBytes } from "@gamecult/cultcache-ts"; -import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; +import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; import { encodeCultNetMessageForWire, parseCultNetMessage, @@ -12,7 +12,7 @@ import { type CultNetRawDocumentRecord, type CultMeshVerseCatalogResponseMessage, type CultMeshSessionOpenMessage, -} from "cultnet-ts/contracts"; +} from "@gamecult/cultnet-ts/contracts"; import { CultMeshBrowserClient, diff --git a/packages/cultmesh-browser/test/interop/content-https-provider.ts b/packages/cultmesh-browser/test/interop/content-https-provider.ts index 50b5f223..f4b15aae 100644 --- a/packages/cultmesh-browser/test/interop/content-https-provider.ts +++ b/packages/cultmesh-browser/test/interop/content-https-provider.ts @@ -5,8 +5,8 @@ import { readFileSync } from "node:fs"; import { createServer } from "node:https"; import type { AddressInfo } from "node:net"; import { createInterface } from "node:readline"; -import { chunkRecordKey } from "cultnet-ts/content"; -import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener, type ContentChunkAnswer, type ContentChunkRequest } from "cultmesh-ts/content"; +import { chunkRecordKey } from "@gamecult/cultnet-ts/content"; +import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener, type ContentChunkAnswer, type ContentChunkRequest } from "@gamecult/cultmesh-ts/content"; const [storeDir, corruptDir, keyPem, certPem, escapeKey] = process.argv.slice(2) as [string, string, string, string, string]; const say = (line: object): void => { process.stdout.write(`${JSON.stringify(line)}\n`); }; diff --git a/packages/cultmesh-browser/test/interop/content-https-reader.ts b/packages/cultmesh-browser/test/interop/content-https-reader.ts index 693b22a8..fab02066 100644 --- a/packages/cultmesh-browser/test/interop/content-https-reader.ts +++ b/packages/cultmesh-browser/test/interop/content-https-reader.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { decodeManifest } from "cultnet-ts/content"; +import { decodeManifest } from "@gamecult/cultnet-ts/content"; import { fetchVerifiedChunk, fetchVerifiedContent, CultMeshContentError } from "../../src/content.js"; const [endpointsJson, manifestsDir, only] = process.argv.slice(2) as [string, string, string | undefined]; diff --git a/packages/cultmesh-browser/test/interop/content-https.test.ts b/packages/cultmesh-browser/test/interop/content-https.test.ts index c86cf6ae..b26621eb 100644 --- a/packages/cultmesh-browser/test/interop/content-https.test.ts +++ b/packages/cultmesh-browser/test/interop/content-https.test.ts @@ -12,7 +12,7 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { EMPTY_SHA256, encodeManifest, packContent } from "cultnet-ts/content"; +import { EMPTY_SHA256, encodeManifest, packContent } from "@gamecult/cultnet-ts/content"; const execFileAsync = promisify(execFile); const here = dirname(fileURLToPath(import.meta.url)); // dist-test/test/interop diff --git a/packages/cultmesh-browser/test/payload-type.test.ts b/packages/cultmesh-browser/test/payload-type.test.ts index 9cc95632..bdc6f250 100644 --- a/packages/cultmesh-browser/test/payload-type.test.ts +++ b/packages/cultmesh-browser/test/payload-type.test.ts @@ -2,8 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts"; -import { decodeWireValue } from "cultnet-ts/wire"; -import type { CultNetRawDocumentRecord } from "cultnet-ts/contracts"; +import { decodeWireValue } from "@gamecult/cultnet-ts/wire"; +import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts/contracts"; import { decodeCultNetPayload } from "../src/index.js"; diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt index 368b5338..10cc2a58 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt @@ -56,6 +56,17 @@ data class CultCacheEnvelope( val payload: ByteArray, ) +/** + * A schema this cache cannot give to one more type: another type already holds the schema id, or the type already holds + * another schema id. Nothing is registered. It names the refused codec's schema id and both document types. + */ +class CultSchemaConflictException( + val schemaId: String, + val schemaNames: List, + val recordKey: String, + message: String, +) : IOException(message) + class CultCache { companion object { const val GLOBAL_KEY = "__global__" @@ -65,7 +76,19 @@ class CultCache { private val codecsBySchema = linkedMapOf>() private val values = linkedMapOf>() + // One type owns a schema id. Every path that names a codec (register, put, get, getAll, delete) comes through here. fun register(codec: CultDocumentCodec) { + val holder = codecs[codec.documentType]?.takeIf { it.schemaVersion != codec.schemaVersion } + ?: codecsBySchema[codec.schemaVersion]?.takeIf { it.documentType != codec.documentType } + if (holder != null) { + throw CultSchemaConflictException( + codec.schemaVersion, + listOf(holder.documentType, codec.documentType), + "", + "Kotlin CultCache type ${holder.documentType} holds schema ${holder.schemaVersion}; " + + "type ${codec.documentType} cannot also be registered for schema id ${codec.schemaVersion}", + ) + } codecs[codec.documentType] = codec codecsBySchema[codec.schemaVersion] = codec } @@ -3555,6 +3578,42 @@ private fun cultCacheRawSnapshotsRoundTripThroughCultNetMessages() { check(syncedAlias == KotlinUiNote("kotlin.alias_note.v1", "canonical-to-ui")) check(target.require(uiNote, "note:alias") == syncedAlias) + // One type per schema in one cache: a second type for a held schema id, or a held type for another schema id, is + // refused typed, by register and by the implicit registration in put, get, getAll and delete alike. + fun refusal(action: () -> Unit): CultSchemaConflictException? = + try { action(); null } catch (conflict: CultSchemaConflictException) { conflict } + val canonicalOtherSchema = cultDocument(KotlinAliasNoteCodec( + documentType = "kotlin.alias_note", + schemaVersion = "kotlin.alias_note.v2", + create = ::KotlinCanonicalNote, + readSchemaVersion = KotlinCanonicalNote::schemaVersion, + readBody = KotlinCanonicalNote::body, + )) + for ((first, second) in listOf(canonicalNote to uiNote, uiNote to canonicalNote, canonicalNote to canonicalOtherSchema)) { + @Suppress("UNCHECKED_CAST") + val claimant = second as CultDocumentDefinition + val claimantValue = claimant.codec.decode(canonicalNote.codec.encode(KotlinCanonicalNote("kotlin.alias_note.v1", "x"))) + val attempts: List<(CultCache) -> Unit> = listOf( + { cache -> cache.register(claimant) }, + { cache -> cache.put(claimant, "note:second", claimantValue) }, + { cache -> cache.get(claimant, "note:second") }, + { cache -> cache.getAll(claimant) }, + { cache -> cache.delete(claimant.codec, "note:second") }, + ) + for (attempt in attempts) { + val cache = CultCache() + @Suppress("UNCHECKED_CAST") + cache.register(first as CultDocumentDefinition) + val conflict = refusal { attempt(cache) } + check(conflict != null) { "${second.documentType}/${second.schemaVersion} beside ${first.documentType}/${first.schemaVersion} was not refused" } + check(conflict.schemaId == second.schemaVersion && conflict.recordKey == "") + check(conflict.schemaNames == listOf(first.documentType, second.documentType)) + check(conflict.message!!.contains(second.schemaVersion)) + check(cache.codecForSchema(first.schemaVersion).documentType == first.documentType) + } + } + CultCache().apply { register(canonicalNote); register(canonicalNote); put(canonicalNote, "note:again", KotlinCanonicalNote("kotlin.alias_note.v1", "again")) } + val reactiveUiNote = cultDocument(KotlinReactiveNoteCodec( documentType = "kotlin.reactive_note.ui", schemaVersion = "kotlin.reactive_note.v1", @@ -3580,8 +3639,8 @@ private fun cultCacheRawSnapshotsRoundTripThroughCultNetMessages() { } val reactiveCanonicalNote = cultDocument(KotlinReactiveNoteCodec( - documentType = "kotlin.reactive_note", - schemaVersion = "kotlin.reactive_note.v1", + documentType = "kotlin.reconcile_note", + schemaVersion = "kotlin.reconcile_note.v1", )) target.cache.register(reactiveCanonicalNote) target.remember(reactiveCanonicalNote, "note:reconcile", KotlinReactiveNote("initial", 1)) diff --git a/packages/cultmesh-py/README.md b/packages/cultmesh-py/README.md index 63799e52..60ae4e49 100644 --- a/packages/cultmesh-py/README.md +++ b/packages/cultmesh-py/README.md @@ -7,7 +7,7 @@ game sessions with prediction reconciliation, committed simulation facts, the local TCP/RUDP server, and a package daemon. It depends on `cultcache-py` and `cultnet-py`, mirroring `cultmesh-rs` and -`cultmesh-ts`. The Verse catalog, peer exchange, authority lease, stream +`@gamecult/cultmesh-ts`. The Verse catalog, peer exchange, authority lease, stream negotiation, and simulation fact contracts are defined in `cultnet_py.cultmesh_contracts` and re-exported here. @@ -122,6 +122,33 @@ server = CultMesh.serve_node( ) ``` +## Peer writes + +**Breaking (0.x):** a served node refuses every peer `document_put_raw` and `document_delete` unless a write +authority grants it, mirroring C# `ICultNetRemoteWriteAuthority`. Before this, any connected peer could overwrite +or delete the node's documents. The refusal is a typed `cultnet.error.v0` with code `write_not_authorized` and the +record key in `details`; an authority that raises, or returns no decision, refuses too. + +Known residual: ungranted peers can still distinguish registered from unregistered schemas by timing (the node resolves the +schema before the gate so the authority sees the canonical id); the refusal itself is identical. + +```python +from cultmesh_py import CultMeshGrantAllWrites, CultMeshRemoteWriteDecision, CultMeshRemoteWriteRequest + +class OwnKeysOnly: + def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision: + if request.record_key.startswith("input:python:"): + return CultMeshRemoteWriteDecision.grant() + return CultMeshRemoteWriteDecision.refuse("not your key") + +server = CultMesh.serve_node(node, write_authority=OwnKeysOnly()) +``` + +The request carries the peer (`endpoint`, `transport`), the operation, the canonical schema id the node +resolved (never the alias the peer sent), the record key and the shard id and epoch as sent. `session.serve(...)` +takes the same `write_authority`. A host that already authenticates its peers, or a test rig, opts in to +`CultMeshGrantAllWrites()`; the daemon's equivalent is `--allow-peer-writes`. Without it the daemon is read-only to peers. + The same local server can be launched as a package daemon when another runtime or operator process needs a CultNet endpoint without embedding Python glue: @@ -228,6 +255,17 @@ prediction = session.predict(note_doc, "input:python:move", {"body": "predicted unsubscribe() ``` +## Authority route verification + +`cultmesh_py.authority` ports the C# reference +(`src/GameCult.Mesh/CultMeshAuthorityProof.cs`): `AuthorityTrustPolicy.validate` +judges an Odin-signed ECDSA P-256 route (channel protection, trusted root, +validity window, IEEE P1363 signature over the canonical bytes) and +`verify_session_proof` checks the provider nonce proof. Refusals raise +`AuthorityRouteRefusal` with the C# message text. It needs `cryptography`. +`tests/test_authority_route_vectors.py` judges +`contracts/cultmesh/authority-route-vectors*.json`. + ## Verify `cultmesh_py.verify` checks the public exports and `py.typed` markers of all diff --git a/packages/cultmesh-py/pyproject.toml b/packages/cultmesh-py/pyproject.toml index a1df5ee3..5f9bda4d 100644 --- a/packages/cultmesh-py/pyproject.toml +++ b/packages/cultmesh-py/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] -dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0"] +dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0", "cryptography>=41"] [project.scripts] cultmesh-py-daemon = "cultmesh_py.daemon:main" cultmesh-py-verify = "cultmesh_py.verify:main" diff --git a/packages/cultmesh-py/src/cultmesh_py/__init__.py b/packages/cultmesh-py/src/cultmesh_py/__init__.py index f2dc6b28..529a3859 100644 --- a/packages/cultmesh-py/src/cultmesh_py/__init__.py +++ b/packages/cultmesh-py/src/cultmesh_py/__init__.py @@ -39,6 +39,14 @@ from .simulation import ( CultMeshSimulationFactCommitter, simulation_fact_document, ) +from .write_authority import ( + CultMeshGrantAllWrites, + CultMeshRemoteWriteAuthority, + CultMeshRemoteWriteDecision, + CultMeshRemoteWriteOperation, + CultMeshRemoteWriteRequest, + CultMeshServerPeer, +) from cultnet_py.cultmesh_contracts import ( PEER_EXCHANGE_REQUEST, PEER_EXCHANGE_RESPONSE, @@ -89,6 +97,12 @@ __all__ = [ "CultMeshSimulationFact", "CultMeshSimulationFactCommit", "CultMeshSimulationFactCommitter", + "CultMeshGrantAllWrites", + "CultMeshRemoteWriteAuthority", + "CultMeshRemoteWriteDecision", + "CultMeshRemoteWriteOperation", + "CultMeshRemoteWriteRequest", + "CultMeshServerPeer", "CultMeshAuthorityLease", "CultMeshAuthorityLeaseCatalog", "CultMeshHmacAuthorityLeaseVerifier", diff --git a/packages/cultmesh-py/src/cultmesh_py/authority.py b/packages/cultmesh-py/src/cultmesh_py/authority.py new file mode 100644 index 00000000..3c6665ea --- /dev/null +++ b/packages/cultmesh-py/src/cultmesh_py/authority.py @@ -0,0 +1,338 @@ +"""CultMesh authority route verification. + +Port of `src/GameCult.Mesh/CultMeshAuthorityProof.cs` (the reference) and of the +route constructors in `CultMeshVerse.cs`. Rule for rule, in the same order, the +same input yields the same refusal text as `CultMeshAuthorityTrustPolicy.Verify`: +unsigned, channel protection, root lookup, validity window, signature. + +Shared vectors: `contracts/cultmesh/authority-route-vectors.json` (C# written). +""" + +from __future__ import annotations + +import base64 +import binascii +import re +import struct +from dataclasses import dataclass +from typing import Iterable, Mapping, Sequence + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature + +ROUTE_DOMAIN = "gamecult.cultmesh.route-certificate.v1" +SESSION_DOMAIN = "gamecult.cultmesh.session-proof.v1" + +AUTHENTICATED_REMOTE = "authenticated-remote" +LOCAL_DEVELOPMENT = "local-development" + +# .NET `char.IsWhiteSpace`: Unicode Zs, Zl, Zp, U+0009-U+000D and U+0085. +# `str.strip()` is a different set (it also strips U+001C-U+001F). +_CSHARP_WHITESPACE = ( + "\u0009\u000a\u000b\u000c\u000d \u0085  " + " â€�         " + "

 â�Ÿã€€" +) + + +def trim_csharp(value: str) -> str: + return value.strip(_CSHARP_WHITESPACE) + + +def is_blank_csharp(value: str | None) -> bool: + return value is None or trim_csharp(value) == "" + + +def _require(value: str | None, name: str) -> str: + if is_blank_csharp(value): + raise ValueError(f"Value must be non-empty. (Parameter '{name}')") + return trim_csharp(value) # type: ignore[arg-type] + + +class AuthorityRouteRefusal(Exception): + """A route or proof the consumer's trust policy refuses. + + Mirrors `CultMeshSessionException` carrying a `CultMeshSessionFailure` with + reason `Authentication`: `reason` is always "authentication". + """ + + reason = "authentication" + + def __init__(self, message: str, endpoint: str = "") -> None: + super().__init__(message) + self.message = message + self.endpoint = endpoint + + +def _b64_strict(value: str) -> bytes: + # `Convert.FromBase64String`: standard alphabet, padded, whitespace ignored. + stripped = re.sub(r"[ \t\r\n]", "", value) + return base64.b64decode(stripped, validate=True) + + +@dataclass(frozen=True) +class P256PublicKey: + key_id: str + x: str + y: str + + def __post_init__(self) -> None: + object.__setattr__(self, "key_id", _require(self.key_id, "keyId")) + for name in ("x", "y"): + value = _require(getattr(self, name), name) + try: + _b64_strict(value) + except (binascii.Error, ValueError) as error: + raise ValueError(f"Coordinate must be base64. (Parameter '{name}')") from error + object.__setattr__(self, name, value) + + def verify(self, payload: bytes, signature_base64: str) -> bool: + """IEEE P1363 (r || s) ECDSA-SHA256 verification; False on any malformation.""" + try: + x, y = _b64_strict(self.x), _b64_strict(self.y) + signature = _b64_strict(signature_base64) + if len(x) != 32 or len(y) != 32 or len(signature) != 64: + return False + key = ec.EllipticCurvePublicNumbers( + int.from_bytes(x, "big"), int.from_bytes(y, "big"), ec.SECP256R1() + ).public_key() + der = encode_dss_signature( + int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big") + ) + key.verify(der, payload, ec.ECDSA(hashes.SHA256())) + return True + except (InvalidSignature, ValueError, binascii.Error): + return False + + +@dataclass(frozen=True) +class RouteCertificate: + provider_key: P256PublicKey + odin_key_id: str + issued_at_unix_milliseconds: int + expires_at_unix_milliseconds: int + signature: str = "" + + def __post_init__(self) -> None: + if is_blank_csharp(self.odin_key_id): + raise ValueError("Odin key identity is required. (Parameter 'odinKeyId')") + object.__setattr__(self, "odin_key_id", trim_csharp(self.odin_key_id)) + if self.issued_at_unix_milliseconds < 0: + raise ValueError("issuedAtUnixMilliseconds must not be negative.") + if self.expires_at_unix_milliseconds <= self.issued_at_unix_milliseconds: + raise ValueError("expiresAtUnixMilliseconds must be after issuedAtUnixMilliseconds.") + object.__setattr__(self, "signature", trim_csharp(self.signature or "")) + + +@dataclass(frozen=True) +class AuthorityRoute: + """`CultMeshAuthorityRoute`: construction cleans every field as the C# does.""" + + authority_runtime_id: str + endpoint: str + protocol_ids: Sequence[str] = () + priority: int = 0 + generation: str | None = None + certificate: RouteCertificate | None = None + + def __post_init__(self) -> None: + runtime_id = _require(self.authority_runtime_id, "authorityRuntimeId") + endpoint = _require(self.endpoint, "endpoint") + if self.priority < 0: + raise ValueError("priority must not be negative.") + object.__setattr__(self, "authority_runtime_id", runtime_id) + object.__setattr__(self, "endpoint", endpoint) + object.__setattr__( + self, + "protocol_ids", + tuple( + sorted( + {trim_csharp(v) for v in self.protocol_ids if not is_blank_csharp(v)}, + key=lambda v: v.encode("utf-16-be"), # StringComparer.Ordinal is UTF-16 code-unit order + ) + ), + ) + object.__setattr__( + self, + "generation", + f"{runtime_id}@{endpoint}" if is_blank_csharp(self.generation) else trim_csharp(self.generation), # type: ignore[arg-type] + ) + + +@dataclass(frozen=True) +class SessionOpen: + """The fields of `CultMeshSessionOpenMessage` the session proof covers.""" + + client_nonce: str | None = None + message_id: str | None = None + source_runtime_id: str | None = None + verse_id: str | None = None + authority_runtime_id: str | None = None + protocol_id: str | None = None + route_generation: str | None = None + + +@dataclass(frozen=True) +class SessionAccepted: + client_nonce: str | None + provider_key_id: str | None + provider_signature: str | None + + +@dataclass(frozen=True) +class VerifiedRoute: + route: AuthorityRoute + provider_key: P256PublicKey | None + is_local_development: bool + + +def canonical_fields(*values: str | None) -> bytes: + out = bytearray() + for value in values: + encoded = (value or "").encode("utf-8") + out += struct.pack(">I", len(encoded)) + encoded + return bytes(out) + + +def canonical_route(verse_id: str, route: AuthorityRoute) -> bytes: + certificate = route.certificate + if certificate is None: + raise ValueError("Route certificate is required.") + return canonical_fields( + ROUTE_DOMAIN, + verse_id, + route.authority_runtime_id, + route.endpoint, + "\u001f".join(route.protocol_ids), # already ordinally sorted; Python sorts by code point + str(route.priority), + route.generation, + certificate.provider_key.key_id, + certificate.provider_key.x, + certificate.provider_key.y, + certificate.odin_key_id, + str(certificate.issued_at_unix_milliseconds), + str(certificate.expires_at_unix_milliseconds), + ) + + +def canonical_session(request: SessionOpen, endpoint: str) -> bytes: + return canonical_fields( + SESSION_DOMAIN, + request.client_nonce, + request.message_id, + request.source_runtime_id, + request.verse_id, + request.authority_runtime_id, + request.protocol_id, + endpoint, + request.route_generation, + ) + + +# `System.Uri` refuses spellings WHATWG parsers repair; a lenient parse could call +# a refused endpoint loopback or protected. Same shape rule as the TypeScript port. +_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+\-.]*://([^/?#]+)") + + +def _split_endpoint(value: str) -> tuple[str, str] | None: + """Return (scheme, lowercase host) or None where System.Uri would refuse.""" + raw = re.sub(r"^[\x00-\x20]+|[\x00-\x20]+$", "", value) + if "\\" in raw: + return None + shape = _AUTHORITY.match(raw) + if not shape: + return None + authority = shape.group(1) + after_user = authority[authority.rfind("@") + 1 :] + if after_user.startswith("["): + host = after_user[: after_user.find("]") + 1] + else: + host = after_user.split(":")[0] + if not host or re.search(r"[^!-~]", host) or host.endswith("."): + return None + return raw.split("://", 1)[0].lower(), host.strip("[]").lower() + + +def is_loopback_endpoint(endpoint: str) -> bool: + parts = _split_endpoint(endpoint) + if parts is None: + return False + host = parts[1] + return host in ("localhost", "loopback", "::1", "::ffff:127.0.0.1") or bool( + re.fullmatch(r"127\.\d{1,3}\.\d{1,3}\.\d{1,3}", host) + ) + + +def is_protected_endpoint(endpoint: str) -> bool: + parts = _split_endpoint(endpoint) + return parts is not None and (parts[0] in ("wss", "https") or "quic" in parts[0]) + + +class AuthorityTrustPolicy: + """Consumer-owned policy. Fails closed unless a route is valid under it.""" + + def __init__(self, mode: str, odin_roots: Iterable[P256PublicKey] = ()) -> None: + if mode not in (AUTHENTICATED_REMOTE, LOCAL_DEVELOPMENT): + raise ValueError(f"Unknown trust mode '{mode}'.") + self.mode = mode + self._roots: dict[str, P256PublicKey] = {} + for root in odin_roots: + if root.key_id in self._roots: + raise ValueError(f"Odin root key id '{root.key_id}' is listed more than once in the trust policy.") + self._roots[root.key_id] = root + + def validate(self, verse_id: str, route: AuthorityRoute, now_unix_milliseconds: int) -> None: + self.verify(verse_id, route, now_unix_milliseconds) + + def verify(self, verse_id: str, route: AuthorityRoute, now_unix_milliseconds: int) -> VerifiedRoute: + local = self.mode == LOCAL_DEVELOPMENT and is_loopback_endpoint(route.endpoint) + + def refuse(message: str) -> AuthorityRouteRefusal: + return AuthorityRouteRefusal(message, route.endpoint) + + certificate = route.certificate + if certificate is None or is_blank_csharp(certificate.signature): + if local: + return VerifiedRoute(route, None, True) + raise refuse("Remote CultMesh routes require an Odin-signed authority certificate.") + if not is_protected_endpoint(route.endpoint) and not local: + raise refuse("Authenticated remote CultMesh routes require TLS or QUIC channel protection.") + root = self._roots.get(certificate.odin_key_id) + if root is None: + raise refuse(f"Odin key '{certificate.odin_key_id}' is not trusted by this consumer.") + if not (certificate.issued_at_unix_milliseconds <= now_unix_milliseconds < certificate.expires_at_unix_milliseconds): + raise refuse("The Odin route certificate is not currently valid.") + try: + signature = _b64_strict(certificate.signature) + except (binascii.Error, ValueError): + raise refuse("The Odin route signature is not base64.") from None + if len(signature) != 64: + raise refuse("The Odin route signature is not IEEE P1363 P-256.") + if not root.verify(canonical_route(verse_id, route), certificate.signature): + raise refuse("The Odin route certificate signature is invalid.") + return VerifiedRoute(route, certificate.provider_key, False) + + +def verify_session_proof( + request: SessionOpen, + response: SessionAccepted, + verse_id: str, + route: AuthorityRoute, + trust: AuthorityTrustPolicy, + now_unix_milliseconds: int, +) -> bool: + """Verifies the Odin route certificate (raising) and the provider nonce proof.""" + verified = trust.verify(verse_id, route, now_unix_milliseconds) + if verified.is_local_development: + return True + key = verified.provider_key + if ( + key is None + or response.provider_key_id != key.key_id + or response.client_nonce != request.client_nonce + or is_blank_csharp(response.provider_signature) + ): + return False + return key.verify(canonical_session(request, route.endpoint), response.provider_signature) # type: ignore[arg-type] diff --git a/packages/cultmesh-py/src/cultmesh_py/daemon.py b/packages/cultmesh-py/src/cultmesh_py/daemon.py index 5b127008..cdc0a0ea 100644 --- a/packages/cultmesh-py/src/cultmesh_py/daemon.py +++ b/packages/cultmesh-py/src/cultmesh_py/daemon.py @@ -13,6 +13,7 @@ from cultnet_py import CultNetSimulationConsensusOptions, CultNetSimulationObser from .facade import CultMesh from .server import CultMeshLocalServer +from .write_authority import CultMeshGrantAllWrites from cultnet_py.cultmesh_contracts import ( CultMeshPeerCard, CultMeshPeerCatalog, @@ -53,6 +54,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--simulation-quorum-ratio", type=float, default=0.5) parser.add_argument("--verse-id") parser.add_argument("--verse-display-name") + parser.add_argument("--allow-peer-writes", action="store_true", help="grant every connected peer put and delete; without it peer writes are refused") parser.add_argument("--role", action="append", dest="roles") args = parser.parse_args(argv) @@ -117,6 +119,7 @@ def start_server(args: argparse.Namespace, parser: argparse.ArgumentParser) -> C display_name=args.display_name, max_snapshot_documents=args.max_snapshot_documents, max_snapshot_bytes=args.max_snapshot_bytes, + write_authority=CultMeshGrantAllWrites() if args.allow_peer_writes else None, ) except ValueError as error: # The server owns its option rules; a limit it refuses is a usage error, not a crash. diff --git a/packages/cultmesh-py/src/cultmesh_py/facade.py b/packages/cultmesh-py/src/cultmesh_py/facade.py index 84bea2c8..932d704a 100644 --- a/packages/cultmesh-py/src/cultmesh_py/facade.py +++ b/packages/cultmesh-py/src/cultmesh_py/facade.py @@ -35,6 +35,7 @@ from .node import ( from .server import CULTMESH_RUDP_DEFAULT_MAX_FRAGMENT_BYTES, CultMeshLocalServer from .session import CultMeshGameSession, CultMeshGameSessionOptions from .simulation import CultMeshSimulationFactCommitter +from .write_authority import CultMeshRemoteWriteAuthority from cultnet_py.cultmesh_contracts import ( AuthorityLeaseVerifier, CultMeshAuthorityLeaseCatalog, @@ -400,6 +401,7 @@ class CultMesh: verse_catalog: CultMeshVerseCatalog | None = None, peer_catalog: CultMeshPeerCatalog | None = None, observation_hub: CultNetSimulationObservationHub | None = None, + write_authority: CultMeshRemoteWriteAuthority | None = None, host: str = "127.0.0.1", port: int = 0, display_name: str | None = None, @@ -416,6 +418,7 @@ class CultMesh: verse_catalog=verse_catalog or CultMeshVerseCatalog(), peer_catalog=peer_catalog or CultMeshPeerCatalog(), observation_hub=observation_hub, + write_authority=write_authority, host=host, port=port, display_name=display_name, diff --git a/packages/cultmesh-py/src/cultmesh_py/node.py b/packages/cultmesh-py/src/cultmesh_py/node.py index bf092060..cee102f0 100644 --- a/packages/cultmesh-py/src/cultmesh_py/node.py +++ b/packages/cultmesh-py/src/cultmesh_py/node.py @@ -26,7 +26,7 @@ from cultnet_py import ( apply_shard_log_response as apply_cultnet_shard_log_response, document_delete, document_put_raw, - resolve_document_and_schema_id_for_raw_record, + resolve_document_for_raw_record, schema_document_map, ) @@ -650,6 +650,29 @@ class CultMeshDatabase: self._append_shard_log_delete(message) return message + def raw_put_target(self, message: dict[str, Any]) -> tuple[str, str] | None: + """The canonical schema id and record key a raw put would write, or None when it would not apply.""" + document_record = message.get("document") + # Only a document of the wire shape is resolved, so an ungranted peer's payload is never + # converted or allocated here: a malformed one is the store's refusal, after the authority. + if ( + not isinstance(document_record, dict) + or not isinstance(document_record.get("recordKey"), str) + or not isinstance(document_record.get("payload"), (bytes, bytearray, memoryview)) + ): + return None + try: + prepared = self._raw_put_envelope(message) + except Exception: # noqa: BLE001 - any other malformed shape + return None + return None if prepared is None else (prepared[0].catalog_entry().schema_id, prepared[1].key) + + def raw_delete_target(self, message: dict[str, Any]) -> tuple[str, str] | None: + """The canonical schema id and record key a raw delete names, or None when its schema is not registered.""" + document = self._document_for_schema(str(message.get("schemaId") or "")) + record_key = str(message.get("recordKey") or "") + return None if document is None or not record_key else (document.catalog_entry().schema_id, record_key) + def apply_raw_put_message(self, message: dict[str, Any]) -> CultMeshDatabaseChange | None: prepared = self._raw_put_envelope(message) if prepared is None: @@ -689,17 +712,14 @@ class CultMeshDatabase: schema_id = str(document_record.get("schemaId") or "") documents_by_schema_id = schema_document_map(self.documents) try: - document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( - documents_by_schema_id, - schema_id, - document_record, - ) + document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, document_record) except KeyError: return None + # One id: the local schema id, under which the cache stores the record. return document, CultCacheEnvelope( key=str(document_record["recordKey"]), type=document.type, - schema_id=resolved_schema_id, + schema_id=document.catalog_entry().schema_id, payload=bytes(document_record["payload"]), stored_at=str(document_record.get("storedAt") or ""), catalog_entry=document.catalog_entry(), @@ -1168,12 +1188,8 @@ class CultMeshDatabase: documents_by_schema_id = schema_document_map(self.documents) if raw_record is not None: try: - _, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( - documents_by_schema_id, - schema_id, - raw_record, - ) - return resolved_schema_id, record_key + document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, raw_record) + return document.catalog_entry().schema_id, record_key except KeyError: pass document = documents_by_schema_id.get(schema_id) @@ -1333,15 +1349,11 @@ class CultMeshDatabase: continue schema_id = str(record.get("schemaId")) try: - document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( - documents_by_schema_id, - schema_id, - record, - ) + document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, record) except KeyError: continue record_key = str(record.get("recordKey")) - previous[(resolved_schema_id, record_key)] = self.cache.get(document, record_key) + previous[(document.catalog_entry().schema_id, record_key)] = self.cache.get(document, record_key) return previous @staticmethod diff --git a/packages/cultmesh-py/src/cultmesh_py/server.py b/packages/cultmesh-py/src/cultmesh_py/server.py index e50def4d..6152be93 100644 --- a/packages/cultmesh-py/src/cultmesh_py/server.py +++ b/packages/cultmesh-py/src/cultmesh_py/server.py @@ -1,5 +1,6 @@ from __future__ import annotations +import errno import logging import socket import threading @@ -10,6 +11,8 @@ from typing import Any import msgpack from cultnet_py import ( + error_message, + unsupported_schema_version_error, CultNetRawSnapshotResponse, CultNetSimulationObservationHub, CultNetRudpPacket, @@ -27,6 +30,12 @@ from cultnet_py import ( ) from .node import CultMeshNode +from .write_authority import ( + CultMeshRemoteWriteAuthority, + CultMeshRemoteWriteOperation, + CultMeshRemoteWriteRequest, + CultMeshServerPeer, +) # The shortest message id this server answers a snapshot request under. It takes a # request's messageId as given, an empty one included, so no response it sends is smaller. @@ -49,6 +58,7 @@ from cultnet_py.cultmesh_contracts import ( ) _LOGGER = logging.getLogger(__name__) +_EPHEMERAL_PAIR_ATTEMPTS = 64 class _ReplyUnqueueable(Exception): @@ -87,6 +97,9 @@ class CultMeshLocalServer: verse_catalog: CultMeshVerseCatalog = field(default_factory=CultMeshVerseCatalog) peer_catalog: CultMeshPeerCatalog = field(default_factory=CultMeshPeerCatalog) observation_hub: CultNetSimulationObservationHub | None = None + # Asked once per peer put and delete. None refuses every one of them (write_not_authorized); a node's own + # writes never come through here. + write_authority: CultMeshRemoteWriteAuthority | None = None host: str = "127.0.0.1" port: int = 0 display_name: str | None = None @@ -157,26 +170,49 @@ class CultMeshLocalServer: def start(self) -> "CultMeshLocalServer": if self._socket is not None: return self - server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind((self.host, self.port)) - server.listen() + server, rudp_socket = self._bind_sockets() server.settimeout(0.2) self._socket = server self.port = int(server.getsockname()[1]) self._stop.clear() self._thread = threading.Thread(target=self._accept_loop, daemon=True) self._thread.start() - if self.enable_rudp: - rudp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - rudp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - rudp_socket.bind((self.host, self.port)) + if rudp_socket is not None: rudp_socket.settimeout(0.02) self._rudp_socket = rudp_socket self._rudp_thread = threading.Thread(target=self._rudp_loop, daemon=True) self._rudp_thread.start() return self + def _bind_sockets(self) -> tuple[socket.socket, socket.socket | None]: + """The listening TCP socket and, when RUDP is on, the UDP socket on the same port number. + + An explicit port is bound as asked and a failure is the caller's. Port 0 asks for any free port, and the + kernel picks the TCP one without knowing whether another process holds that number for UDP, so a + UDP collision retries with a fresh TCP port instead of failing the start. Both sockets are bound + before any thread starts, so a failed start leaves nothing running or open. + """ + attempts = _EPHEMERAL_PAIR_ATTEMPTS if self.port == 0 and self.enable_rudp else 1 + for attempt in range(attempts): + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + rudp_socket = None + try: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((self.host, self.port)) + server.listen() + if self.enable_rudp: + rudp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + rudp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + rudp_socket.bind((self.host, int(server.getsockname()[1]))) + return server, rudp_socket + except OSError as error: + server.close() + if rudp_socket is not None: + rudp_socket.close() + if error.errno != errno.EADDRINUSE or attempt == attempts - 1: + raise + raise AssertionError("unreachable") + def stop(self) -> None: self._stop.set() if self._rudp_socket is not None: @@ -229,12 +265,7 @@ class CultMeshLocalServer: if schema_version == "cultnet.simulation_observation.v0": candidates = self._candidate_responses(message) return candidates[0] if candidates else None - return self._error_response( - f"Unsupported CultNet message schema: {schema_version!r}.", - message_id=str(message.get("messageId") or ""), - code="unsupported_schema_version", - details={"schemaVersion": schema_version}, - ) + return unsupported_schema_version_error(schema_version).to_wire() def _accept_loop(self) -> None: while not self._stop.is_set(): @@ -242,14 +273,15 @@ class CultMeshLocalServer: if server is None: return try: - client, _ = server.accept() + client, address = server.accept() except TimeoutError: continue except OSError: return - threading.Thread(target=self._handle_connection, args=(client,), daemon=True).start() + peer = CultMeshServerPeer(f"{address[0]}:{address[1]}", "tcp") + threading.Thread(target=self._handle_connection, args=(client, peer), daemon=True).start() - def _handle_connection(self, client: socket.socket) -> None: + def _handle_connection(self, client: socket.socket, peer: CultMeshServerPeer) -> None: with client: stream = client.makefile("rwb") transport = TcpFramedTransportConnection( @@ -271,7 +303,7 @@ class CultMeshLocalServer: if not isinstance(message, dict): continue try: - responses = self._handle_connection_message(message, subscriptions) + responses = self._handle_connection_message(message, subscriptions, peer=peer) except Exception as error: # noqa: BLE001 - one message never ends the connection # Whatever a peer's message makes the handling raise is that peer's failure: # it is refused with fixed text and the connection serves on. The log names @@ -367,7 +399,12 @@ class CultMeshLocalServer: continue if not isinstance(message, dict): continue - responses = self._handle_connection_message(message, peer.subscriptions, over_rudp=True) + responses = self._handle_connection_message( + message, + peer.subscriptions, + over_rudp=True, + peer=CultMeshServerPeer(f"{remote_addr[0]}:{remote_addr[1]}", "rudp"), + ) for response in responses: if unsendable is None: unsendable = self._send_rudp_schema_frame( @@ -523,6 +560,7 @@ class CultMeshLocalServer: subscriptions: dict[str, _DatabaseSubscription], *, over_rudp: bool = False, + peer: CultMeshServerPeer | None = None, ) -> list[dict[str, Any]]: schema_version = message.get("schemaVersion") if schema_version == "cultnet.database_subscribe.v0": @@ -546,9 +584,9 @@ class CultMeshLocalServer: subscriptions.pop(str(message.get("subscriptionId") or ""), None) return [] if schema_version == "cultnet.document_put_raw.v0": - return self._handle_raw_put(message, subscriptions, over_rudp=over_rudp) + return self._handle_raw_put(message, subscriptions, over_rudp=over_rudp, peer=peer) if schema_version == "cultnet.document_delete.v0": - return self._handle_raw_delete(message, subscriptions) + return self._handle_raw_delete(message, subscriptions, peer=peer) if schema_version == "cultnet.simulation_observation.v0": return self._candidate_responses(message) response = self.handle_message(message) @@ -560,6 +598,7 @@ class CultMeshLocalServer: subscriptions: dict[str, _DatabaseSubscription], *, over_rudp: bool = False, + peer: CultMeshServerPeer | None = None, ) -> list[dict[str, Any]]: document_record = message.get("document") if not isinstance(document_record, dict): @@ -568,6 +607,22 @@ class CultMeshLocalServer: message_id=str(message.get("messageId") or ""), code="malformed_document_put", )] + malformed_shard = self._malformed_shard_fields(message, "malformed_document_put") + if malformed_shard is not None: + return [malformed_shard] + # Refusal precedes every other judgment of the put. The node resolves the schema first, because the + # authority must be asked about the canonical schema id (a payload-inferred alias included), as C# + # does; only a payload of the wire shape is resolved, so nothing of an ungranted peer's is + # allocated. A put the node cannot resolve is asked about under the ids as sent. Residual: the + # refusal is the same either way, but an ungranted peer can still tell registered from unregistered + # schemas by timing. + target = self.node.database.raw_put_target(message) or ( + str(document_record.get("schemaId") or ""), + str(document_record.get("recordKey") or ""), + ) + refusal = self._refuse_unauthorized_write(peer, CultMeshRemoteWriteOperation.PUT, target, message) + if refusal is not None: + return [refusal] unservable = self._unservable_put(message, over_rudp=over_rudp) if unservable is not None: return [unservable] @@ -602,6 +657,8 @@ class CultMeshLocalServer: self, message: dict[str, Any], subscriptions: dict[str, _DatabaseSubscription], + *, + peer: CultMeshServerPeer | None = None, ) -> list[dict[str, Any]]: schema_id = str(message.get("schemaId") or "") record_key = str(message.get("recordKey") or "") @@ -611,6 +668,13 @@ class CultMeshLocalServer: message_id=str(message.get("messageId") or ""), code="malformed_document_delete", )] + malformed_shard = self._malformed_shard_fields(message, "malformed_document_delete") + if malformed_shard is not None: + return [malformed_shard] + target = self.node.database.raw_delete_target(message) or (schema_id, record_key) + refusal = self._refuse_unauthorized_write(peer, CultMeshRemoteWriteOperation.DELETE, target, message) + if refusal is not None: + return [refusal] change = self.node.database.apply_raw_delete_message(message) if change is None: return [self._error_response( @@ -620,6 +684,64 @@ class CultMeshLocalServer: )] return self._database_delete_notifications(message, subscriptions, resolved_schema_id=change.schema_id) + def _malformed_shard_fields(self, message: dict[str, Any], code: str) -> dict[str, Any] | None: + """The protocol error for a shardId that is not a string or a shardEpoch that is not an int (a bool is + not), else None. It precedes the gate, so the authority only ever judges a write whose shard it can + read, and the store never files a write under a shard the authority did not see.""" + shard_id = message.get("shardId") + shard_epoch = message.get("shardEpoch") + if (shard_id is None or isinstance(shard_id, str)) and ( + shard_epoch is None or (isinstance(shard_epoch, int) and not isinstance(shard_epoch, bool)) + ): + return None + return self._error_response( + "shardId must be a string and shardEpoch an integer.", + message_id=str(message.get("messageId") or ""), + code=code, + ) + + def _refuse_unauthorized_write( + self, + peer: CultMeshServerPeer | None, + operation: CultMeshRemoteWriteOperation, + target: tuple[str, str], + message: dict[str, Any], + ) -> dict[str, Any] | None: + """None when the write may proceed, else the typed refusal for the sending peer. + + No authority configured refuses; an authority that raises refuses too (fail closed) and is logged. + """ + schema_id, record_key = target + reason: str | None + authority = self.write_authority + if authority is None: + reason = "no remote write authority is configured" + else: + request = CultMeshRemoteWriteRequest( + peer=peer or CultMeshServerPeer("", "unknown"), + operation=operation, + schema_id=schema_id, + record_key=record_key, + shard_id=message.get("shardId"), + shard_epoch=message.get("shardEpoch"), + ) + try: + decision = authority.decide(request) + except Exception as error: # noqa: BLE001 - a failing authority refuses + _LOGGER.error("CultMesh remote write authority failed (%s); the write was refused.", type(error).__name__) + decision = None + reason = "the write authority failed" + else: + reason = getattr(decision, "reason", None) + if decision is not None and getattr(decision, "is_granted", False) is True: + return None + _LOGGER.warning("CultMesh %s of %s refused: write not authorized.", operation.value, schema_id) + return error_message( + f"write_not_authorized: {reason}" if reason and reason.strip() else "write_not_authorized", + code="write_not_authorized", + details={"field": "recordKey", "value": record_key}, + ).to_wire() + def _database_change_notifications( self, message: dict[str, Any], diff --git a/packages/cultmesh-py/src/cultmesh_py/session.py b/packages/cultmesh-py/src/cultmesh_py/session.py index c02696f0..4126056d 100644 --- a/packages/cultmesh-py/src/cultmesh_py/session.py +++ b/packages/cultmesh-py/src/cultmesh_py/session.py @@ -27,6 +27,7 @@ from cultnet_py.cultmesh_contracts import CultMeshAuthorityLeaseCatalog, CultMes if TYPE_CHECKING: from .server import CultMeshLocalServer + from .write_authority import CultMeshRemoteWriteAuthority @dataclass(frozen=True) @@ -83,6 +84,7 @@ class CultMeshGameSession: def serve( self, *, + write_authority: CultMeshRemoteWriteAuthority | None = None, host: str = "127.0.0.1", port: int = 0, display_name: str | None = None, @@ -96,6 +98,7 @@ class CultMeshGameSession: verse_catalog=self.verse_catalog if self._serve_verse_discovery else CultMeshVerseCatalog(), peer_catalog=self.peer_catalog if self._serve_peer_exchange else CultMeshPeerCatalog(), observation_hub=self.observation_hub if self._serve_simulation_observations else None, + write_authority=write_authority, host=host, port=port, display_name=display_name, diff --git a/packages/cultmesh-py/src/cultmesh_py/write_authority.py b/packages/cultmesh-py/src/cultmesh_py/write_authority.py new file mode 100644 index 00000000..5902827b --- /dev/null +++ b/packages/cultmesh-py/src/cultmesh_py/write_authority.py @@ -0,0 +1,66 @@ +"""The one question a CultMesh server asks before it applies a peer's write. + +Mirrors C# ``ICultNetRemoteWriteAuthority``: a server with no authority refuses every peer put and +delete, so serving a node never lets a connected peer overwrite its documents by default. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Protocol + + +class CultMeshRemoteWriteOperation(Enum): + PUT = "put" + DELETE = "delete" + + +@dataclass(frozen=True) +class CultMeshServerPeer: + """The connection a write arrived on: its endpoint and transport, nothing the peer claimed.""" + + endpoint: str + transport: str + + +@dataclass(frozen=True) +class CultMeshRemoteWriteRequest: + """One peer write as the authority sees it. + + ``schema_id`` is the canonical id the node resolved for the write, never an alias the peer sent. + ``shard_id`` and ``shard_epoch`` are as the peer sent them. + """ + + peer: CultMeshServerPeer + operation: CultMeshRemoteWriteOperation + schema_id: str + record_key: str + shard_id: str | None = None + shard_epoch: int | None = None + + +@dataclass(frozen=True) +class CultMeshRemoteWriteDecision: + is_granted: bool + reason: str | None = None + + @staticmethod + def grant() -> CultMeshRemoteWriteDecision: + return CultMeshRemoteWriteDecision(True) + + @staticmethod + def refuse(reason: str | None = None) -> CultMeshRemoteWriteDecision: + return CultMeshRemoteWriteDecision(False, reason) + + +class CultMeshRemoteWriteAuthority(Protocol): + def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision: ... + + +class CultMeshGrantAllWrites: + """Grants every peer write. For a host that has already authenticated its peers (or a test rig); it is + an explicit choice because serving without an authority refuses writes.""" + + def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision: + return CultMeshRemoteWriteDecision.grant() diff --git a/packages/cultmesh-py/tests/test_authority_route_vectors.py b/packages/cultmesh-py/tests/test_authority_route_vectors.py new file mode 100644 index 00000000..3f116e2d --- /dev/null +++ b/packages/cultmesh-py/tests/test_authority_route_vectors.py @@ -0,0 +1,217 @@ +"""Judges `contracts/cultmesh/authority-route-vectors*.json` in Python. + +`authority-route-vectors.json` is written by the C# reference (signatures are +captured C# bytes); the `.ts-signed.json` file is signed by TypeScript over awkward +inputs. Each negative case fails when the matching check is removed from +`cultmesh_py.authority`. +""" + +from __future__ import annotations + +import base64 +import json +import unittest +from dataclasses import replace +from pathlib import Path + +from cryptography.hazmat.primitives.asymmetric import ec + +from cultmesh_py.authority import ( + AUTHENTICATED_REMOTE, + LOCAL_DEVELOPMENT, + AuthorityRoute, + AuthorityRouteRefusal, + AuthorityTrustPolicy, + P256PublicKey, + RouteCertificate, + SessionAccepted, + SessionOpen, + canonical_route, + verify_session_proof, +) + +CONTRACTS = Path(__file__).resolve().parents[3] / "contracts" / "cultmesh" + + +def _key(element: dict) -> P256PublicKey: + return P256PublicKey(element["keyId"], element["x"], element["y"]) + + +class Vector: + def __init__(self, name: str) -> None: + doc = json.loads((CONTRACTS / name).read_text(encoding="utf-8")) + self.now = doc["nowUnixMilliseconds"] + self.root = _key(doc["odinRoot"]) + r = doc["route"] + self.verse_id = r["verseId"] + c = r["certificate"] + self.certificate = RouteCertificate( + _key(c["providerKey"]), c["odinKeyId"], c["issuedAtUnixMilliseconds"], + c["expiresAtUnixMilliseconds"], c["signature"], + ) + self.route = AuthorityRoute( + r["authorityRuntimeId"], r["endpoint"], r["protocolIds"], r["priority"], + r["generation"], self.certificate, + ) + p = doc["sessionProof"] + q = p["request"] + self.request = SessionOpen( + q["clientNonce"], q["messageId"], q["sourceRuntimeId"], q["verseId"], + q["authorityRuntimeId"], q["protocolId"], q["routeGeneration"], + ) + self.accepted = SessionAccepted(q["clientNonce"], p["providerKeyId"], p["providerSignature"]) + self.trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [self.root]) + + def with_certificate(self, **changes) -> AuthorityRoute: + return replace(self.route, certificate=replace(self.certificate, **changes)) + + +def refusal(test: unittest.TestCase, fn) -> str: + with test.assertRaises(AuthorityRouteRefusal) as raised: + fn() + test.assertEqual("authentication", raised.exception.reason) + return raised.exception.message + + +class AuthorityRouteVectorTests(unittest.TestCase): + def test_cs_written_vector_verifies_route_and_session_proof(self) -> None: + v = Vector("authority-route-vectors.json") + v.trust.validate(v.verse_id, v.route, v.now) + self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, v.route, v.trust, v.now)) + + def test_ts_signed_vector_verifies_and_tamper_is_refused(self) -> None: + v = Vector("authority-route-vectors.ts-signed.json") + v.trust.validate(v.verse_id, v.route, v.now) + self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, v.route, v.trust, v.now)) + tampered = replace(v.route, priority=v.route.priority + 1) + self.assertEqual( + "The Odin route certificate signature is invalid.", + refusal(self, lambda: v.trust.validate(v.verse_id, tampered, v.now)), + ) + + def test_canonical_route_is_length_prefixed_and_sorted(self) -> None: + v = Vector("authority-route-vectors.json") + data = canonical_route(v.verse_id, v.route) + self.assertTrue(data.startswith(b"\x00\x00\x00\x26gamecult.cultmesh.route-certificate.v1\x00\x00\x00\x08aetheria")) + self.assertIn(b"cultmesh.content.v1\x1fcultmesh.documents.v1", data) + + def test_tampered_payload_fields_are_refused(self) -> None: + v = Vector("authority-route-vectors.json") + invalid = "The Odin route certificate signature is invalid." + for tampered, verse in ( + (replace(v.route, priority=8), v.verse_id), + (replace(v.route, endpoint="wss://evil.example/mesh"), v.verse_id), + (replace(v.route, protocol_ids=("cultmesh.documents.v1",)), v.verse_id), + (replace(v.route, generation="other"), v.verse_id), + (v.with_certificate(expires_at_unix_milliseconds=v.certificate.expires_at_unix_milliseconds + 1), v.verse_id), + (v.route, "other-verse"), + ): + self.assertEqual(invalid, refusal(self, lambda: v.trust.validate(verse, tampered, v.now))) + + def test_wrong_root_key_is_refused(self) -> None: + v = Vector("authority-route-vectors.json") + numbers = ec.generate_private_key(ec.SECP256R1()).public_key().public_numbers() + impostor = P256PublicKey( + v.root.key_id, + base64.b64encode(numbers.x.to_bytes(32, "big")).decode(), + base64.b64encode(numbers.y.to_bytes(32, "big")).decode(), + ) + trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [impostor]) + self.assertEqual( + "The Odin route certificate signature is invalid.", + refusal(self, lambda: trust.validate(v.verse_id, v.route, v.now)), + ) + + def test_untrusted_odin_key_id_is_refused(self) -> None: + v = Vector("authority-route-vectors.json") + trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [replace(v.root, key_id="someone-else")]) + self.assertEqual( + "Odin key 'odin-vector' is not trusted by this consumer.", + refusal(self, lambda: trust.validate(v.verse_id, v.route, v.now)), + ) + + def test_expired_and_not_yet_valid_are_refused(self) -> None: + v = Vector("authority-route-vectors.json") + window = "The Odin route certificate is not currently valid." + expires = v.certificate.expires_at_unix_milliseconds + issued = v.certificate.issued_at_unix_milliseconds + v.trust.validate(v.verse_id, v.route, expires - 1) + v.trust.validate(v.verse_id, v.route, issued) + self.assertEqual(window, refusal(self, lambda: v.trust.validate(v.verse_id, v.route, expires))) + self.assertEqual(window, refusal(self, lambda: v.trust.validate(v.verse_id, v.route, issued - 1))) + + def test_unsigned_and_unprotected_routes_are_refused(self) -> None: + v = Vector("authority-route-vectors.json") + unsigned = replace(v.route, certificate=None) + self.assertEqual( + "Remote CultMesh routes require an Odin-signed authority certificate.", + refusal(self, lambda: v.trust.validate(v.verse_id, unsigned, v.now)), + ) + blank = v.with_certificate(signature=" ") + self.assertEqual( + "Remote CultMesh routes require an Odin-signed authority certificate.", + refusal(self, lambda: v.trust.validate(v.verse_id, blank, v.now)), + ) + plain = replace(v.route, endpoint="ws://provider.example/mesh") + self.assertEqual( + "Authenticated remote CultMesh routes require TLS or QUIC channel protection.", + refusal(self, lambda: v.trust.validate(v.verse_id, plain, v.now)), + ) + + def test_local_development_admits_only_unsigned_loopback(self) -> None: + v = Vector("authority-route-vectors.json") + dev = AuthorityTrustPolicy(LOCAL_DEVELOPMENT, []) + loop = AuthorityRoute("a", "ws://127.0.0.1:9/mesh") + dev.validate(v.verse_id, loop, v.now) + self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, loop, dev, v.now)) + for endpoint in ("ws://provider.example/mesh", "ws://127.0.0.1./mesh", "ws:127.0.0.1"): + refusal(self, lambda e=endpoint: dev.validate(v.verse_id, AuthorityRoute("a", e), v.now)) + authenticated = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, []) + refusal(self, lambda: authenticated.validate(v.verse_id, loop, v.now)) + + def test_malformed_signatures_get_their_own_refusals(self) -> None: + v = Vector("authority-route-vectors.json") + not_base64 = v.with_certificate(signature="!!not base64!!") + self.assertEqual( + "The Odin route signature is not base64.", + refusal(self, lambda: v.trust.validate(v.verse_id, not_base64, v.now)), + ) + short = v.with_certificate(signature=base64.b64encode(b"x" * 63).decode()) + self.assertEqual( + "The Odin route signature is not IEEE P1363 P-256.", + refusal(self, lambda: v.trust.validate(v.verse_id, short, v.now)), + ) + + def test_high_s_signature_verifies_as_it_does_in_dotnet(self) -> None: + v = Vector("authority-route-vectors.json") + order = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 + raw = base64.b64decode(v.certificate.signature) + s = int.from_bytes(raw[32:], "big") + flipped = raw[:32] + (order - s).to_bytes(32, "big") + high = v.with_certificate(signature=base64.b64encode(flipped).decode()) + v.trust.validate(v.verse_id, high, v.now) + + def test_session_proof_rejects_replay_wrong_key_and_forgery(self) -> None: + v = Vector("authority-route-vectors.json") + replayed = replace(v.request, client_nonce="replayed") + accepted = replace(v.accepted, client_nonce="replayed") + self.assertFalse(verify_session_proof(replayed, accepted, v.verse_id, v.route, v.trust, v.now)) + self.assertFalse(verify_session_proof( + v.request, replace(v.accepted, provider_key_id="other"), v.verse_id, v.route, v.trust, v.now)) + self.assertFalse(verify_session_proof( + v.request, replace(v.accepted, client_nonce="other"), v.verse_id, v.route, v.trust, v.now)) + self.assertFalse(verify_session_proof( + v.request, replace(v.accepted, provider_signature=""), v.verse_id, v.route, v.trust, v.now)) + raw = bytearray(base64.b64decode(v.accepted.provider_signature)) + raw[0] ^= 1 + forged = replace(v.accepted, provider_signature=base64.b64encode(bytes(raw)).decode()) + self.assertFalse(verify_session_proof(v.request, forged, v.verse_id, v.route, v.trust, v.now)) + moved = replace(v.request, protocol_id="cultmesh.content.v1") + self.assertFalse(verify_session_proof(moved, v.accepted, v.verse_id, v.route, v.trust, v.now)) + # An expired route fails closed even with a good proof. + refusal(self, lambda: verify_session_proof( + v.request, v.accepted, v.verse_id, v.route, v.trust, v.certificate.expires_at_unix_milliseconds)) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultmesh-py/tests/test_cultmesh.py b/packages/cultmesh-py/tests/test_cultmesh.py index cdf2c965..cc565d91 100644 --- a/packages/cultmesh-py/tests/test_cultmesh.py +++ b/packages/cultmesh-py/tests/test_cultmesh.py @@ -68,7 +68,7 @@ from cultnet_py import ( snapshot_request, ) from cultnet_py.cultmesh_contracts import VERSE_CATALOG_REQUEST -from cultmesh_py import create_node +from cultmesh_py import CultMeshGrantAllWrites, create_node from cultmesh_py import ( CultMesh, CultMeshDatabase, @@ -380,6 +380,46 @@ class CultMeshTests(unittest.TestCase): self.assertEqual(observed[0].value["value"], "new") self.assertEqual(node.cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) + # A record arriving under a compatible id or the schema name is the same record: one id, the local one, so an existing + # record is updated with its previous value. + def test_cultmesh_node_reports_an_update_when_a_record_arrives_under_a_compatible_id_or_the_schema_name(self) -> None: + document = define_database_entry_type( + "mesh.compatible-policy", + [("schema_version", 0), ("name", 1), ("value", 2)], + schema_id="mesh.compatible-policy.current", + schema_name="mesh.compatible_policy", + schema_version="mesh.compatible_policy.v1", + compatible_schema_ids=["mesh.compatible-policy.current", "mesh.compatible-policy.older"], + ) + for wire_schema_id in ("mesh.compatible-policy.older", "mesh.compatible_policy"): + node = create_node(runtime_id="python-node") + node.register_document(document) + node.put(document, "policy:1", {"schema_version": "mesh.compatible_policy.v1", "name": "policy", "value": "old"}) + observed: list[CultMeshDatabaseChange] = [] + unsubscribe = node.database.watch_record(document, "policy:1", observed.append) + try: + applied = node.database.apply_snapshot_response({ + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "compatible-snapshot", + "documents": [{ + "schemaId": wire_schema_id, + "recordKey": "policy:1", + "storedAt": "2026-06-25T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload({ + "schema_version": "mesh.compatible_policy.v1", "name": "policy", "value": "new", + }), + }], + }) + finally: + unsubscribe() + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(applied[0].schema_id, local_schema_id, wire_schema_id) + self.assertEqual([change.change_kind for change in observed], ["updated"], wire_schema_id) + self.assertEqual(observed[0].previous_value["value"], "old", wire_schema_id) + self.assertEqual(observed[0].value["value"], "new", wire_schema_id) + def test_cultmesh_node_syncs_snapshot_and_shard_log_through_cultnet_client(self) -> None: import msgpack # type: ignore from cultnet_py import read_frame, write_frame @@ -1591,7 +1631,7 @@ class CultMeshTests(unittest.TestCase): ) primary = CultMesh.create_node(runtime_id="forward-primary") primary.database.register_document(document) - server = CultMesh.serve_node(primary) + server = CultMesh.serve_node(primary, write_authority=CultMeshGrantAllWrites()) try: shard = CultNetShardDescriptor( shard_id="forward", @@ -2975,7 +3015,7 @@ class CultMeshTests(unittest.TestCase): source.database.put_raw_message(document, "note:live", CanonicalNote("initial")) target = CultMesh.create_node(runtime_id="subscription-alias-target") target.database.register_document(document) - server = CultMesh.serve_node(source) + server = CultMesh.serve_node(source, write_authority=CultMeshGrantAllWrites()) try: raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription: @@ -3493,6 +3533,7 @@ class CultMeshTests(unittest.TestCase): sys.executable, "-m", "cultmesh_py.daemon", + "--allow-peer-writes", "--runtime-id", "live-daemon-peer", "--port", @@ -3683,6 +3724,7 @@ class CultMeshTests(unittest.TestCase): sys.executable, "-m", "cultmesh_py.daemon", + "--allow-peer-writes", "--runtime-id", "forwarder-daemon-peer", "--port", @@ -3958,6 +4000,7 @@ class CultMeshTests(unittest.TestCase): server = CultMesh.serve_node( node, verse_catalog=verses, + write_authority=CultMeshGrantAllWrites(), peer_catalog=peers, observation_hub=observation_hub, display_name="Mesh Server", @@ -4234,7 +4277,7 @@ class CultMeshTests(unittest.TestCase): node = CultMesh.create_node(runtime_id="mesh-alias-sub-server") node.database.register_document(document) - server = CultMesh.serve_node(node) + server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites()) try: raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) with raw_client.subscribe_database( @@ -4283,7 +4326,7 @@ class CultMeshTests(unittest.TestCase): shard_epoch=1, ) - server = CultMesh.serve_node(node) + server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites()) try: raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) with raw_client.subscribe_database( @@ -4380,7 +4423,7 @@ class CultMeshTests(unittest.TestCase): def served_alone_bytes(key: str, body_length: int) -> int: node = CultMesh.create_node(runtime_id="mesh-served-bound-probe") node.database.register_document(document) - server = CultMesh.serve_node(node, enable_rudp=False) + server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), enable_rudp=False) try: response, payload = exchange(server, put("probe", key, body_length), empty_id_snapshot) finally: @@ -4394,7 +4437,7 @@ class CultMeshTests(unittest.TestCase): node = CultMesh.create_node(runtime_id="mesh-served-bound") node.database.register_document(document) - server = CultMesh.serve_node(node, max_snapshot_bytes=limit, enable_rudp=False) + server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), max_snapshot_bytes=limit, enable_rudp=False) try: refused, _ = exchange(server, put("put-over", "note:over", 101)) stored_after_refusal = node.database.get(document, "note:over") @@ -4458,7 +4501,7 @@ class CultMeshTests(unittest.TestCase): document = self._rudp_bound_note() node = CultMesh.create_node(runtime_id="mesh-rudp-large") node.database.register_document(document) - server = CultMesh.serve_node(node) + server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites()) try: served, payload = self._rudp_exchange( server, @@ -4484,7 +4527,7 @@ class CultMeshTests(unittest.TestCase): def served_alone_bytes(body_length: int) -> int: node = CultMesh.create_node(runtime_id="mesh-rudp-bound-probe") node.database.register_document(document) - server = CultMesh.serve_node(node) + server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites()) try: _, payload = self._rudp_exchange( server, self._rudp_note_put(document, "probe", key, body_length), @@ -4514,7 +4557,7 @@ class CultMeshTests(unittest.TestCase): node = CultMesh.create_node(runtime_id="mesh-rudp-bound") node.database.register_document(document) - server = CultMesh.serve_node(node, rudp_max_fragment_bytes=100, rudp_max_pending_reliable_packets=8) + server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), rudp_max_fragment_bytes=100, rudp_max_pending_reliable_packets=8) try: refusals = [ self._rudp_exchange(server, self._rudp_note_put(document, f"put-{served}", "note:q", body_for(served)))[0] @@ -4752,7 +4795,7 @@ class CultMeshTests(unittest.TestCase): body_for = self._rudp_body_for(document, "note:w") node = CultMesh.create_node(runtime_id="mesh-rudp-wide") node.database.register_document(document) - server = CultMesh.serve_node(node, rudp_max_fragment_bytes=1) + server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), rudp_max_fragment_bytes=1) try: transport = create_rudp_schema_transport( host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id, @@ -4817,7 +4860,7 @@ class CultMeshTests(unittest.TestCase): document = self._rudp_bound_note() node = CultMesh.create_node(runtime_id="mesh-rudp-malformed") node.database.register_document(document) - server = CultMesh.serve_node(node) + server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites()) try: refusals = [] for field_name, value in ( @@ -4877,7 +4920,7 @@ class CultMeshTests(unittest.TestCase): node = CultMesh.create_node(runtime_id="mesh-unstorable-put") node.database.register_document(document) node.database.register_document(global_document) - server = CultMesh.serve_node(node) + server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites()) try: rudp_refusals = [(put["messageId"], self._rudp_exchange(server, put)[0]) for put in cases()] rudp_after, _ = self._rudp_exchange(server, hello(runtime_id="rudp-after").to_wire()) @@ -5008,17 +5051,16 @@ class CultMeshTests(unittest.TestCase): finally: server.stop() - self.assertEqual(unsupported["messageId"], "bad-schema") self.assertEqual(unsupported["code"], "unsupported_schema_version") - self.assertEqual(unsupported["details"]["schemaVersion"], "cultnet.nope.v0") - self.assertIn("Unsupported CultNet message schema", unsupported["error"]) + self.assertEqual(unsupported["details"]["field"], "schemaVersion") + self.assertEqual(unsupported["details"]["value"], "cultnet.nope.v0") + self.assertIn("unsupported_schema_version", unsupported["error"]) self.assertEqual(malformed_put["messageId"], "bad-put") self.assertEqual(malformed_put["code"], "malformed_document_put") self.assertEqual(malformed_delete["messageId"], "bad-delete") self.assertEqual(malformed_delete["code"], "malformed_document_delete") - self.assertEqual(unsupported_v1["messageId"], "bad-v1-selection") self.assertEqual(unsupported_v1["code"], "unsupported_schema_version") - self.assertEqual(unsupported_v1["details"]["schemaVersion"], "cultnet.snapshot_request.v1") + self.assertEqual(unsupported_v1["details"]["value"], "cultnet.snapshot_request.v1") def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None: peer = CultMeshPeerCard( diff --git a/packages/cultmesh-py/tests/test_remote_write_authority.py b/packages/cultmesh-py/tests/test_remote_write_authority.py new file mode 100644 index 00000000..0d2a0feb --- /dev/null +++ b/packages/cultmesh-py/tests/test_remote_write_authority.py @@ -0,0 +1,341 @@ +"""A peer's put or delete is applied only when the server's write authority grants it. + +Ports the behaviours of C# RemoteWriteAuthorityTests. The C# forwarding behaviour (a refused write is never +forwarded) has no counterpart: this server does not forward non-primary writes. The handlers are reached with +the peer the connection loop would pass, and one test drives the same path over a real TCP connection. +""" + +from __future__ import annotations + +import json +import unittest +from pathlib import Path +from typing import Any + +import msgpack +from cultcache_py import define_database_entry_type +from cultmesh_py import ( + CultMesh, + CultMeshGrantAllWrites, + CultMeshRemoteWriteDecision, + CultMeshRemoteWriteOperation, + CultMeshRemoteWriteRequest, + CultMeshServerPeer, + create_node, +) +from cultmesh_py.server import CultMeshLocalServer +from cultnet_py import CultNetPeerError, create_rudp_schema_transport, document_delete, document_put_raw, error_message + +SCHEMA_ID = "sha256:authority-note" +ALIAS = "runtime.generated.authority-note.ui.42" +SHARD_ID = "authority-shard" +KEY_A = "tests:authority:a" +KEY_B = "tests:authority:b" +PEER = CultMeshServerPeer("127.0.0.1:50000", "tcp") + +_REPO = Path(__file__).resolve().parents[3] +_VECTORS = json.loads((_REPO / "contracts/cultnet/interop/error-vectors.json").read_text(encoding="utf-8"))["vectors"] + +NOTE = define_database_entry_type( + "tests.authority_note", + [("schema_version", 0), ("text", 1)], + schema_id=SCHEMA_ID, + schema_name="tests.authority_note", + schema_version="tests.authority_note.v1", +) + + +class Recording: + def __init__(self, decide: Any) -> None: + self._decide = decide + self.requests: list[CultMeshRemoteWriteRequest] = [] + + def decide(self, request: CultMeshRemoteWriteRequest) -> Any: + self.requests.append(request) + return self._decide(request) + + +def put_of(key: str, text: str, *, schema_id: str = SCHEMA_ID) -> dict[str, Any]: + return document_put_raw( + message_id="wire", + key=key, + schema_id=schema_id, + stored_at="2026-11-07T00:00:00Z", + payload=NOTE.encode_payload({"schema_version": "tests.authority_note.v1", "text": text}), + shard_id=SHARD_ID, + shard_epoch=1, + ).to_wire() + + +def delete_of(key: str, *, schema_id: str = SCHEMA_ID) -> dict[str, Any]: + return document_delete( + message_id="wire-delete", schema_id=schema_id, record_key=key, shard_id=SHARD_ID, shard_epoch=1 + ).to_wire() + + +class RemoteWriteAuthorityTests(unittest.TestCase): + def build(self, authority: Any = None) -> CultMeshLocalServer: + node = create_node(runtime_id="authority-server") + node.database.register_document(NOTE) + return CultMeshLocalServer(node=node, port=0, write_authority=authority) + + def text_of(self, server: CultMeshLocalServer, key: str) -> str | None: + value = server.node.cache.get(NOTE, key) + return None if value is None else value["text"] + + def send(self, server: CultMeshLocalServer, message: dict[str, Any]) -> list[dict[str, Any]]: + subscriptions: dict[str, Any] = {} + return server._handle_connection_message(message, subscriptions, peer=PEER) + + def assert_refused(self, replies: list[dict[str, Any]], key: str, *, error: str | None = None) -> None: + self.assertEqual(len(replies), 1) + reply = replies[0] + self.assertEqual(reply["schemaVersion"], "cultnet.error.v0") + self.assertEqual(reply["code"], "write_not_authorized") + self.assertTrue(reply["error"].startswith("write_not_authorized")) + self.assertEqual(reply["details"]["field"], "recordKey") + self.assertEqual(reply["details"]["value"], key) + if error is not None: + self.assertEqual(reply["error"], error) + + def seed(self, server: CultMeshLocalServer, key: str, text: str) -> None: + # The host writes its own record through its own door, which asks no remote authority. + self.assertEqual(server.node.database.apply_raw_put_message(put_of(key, text)).change_kind, "added") + + def test_with_no_authority_a_peers_put_and_delete_are_refused_and_change_nothing(self) -> None: + server = self.build(None) + self.seed(server, KEY_A, "server") + + with self.assertLogs("cultmesh_py.server", level="WARNING") as logs: + self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A) + self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A) + self.assert_refused(self.send(server, put_of(KEY_B, "peer")), KEY_B) + + self.assertEqual(self.text_of(server, KEY_A), "server") + self.assertIsNone(self.text_of(server, KEY_B)) + self.assertEqual(sum("write not authorized" in line for line in logs.output), 3) + + def test_a_refusal_publishes_no_change_to_a_subscriber(self) -> None: + server = self.build(None) + server.start() + try: + client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + with client.subscribe_database(subscription_id="s", schema_ids=[SCHEMA_ID], include_snapshot=False) as sub: + with self.assertRaises(CultNetPeerError) as raised: + sub.send(put_of(KEY_A, "peer")) + sub.read_next() + finally: + server.stop() + self.assertEqual(raised.exception.response["code"], "write_not_authorized") + self.assertIsNone(self.text_of(server, KEY_A)) + + def test_a_granted_put_and_delete_commit_and_the_authority_saw_this_peer(self) -> None: + authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant()) + server = self.build(authority) + + put_replies = self.send(server, put_of(KEY_A, "peer")) + self.assertEqual(self.text_of(server, KEY_A), "peer") + self.assertEqual(put_replies, []) + self.send(server, delete_of(KEY_A)) + self.assertIsNone(self.text_of(server, KEY_A)) + + self.assertEqual(len(authority.requests), 2) + self.assertTrue(all(request.peer is PEER for request in authority.requests)) + + def test_the_request_carries_the_resolved_write_and_scoped_grants_refuse_their_neighbours(self) -> None: + authority = Recording( + lambda request: CultMeshRemoteWriteDecision.grant() + if request.operation is CultMeshRemoteWriteOperation.PUT and request.record_key == KEY_A + else CultMeshRemoteWriteDecision.refuse("scoped") + ) + server = self.build(authority) + self.seed(server, KEY_A, "server") + + self.send(server, put_of(KEY_A, "aliased", schema_id=ALIAS)) + + request = authority.requests[0] + self.assertEqual(request.schema_id, SCHEMA_ID, "the authority sees the canonical id, not the alias as sent") + self.assertIs(request.operation, CultMeshRemoteWriteOperation.PUT) + self.assertEqual(request.record_key, KEY_A) + self.assertEqual(request.shard_id, SHARD_ID) + self.assertEqual(request.shard_epoch, 1) + self.assertEqual(self.text_of(server, KEY_A), "aliased") + + self.assert_refused(self.send(server, put_of(KEY_B, "other-key")), KEY_B) + self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A) + self.assertIsNone(self.text_of(server, KEY_B), "a grant scoped to key a refuses key b") + self.assertEqual(self.text_of(server, KEY_A), "aliased", "a grant scoped to put refuses delete") + self.assertIs(authority.requests[-1].operation, CultMeshRemoteWriteOperation.DELETE) + self.assertEqual(authority.requests[-1].schema_id, SCHEMA_ID) + + def test_refusal_precedes_every_other_judgment_of_the_put(self) -> None: + refusing = self.build(None) + unowned = put_of("unrelated:authority", "x", schema_id="sha256:nobody-owns-this") + unowned["document"]["payload"] = msgpack.packb(["nobody.owns.this.v1", "x"], use_bin_type=True) + self.assert_refused(self.send(refusing, unowned), "unrelated:authority") + blank_key = put_of("", "x") + self.assert_refused(self.send(refusing, blank_key), "") + + granting = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.grant())) + reply = self.send(granting, unowned) + self.assertEqual(reply[0]["code"], "unregistered_document_put", "a granted put still meets the store's refusal") + + def test_an_authority_that_raises_refuses_and_is_logged_as_an_error(self) -> None: + def explode(_: Any) -> Any: + raise RuntimeError("authority exploded") + + server = self.build(Recording(explode)) + self.seed(server, KEY_A, "server") + + with self.assertLogs("cultmesh_py.server", level="ERROR") as logs: + self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A) + self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A) + + self.assertEqual(self.text_of(server, KEY_A), "server", "fail closed") + self.assertTrue(any("RuntimeError" in line for line in logs.output)) + + def test_a_refusal_with_no_reason_still_says_why(self) -> None: + server = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.refuse())) + self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A, error="write_not_authorized") + + def test_the_refusal_names_the_key_and_the_code_in_the_pinned_bytes(self) -> None: + vector = next(v for v in _VECTORS if v["name"] == "write_not_authorized") + message = error_message( + "write_not_authorized: not granted", + code="write_not_authorized", + details={"field": "recordKey", "value": "some:key"}, + ) + self.assertEqual(message.to_bytes(), bytes.fromhex(vector["messagePackHex"])) + + server = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.refuse("not granted"))) + reply = self.send(server, put_of("some:key", "peer"))[0] + self.assertEqual(msgpack.packb(reply, use_bin_type=True), bytes.fromhex(vector["messagePackHex"])) + + def test_the_peer_receives_the_typed_refusal_over_tcp_and_a_grant_sends_nothing_back(self) -> None: + authority = Recording( + lambda request: CultMeshRemoteWriteDecision.grant() + if request.record_key == KEY_A + else CultMeshRemoteWriteDecision.refuse("only a") + ) + server = self.build(authority) + server.start() + try: + client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) + with client.subscribe_database(subscription_id="s", schema_ids=[SCHEMA_ID], include_snapshot=False) as sub: + sub.send(put_of(KEY_A, "peer")) + self.assertEqual(sub.read_next_change().record_key, KEY_A, "the granted write was answered by its change, not an error") + sub.send(put_of(KEY_B, "peer")) + with self.assertRaises(CultNetPeerError) as raised: + sub.read_next() + finally: + server.stop() + + reply = raised.exception.response + self.assertEqual(reply["error"], "write_not_authorized: only a") + self.assertEqual(reply["details"]["value"], KEY_B) + self.assertTrue(authority.requests[0].peer.endpoint.startswith("127.0.0.1:")) + self.assertEqual(authority.requests[0].peer.transport, "tcp") + self.assertEqual(self.text_of(server, KEY_A), "peer") + self.assertIsNone(self.text_of(server, KEY_B)) + + def test_a_delete_sent_under_a_schema_alias_asks_the_authority_with_the_canonical_id(self) -> None: + authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant()) + server = self.build(authority) + self.seed(server, KEY_A, "server") + + self.send(server, delete_of(KEY_A, schema_id="tests.authority_note.v1")) + + request = authority.requests[0] + self.assertIs(request.operation, CultMeshRemoteWriteOperation.DELETE) + self.assertEqual(request.schema_id, SCHEMA_ID) + self.assertIsNone(self.text_of(server, KEY_A), "the aliased delete was granted and applied") + + def test_a_missing_decision_refuses_put_and_delete(self) -> None: + server = self.build(Recording(lambda _: None)) + self.seed(server, KEY_A, "server") + + self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A) + self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A) + self.assert_refused(self.send(server, put_of(KEY_B, "peer")), KEY_B) + + self.assertEqual(self.text_of(server, KEY_A), "server") + self.assertIsNone(self.text_of(server, KEY_B)) + + def test_a_peer_over_rudp_is_refused_with_its_rudp_endpoint_and_the_server_serves_on(self) -> None: + authority = Recording(lambda _: CultMeshRemoteWriteDecision.refuse("rudp no")) + server = self.build(authority) + server.start() + try: + transport = create_rudp_schema_transport( + host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id, timeout_seconds=4.0 + ) + try: + transport.send("schema", msgpack.packb(put_of(KEY_A, "peer"), use_bin_type=True)) + reply = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False) + transport.send("schema", msgpack.packb(delete_of(KEY_A), use_bin_type=True)) + delete_reply = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False) + finally: + transport.close() + finally: + server.stop() + self.assertEqual(reply["error"], "write_not_authorized: rudp no") + self.assertEqual(delete_reply["code"], "write_not_authorized") + self.assertEqual([r.peer.transport for r in authority.requests], ["rudp", "rudp"]) + self.assertTrue(all(r.peer.endpoint.startswith("127.0.0.1:") for r in authority.requests)) + self.assertIsNone(self.text_of(server, KEY_A)) + + def test_a_put_with_an_absurd_payload_is_refused_by_the_gate_not_crashed_on(self) -> None: + server = self.build(None) + absurd = put_of(KEY_A, "x") + absurd["document"]["payload"] = 2**70 + self.assert_refused(self.send(server, absurd), KEY_A) + + def test_an_ungranted_peers_huge_payload_is_refused_without_allocating_it(self) -> None: + import tracemalloc + + server = self.build(None) + huge = put_of(KEY_A, "x") + huge["document"]["payload"] = 10**9 # bytes(10**9) would allocate a gigabyte + tracemalloc.start() + try: + replies = self.send(server, huge) + _, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + self.assert_refused(replies, KEY_A) + self.assertLess(peak, 10_000_000) + + def test_wrong_typed_shard_fields_are_refused_before_the_gate_and_write_nothing(self) -> None: + authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant()) + server = self.build(authority) + self.seed(server, KEY_B, "server") + shards_before = server.node.database.shard_ids() + for field, value in (("shardId", {"not": "a string"}), ("shardId", 5), ("shardEpoch", "7"), ("shardEpoch", True), ("shardEpoch", 1.5)): + for operation, code in ( + ("put", "malformed_document_put"), + ("delete", "malformed_document_delete"), + ): + with self.subTest(field=field, value=value, operation=operation): + message = put_of(KEY_A, "x") if operation == "put" else delete_of(KEY_B) + message[field] = value + replies = self.send(server, message) + self.assertEqual([reply["code"] for reply in replies], [code]) + self.assertEqual(authority.requests, [], "the authority was never asked") + self.assertIsNone(self.text_of(server, KEY_A), "nothing was written") + self.assertEqual(self.text_of(server, KEY_B), "server", "nothing was deleted") + self.assertEqual(server.node.database.shard_ids(), shards_before, "no shard or log entry was filed") + + def test_well_typed_shard_fields_reach_the_authority_as_sent(self) -> None: + authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant()) + server = self.build(authority) + self.send(server, put_of(KEY_A, "x")) + self.assertEqual((authority.requests[0].shard_id, authority.requests[0].shard_epoch), (SHARD_ID, 1)) + + def test_grant_all_is_an_explicit_choice_that_grants_every_write(self) -> None: + server = self.build(CultMeshGrantAllWrites()) + self.send(server, put_of(KEY_A, "peer")) + self.assertEqual(self.text_of(server, KEY_A), "peer") + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultmesh-py/tests/test_rudp_server_generation.py b/packages/cultmesh-py/tests/test_rudp_server_generation.py index cd84c908..8cb7586b 100644 --- a/packages/cultmesh-py/tests/test_rudp_server_generation.py +++ b/packages/cultmesh-py/tests/test_rudp_server_generation.py @@ -10,7 +10,7 @@ from dataclasses import dataclass import msgpack from cultcache_py import define_database_entry_type -from cultmesh_py import create_node +from cultmesh_py import CultMeshGrantAllWrites, create_node from cultmesh_py.server import CultMeshLocalServer from cultnet_py import ( CultNetRudpSendOptions, @@ -49,7 +49,7 @@ class RudpServerGenerationTests(unittest.TestCase): node = create_node(runtime_id="rudp-generation") node.database.register_document(NOTE) # Replies stay whole datagrams here, so each server packet the pump reads is one message. - self.server = CultMeshLocalServer(node=node, rudp_connection_id=CONNECTION_ID, rudp_max_fragment_bytes=60_000) + self.server = CultMeshLocalServer(node=node, rudp_connection_id=CONNECTION_ID, rudp_max_fragment_bytes=60_000, write_authority=CultMeshGrantAllWrites()) self.server.start() self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock.bind(("127.0.0.1", 0)) diff --git a/packages/cultmesh-py/tests/test_server_bind.py b/packages/cultmesh-py/tests/test_server_bind.py new file mode 100644 index 00000000..89444b47 --- /dev/null +++ b/packages/cultmesh-py/tests/test_server_bind.py @@ -0,0 +1,69 @@ +"""A server asked for any port finds a TCP+UDP pair; one asked for a port gets that port or an honest failure.""" + +from __future__ import annotations + +import errno +import socket +import threading +import unittest +from unittest.mock import patch + +from cultmesh_py import create_node +from cultmesh_py.server import CultMeshLocalServer + + +def server(**kwargs) -> CultMeshLocalServer: + return CultMeshLocalServer(node=create_node(runtime_id="bind-test"), host="127.0.0.1", **kwargs) + + +class ServerBindTests(unittest.TestCase): + def test_port_zero_retries_with_a_fresh_port_when_another_process_holds_the_udp_number(self) -> None: + real_bind = socket.socket.bind + refused: list[int] = [] + + def bind(sock: socket.socket, address) -> None: + # The first two UDP binds find the number taken, as a UDP listener of another process would. + if sock.type == socket.SOCK_DGRAM and len(refused) < 2: + refused.append(address[1]) + raise OSError(errno.EADDRINUSE, "Address already in use") + real_bind(sock, address) + + with patch.object(socket.socket, "bind", bind): + started = server().start() + try: + self.assertEqual(len(refused), 2) + self.assertNotIn(started.port, refused[:1], "the retry used a fresh TCP port") + probe = socket.create_connection(("127.0.0.1", started.port), timeout=2.0) + probe.close() + finally: + started.stop() + + def test_an_explicit_port_whose_udp_number_is_taken_fails_and_leaves_nothing_running(self) -> None: + holder = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + holder.bind(("127.0.0.1", 0)) + port = holder.getsockname()[1] + free = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + free.bind(("127.0.0.1", port)) # the TCP number is free: only the UDP one is taken + finally: + free.close() + threads_before = threading.active_count() + try: + failing = server(port=port) + with self.assertRaises(OSError) as raised: + failing.start() + self.assertEqual(raised.exception.errno, errno.EADDRINUSE) + self.assertIsNone(failing._socket) + self.assertEqual(threading.active_count(), threads_before, "no accept thread was left running") + # The TCP number was released, not left listening. + free = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + free.bind(("127.0.0.1", port)) + finally: + free.close() + finally: + holder.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultmesh-rs/Cargo.lock b/packages/cultmesh-rs/Cargo.lock index 8dfc33ed..de7c506e 100644 --- a/packages/cultmesh-rs/Cargo.lock +++ b/packages/cultmesh-rs/Cargo.lock @@ -52,12 +52,27 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -152,6 +167,18 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -174,7 +201,7 @@ dependencies = [ [[package]] name = "cultcache-rs" -version = "0.2.0" +version = "0.3.1" dependencies = [ "anyhow", "chrono", @@ -185,13 +212,15 @@ dependencies = [ "serde", "serde_bytes", "serde_json", + "serde_stacker", + "stacker", "uuid", "windows-sys 0.59.0", ] [[package]] name = "cultcache-rs-derive" -version = "0.1.0" +version = "0.2.0" dependencies = [ "proc-macro2", "quote", @@ -203,17 +232,20 @@ name = "cultmesh-rs" version = "0.2.0" dependencies = [ "anyhow", + "base64", "cultcache-rs", "cultnet-rs", + "p256", "pretty_assertions", "rmp-serde", "serde", + "serde_json", "tempfile", ] [[package]] name = "cultnet-rs" -version = "0.2.0" +version = "0.3.0" dependencies = [ "aes-gcm", "anyhow", @@ -221,7 +253,9 @@ dependencies = [ "chrono", "cultcache-rs", "ed25519-dalek", + "fs2", "hmac", + "libc", "rand", "rmp-serde", "rmpv", @@ -285,10 +319,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -313,6 +362,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "errno" version = "0.3.14" @@ -329,6 +397,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -383,6 +461,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -429,6 +508,17 @@ dependencies = [ "polyval", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hmac" version = "0.12.1" @@ -521,6 +611,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -533,6 +632,18 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -580,6 +691,15 @@ dependencies = [ "yansi", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -589,6 +709,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quote" version = "1.0.47" @@ -657,6 +787,16 @@ dependencies = [ "libc", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rmp" version = "0.8.15" @@ -715,6 +855,20 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -774,6 +928,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_stacker" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" +dependencies = [ + "serde", + "serde_core", + "stacker", +] + [[package]] name = "sha2" version = "0.10.9" @@ -797,6 +962,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest", "rand_core 0.6.4", ] @@ -826,6 +992,19 @@ dependencies = [ "der", ] +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/packages/cultmesh-rs/Cargo.toml b/packages/cultmesh-rs/Cargo.toml index af75e419..984b74ab 100644 --- a/packages/cultmesh-rs/Cargo.toml +++ b/packages/cultmesh-rs/Cargo.toml @@ -13,9 +13,12 @@ path = "src/lib.rs" anyhow = "1" cultcache-rs = { path = "../cultcache-rs" } cultnet-rs = { path = "../cultnet-rs" } +base64 = "0.22" +p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] } serde = { version = "1", features = ["derive"] } [dev-dependencies] pretty_assertions = "1.4.1" rmp-serde = "1" +serde_json = "1" tempfile = "3" diff --git a/packages/cultmesh-rs/src/authority.rs b/packages/cultmesh-rs/src/authority.rs new file mode 100644 index 00000000..f4368884 --- /dev/null +++ b/packages/cultmesh-rs/src/authority.rs @@ -0,0 +1,424 @@ +//! CultMesh authority route verification. +//! +//! Port of `src/GameCult.Mesh/CultMeshAuthorityProof.cs` (the reference) and of +//! the route constructors in `CultMeshVerse.cs`. Rule for rule, in the same +//! order, the same input yields the same refusal text as +//! `CultMeshAuthorityTrustPolicy.Verify`: unsigned, channel protection, root +//! lookup, validity window, signature. +//! +//! Shared vectors: `contracts/cultmesh/authority-route-vectors.json` (C# written). + +use base64::alphabet::STANDARD as STANDARD_ALPHABET; +use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}; +use base64::Engine; +use p256::ecdsa::signature::Verifier; +use p256::ecdsa::{Signature, VerifyingKey}; +use std::collections::BTreeMap; +use std::fmt; + +const ROUTE_DOMAIN: &str = "gamecult.cultmesh.route-certificate.v1"; +const SESSION_DOMAIN: &str = "gamecult.cultmesh.session-proof.v1"; + +// `Convert.FromBase64String`: standard alphabet, padded, tolerant of trailing bits. +const DOTNET_BASE64: GeneralPurpose = GeneralPurpose::new( + &STANDARD_ALPHABET, + GeneralPurposeConfig::new() + .with_decode_padding_mode(DecodePaddingMode::RequireCanonical) + .with_decode_allow_trailing_bits(true), +); + +fn base64_decode(value: &str) -> Option> { + let stripped: String = value.chars().filter(|c| !matches!(c, ' ' | '\t' | '\r' | '\n')).collect(); + DOTNET_BASE64.decode(stripped).ok() +} + +/// .NET `char.IsWhiteSpace` is the Unicode White_Space property, as is `char::is_whitespace`. +fn trim(value: &str) -> &str { + value.trim_matches(char::is_whitespace) +} + +fn is_blank(value: &str) -> bool { + trim(value).is_empty() +} + +fn require(value: &str, name: &str) -> Result { + if is_blank(value) { + Err(format!("Value must be non-empty. (Parameter '{name}')")) + } else { + Ok(trim(value).to_owned()) + } +} + +/// A route or proof the consumer's trust policy refuses. Mirrors +/// `CultMeshSessionFailure` with reason `Authentication`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorityRouteRefusal { + pub message: String, + pub endpoint: String, +} + +impl AuthorityRouteRefusal { + pub const REASON: &'static str = "authentication"; +} + +impl fmt::Display for AuthorityRouteRefusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for AuthorityRouteRefusal {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct P256PublicKey { + pub key_id: String, + pub x: String, + pub y: String, +} + +impl P256PublicKey { + pub fn new(key_id: &str, x: &str, y: &str) -> Result { + let key = Self { + key_id: require(key_id, "keyId")?, + x: require(x, "x")?, + y: require(y, "y")?, + }; + for (name, value) in [("x", &key.x), ("y", &key.y)] { + if base64_decode(value).is_none() { + return Err(format!("Coordinate must be base64. (Parameter '{name}')")); + } + } + Ok(key) + } + + /// IEEE P1363 (r || s) ECDSA-SHA256 verification; false on any malformation. + pub fn verify(&self, payload: &[u8], signature_base64: &str) -> bool { + let (Some(x), Some(y), Some(signature)) = ( + base64_decode(&self.x), + base64_decode(&self.y), + base64_decode(signature_base64), + ) else { + return false; + }; + if x.len() != 32 || y.len() != 32 || signature.len() != 64 { + return false; + } + let mut sec1 = Vec::with_capacity(65); + sec1.push(4); + sec1.extend_from_slice(&x); + sec1.extend_from_slice(&y); + let (Ok(key), Ok(signature)) = ( + VerifyingKey::from_sec1_bytes(&sec1), + Signature::from_slice(&signature), + ) else { + return false; + }; + key.verify(payload, &signature).is_ok() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RouteCertificate { + pub provider_key: P256PublicKey, + pub odin_key_id: String, + pub issued_at_unix_milliseconds: i64, + pub expires_at_unix_milliseconds: i64, + pub signature: String, +} + +impl RouteCertificate { + pub fn new( + provider_key: P256PublicKey, + odin_key_id: &str, + issued_at_unix_milliseconds: i64, + expires_at_unix_milliseconds: i64, + signature: &str, + ) -> Result { + if is_blank(odin_key_id) { + return Err("Odin key identity is required. (Parameter 'odinKeyId')".into()); + } + if issued_at_unix_milliseconds < 0 { + return Err("issuedAtUnixMilliseconds must not be negative.".into()); + } + if expires_at_unix_milliseconds <= issued_at_unix_milliseconds { + return Err("expiresAtUnixMilliseconds must be after issuedAtUnixMilliseconds.".into()); + } + Ok(Self { + provider_key, + odin_key_id: trim(odin_key_id).to_owned(), + issued_at_unix_milliseconds, + expires_at_unix_milliseconds, + signature: trim(signature).to_owned(), + }) + } +} + +/// `CultMeshAuthorityRoute`: construction cleans every field as the C# does. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorityRoute { + pub authority_runtime_id: String, + pub endpoint: String, + pub protocol_ids: Vec, + pub priority: i32, + pub generation: String, + pub certificate: Option, +} + +impl AuthorityRoute { + pub fn new( + authority_runtime_id: &str, + endpoint: &str, + protocol_ids: &[&str], + priority: i32, + generation: Option<&str>, + certificate: Option, + ) -> Result { + let authority_runtime_id = require(authority_runtime_id, "authorityRuntimeId")?; + let endpoint = require(endpoint, "endpoint")?; + if priority < 0 { + return Err("priority must not be negative.".into()); + } + let mut protocols: Vec = protocol_ids + .iter() + .filter(|value| !is_blank(value)) + .map(|value| trim(value).to_owned()) + .collect(); + // StringComparer.Ordinal is UTF-16 code-unit order. + protocols.sort_by_key(|value| value.encode_utf16().collect::>()); + protocols.dedup(); + let generation = match generation { + Some(value) if !is_blank(value) => trim(value).to_owned(), + _ => format!("{authority_runtime_id}@{endpoint}"), + }; + Ok(Self { + authority_runtime_id, + endpoint, + protocol_ids: protocols, + priority, + generation, + certificate, + }) + } +} + +/// The fields of `CultMeshSessionOpenMessage` the session proof covers. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SessionOpen { + pub client_nonce: String, + pub message_id: String, + pub source_runtime_id: String, + pub verse_id: String, + pub authority_runtime_id: String, + pub protocol_id: String, + pub route_generation: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SessionAccepted { + pub client_nonce: String, + pub provider_key_id: String, + pub provider_signature: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TrustMode { + AuthenticatedRemote, + LocalDevelopment, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedRoute { + pub route: AuthorityRoute, + pub provider_key: Option, + pub is_local_development: bool, +} + +pub fn canonical_fields(values: &[&str]) -> Vec { + let mut out = Vec::new(); + for value in values { + out.extend_from_slice(&(value.len() as u32).to_be_bytes()); + out.extend_from_slice(value.as_bytes()); + } + out +} + +pub fn canonical_route(verse_id: &str, route: &AuthorityRoute) -> Result, String> { + let certificate = route.certificate.as_ref().ok_or("Route certificate is required.")?; + let protocols = route.protocol_ids.join("\u{1f}"); + let priority = route.priority.to_string(); + let issued = certificate.issued_at_unix_milliseconds.to_string(); + let expires = certificate.expires_at_unix_milliseconds.to_string(); + Ok(canonical_fields(&[ + ROUTE_DOMAIN, + verse_id, + &route.authority_runtime_id, + &route.endpoint, + &protocols, + &priority, + &route.generation, + &certificate.provider_key.key_id, + &certificate.provider_key.x, + &certificate.provider_key.y, + &certificate.odin_key_id, + &issued, + &expires, + ])) +} + +pub fn canonical_session(request: &SessionOpen, endpoint: &str) -> Vec { + canonical_fields(&[ + SESSION_DOMAIN, + &request.client_nonce, + &request.message_id, + &request.source_runtime_id, + &request.verse_id, + &request.authority_runtime_id, + &request.protocol_id, + endpoint, + &request.route_generation, + ]) +} + +/// `(scheme, lowercase host)`, or `None` where `System.Uri` refuses the spelling. +/// A lenient parse could call a refused endpoint loopback or protected, so the +/// shape rule is the same strict one the TypeScript port applies. +fn split_endpoint(value: &str) -> Option<(String, String)> { + let raw = value.trim_matches(|c: char| c <= ' '); + if raw.contains('\\') { + return None; + } + let (scheme, rest) = raw.split_once("://")?; + let mut scheme_chars = scheme.chars(); + if !scheme_chars.next()?.is_ascii_alphabetic() + || !scheme_chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + { + return None; + } + let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())]; + if authority.is_empty() { + return None; + } + let after_user = &authority[authority.rfind('@').map_or(0, |i| i + 1)..]; + let host = if after_user.starts_with('[') { + &after_user[..after_user.find(']').map_or(after_user.len(), |i| i + 1)] + } else { + after_user.split(':').next().unwrap_or("") + }; + if host.is_empty() || host.chars().any(|c| !('\u{21}'..='\u{7e}').contains(&c)) || host.ends_with('.') { + return None; + } + Some(( + scheme.to_ascii_lowercase(), + host.trim_matches(['[', ']']).to_ascii_lowercase(), + )) +} + +pub fn is_loopback_endpoint(endpoint: &str) -> bool { + let Some((_, host)) = split_endpoint(endpoint) else { + return false; + }; + let octets: Vec<&str> = host.split('.').collect(); + matches!(host.as_str(), "localhost" | "loopback" | "::1" | "::ffff:127.0.0.1") + || (octets.len() == 4 + && octets[0] == "127" + && octets[1..].iter().all(|o| (1..=3).contains(&o.len()) && o.bytes().all(|b| b.is_ascii_digit()))) +} + +pub fn is_protected_endpoint(endpoint: &str) -> bool { + split_endpoint(endpoint) + .is_some_and(|(scheme, _)| scheme == "wss" || scheme == "https" || scheme.contains("quic")) +} + +/// Consumer-owned policy. Fails closed unless a route is valid under it. +pub struct AuthorityTrustPolicy { + mode: TrustMode, + roots: BTreeMap, +} + +impl AuthorityTrustPolicy { + pub fn new(mode: TrustMode, odin_roots: Vec) -> Result { + let mut roots = BTreeMap::new(); + for root in odin_roots { + let key_id = root.key_id.clone(); + if roots.insert(key_id.clone(), root).is_some() { + return Err(format!( + "Odin root key id '{key_id}' is listed more than once in the trust policy." + )); + } + } + Ok(Self { mode, roots }) + } + + pub fn validate(&self, verse_id: &str, route: &AuthorityRoute, now_unix_milliseconds: i64) -> Result<(), AuthorityRouteRefusal> { + self.verify(verse_id, route, now_unix_milliseconds).map(|_| ()) + } + + pub fn verify(&self, verse_id: &str, route: &AuthorityRoute, now_unix_milliseconds: i64) -> Result { + let refuse = |message: &str| AuthorityRouteRefusal { + message: message.to_owned(), + endpoint: route.endpoint.clone(), + }; + let local = self.mode == TrustMode::LocalDevelopment && is_loopback_endpoint(&route.endpoint); + let certificate = match &route.certificate { + Some(certificate) if !is_blank(&certificate.signature) => certificate, + _ if local => { + return Ok(VerifiedRoute { route: route.clone(), provider_key: None, is_local_development: true }); + } + _ => return Err(refuse("Remote CultMesh routes require an Odin-signed authority certificate.")), + }; + if !is_protected_endpoint(&route.endpoint) && !local { + return Err(refuse("Authenticated remote CultMesh routes require TLS or QUIC channel protection.")); + } + let Some(root) = self.roots.get(&certificate.odin_key_id) else { + return Err(refuse(&format!( + "Odin key '{}' is not trusted by this consumer.", + certificate.odin_key_id + ))); + }; + if now_unix_milliseconds < certificate.issued_at_unix_milliseconds + || now_unix_milliseconds >= certificate.expires_at_unix_milliseconds + { + return Err(refuse("The Odin route certificate is not currently valid.")); + } + let Some(signature) = base64_decode(&certificate.signature) else { + return Err(refuse("The Odin route signature is not base64.")); + }; + if signature.len() != 64 { + return Err(refuse("The Odin route signature is not IEEE P1363 P-256.")); + } + let canonical = canonical_route(verse_id, route).map_err(|message| refuse(&message))?; + if !root.verify(&canonical, &certificate.signature) { + return Err(refuse("The Odin route certificate signature is invalid.")); + } + Ok(VerifiedRoute { + route: route.clone(), + provider_key: Some(certificate.provider_key.clone()), + is_local_development: false, + }) + } +} + +/// Verifies the Odin route certificate (refusing) and the provider nonce proof. +pub fn verify_session_proof( + request: &SessionOpen, + response: &SessionAccepted, + verse_id: &str, + route: &AuthorityRoute, + trust: &AuthorityTrustPolicy, + now_unix_milliseconds: i64, +) -> Result { + let verified = trust.verify(verse_id, route, now_unix_milliseconds)?; + if verified.is_local_development { + return Ok(true); + } + let Some(key) = verified.provider_key else { + return Ok(false); + }; + if response.provider_key_id != key.key_id + || response.client_nonce != request.client_nonce + || is_blank(&response.provider_signature) + { + return Ok(false); + } + Ok(key.verify(&canonical_session(request, &route.endpoint), &response.provider_signature)) +} diff --git a/packages/cultmesh-rs/src/lib.rs b/packages/cultmesh-rs/src/lib.rs index 6965e2d5..d7c2c8fd 100644 --- a/packages/cultmesh-rs/src/lib.rs +++ b/packages/cultmesh-rs/src/lib.rs @@ -25,6 +25,7 @@ use std::time::Duration; use std::time::Instant; mod rudp_document_server; +pub mod authority; pub use rudp_document_server::*; @@ -62,7 +63,7 @@ macro_rules! cultmesh_documents { $schema_version.to_string(), $schema_version.to_string(), ), - ); + )?; )* Ok(()) } @@ -864,6 +865,7 @@ pub fn request_raw_snapshot_from_rudp_catalog( /// fixed refusals a CultMesh catalog sends; any other text could carry /// anything, so it is left out. fn recognised_refusal(text: &str) -> String { + let text = text.strip_prefix("write_not_authorized: ").unwrap_or(text); if REFUSAL_TEXTS.contains(&text) { format!(": {text}") } else { diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index 09c31adf..817e9b5c 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -1,6 +1,6 @@ use anyhow::{Result, anyhow}; use cultnet_rs::{ - CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType, + CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract, decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec, encode_rudp_packet, is_permanent_send_error, send_error_code, @@ -316,14 +316,50 @@ impl CultMeshRudpRejectionReason { } } +/// The typed `write_not_authorized` refusal for a put whose record is `record_key`, as C# +/// `CultNetErrorMessage.ForRemoteWriteRefused` builds it: code, and `details` naming the record. +pub(crate) fn write_refused_message(record_key: &str, reason: &str) -> CultNetMessage { + CultNetMessage::Error { + error: format!("write_not_authorized: {reason}"), + code: Some(CultNetErrorCode::WriteNotAuthorized), + details: Some(CultNetErrorDetails { + field: Some("recordKey".into()), + value: Some(record_key.into()), + as_of: None, + current: None, + }), + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CultMeshRudpApplicationRejection { pub session: CultMeshRudpSessionKey, pub operation: CultMeshRudpApplicationOperation, pub message_id: String, + /// The record a rejected put concerned; `None` for a snapshot request. A sink refusal names it to the peer. + pub record_key: Option, pub reason: CultMeshRudpRejectionReason, } +impl CultMeshRudpApplicationRejection { + /// What the refused peer is sent: a sink's refusal of a put is a typed `write_not_authorized` naming + /// the record; every other rejection is the variant's fixed text, untyped. Neither carries the + /// sink's or source's own text. + fn refusal_message(&self) -> CultNetMessage { + let text = self.reason.refusal_text(); + match (&self.reason, &self.record_key) { + (CultMeshRudpRejectionReason::SinkRefused(_), Some(record_key)) => { + write_refused_message(record_key, text) + } + _ => CultNetMessage::Error { + error: text.into(), + code: None, + details: None, + }, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum CultMeshRudpPollOutcome { Idle, @@ -335,8 +371,8 @@ pub enum CultMeshRudpPollOutcome { enum SessionEnd { /// The session refused a packet. Refused, - /// The server rejected a peer's message; the peer is sent this fixed text. - Rejected(&'static str), + /// The server rejected a peer's message; the peer is sent this refusal. + Rejected(CultNetMessage), /// The session owes its peer a packet that can never be sent as built. Unsendable(std::io::Error), } @@ -555,7 +591,7 @@ where rejection.reason, CultMeshRudpRejectionReason::ResponseSendFailed(_) ) { - self.end_session(key, SessionEnd::Rejected(rejection.reason.refusal_text()))?; + self.end_session(key, SessionEnd::Rejected(rejection.refusal_message()))?; } return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); } @@ -683,11 +719,13 @@ where message_id, document, } => { + let record_key = document.record_key.clone(); let reject = |reason| { Ok(Some(CultMeshRudpApplicationRejection { session: key, operation: CultMeshRudpApplicationOperation::DocumentPutRaw, message_id: message_id.clone(), + record_key: Some(record_key.clone()), reason, })) }; @@ -758,6 +796,7 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, + record_key: None, reason: CultMeshRudpRejectionReason::SnapshotSourceFailed(format!( "{error:#}" )), @@ -769,6 +808,7 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, + record_key: None, reason: CultMeshRudpRejectionReason::SnapshotTooManyDocuments { documents: documents.len(), max_snapshot_documents: self.options.max_snapshot_documents, @@ -786,6 +826,7 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, + record_key: None, reason: CultMeshRudpRejectionReason::ResponseEncodingFailed(error), })); } @@ -795,6 +836,7 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, + record_key: None, reason: CultMeshRudpRejectionReason::SnapshotResponseTooLarge { response_bytes: payload.len(), max_snapshot_response_bytes: self.options.max_snapshot_response_bytes, @@ -806,6 +848,7 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, + record_key: None, reason: CultMeshRudpRejectionReason::PayloadBudgetFull, })); } @@ -832,6 +875,7 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, + record_key: None, reason: CultMeshRudpRejectionReason::ResponseQueueFailed(format!( "{error:#}" )), @@ -848,6 +892,7 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, + record_key: None, reason: CultMeshRudpRejectionReason::ResponseSendFailed(code), })); } @@ -875,7 +920,7 @@ where }; let (refusal, mut unsendable) = match end { SessionEnd::Refused => (Vec::new(), None), - SessionEnd::Rejected(text) => (self.refusal_packets(&mut entry.session, text), None), + SessionEnd::Rejected(refusal) => (self.refusal_packets(&mut entry.session, &refusal), None), SessionEnd::Unsendable(error) => (Vec::new(), Some(error)), }; entry.session.reset_peer_state(); @@ -902,14 +947,9 @@ where fn refusal_packets( &self, session: &mut CultNetRudpSession, - text: &str, + refusal: &CultNetMessage, ) -> Vec { - let refusal = CultNetMessage::Error { - error: text.into(), - code: None, - details: None, - }; - encode_cultnet_message_to_vec(&refusal, CultNetWireContract::CultNetSchemaV0) + encode_cultnet_message_to_vec(refusal, CultNetWireContract::CultNetSchemaV0) .and_then(|payload| { session.send_many( "schema", @@ -1080,3 +1120,26 @@ fn encode_snapshot_response(response: &CultNetMessage) -> std::result::Result u64 { duration.as_millis().try_into().unwrap_or(u64::MAX) } + +#[cfg(test)] +mod tests { + use super::*; + + // contracts/cultnet/interop/error-vectors.json, `write_not_authorized`: bytes captured from C# MessagePack + // (`ForRemoteWriteRefused(request for "some:key", "not granted")`) and pinned by C#, TS and Python too. + const WRITE_NOT_AUTHORIZED_HEX: &str = "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0"; + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02X}")).collect() + } + + #[test] + fn the_sink_refusal_is_the_pinned_write_not_authorized_bytes() { + let bytes = encode_cultnet_message_to_vec( + &write_refused_message("some:key", "not granted"), + CultNetWireContract::CultNetSchemaV0, + ) + .unwrap(); + assert_eq!(hex(&bytes), WRITE_NOT_AUTHORIZED_HEX); + } +} diff --git a/packages/cultmesh-rs/tests/authority_route_vectors.rs b/packages/cultmesh-rs/tests/authority_route_vectors.rs new file mode 100644 index 00000000..40cc5bba --- /dev/null +++ b/packages/cultmesh-rs/tests/authority_route_vectors.rs @@ -0,0 +1,261 @@ +//! Judges `contracts/cultmesh/authority-route-vectors*.json` in Rust. +//! +//! `authority-route-vectors.json` is written by the C# reference (its signatures +//! are captured C# bytes); the `.ts-signed.json` file is signed by TypeScript +//! over awkward inputs. Each negative case fails when the matching check is +//! removed from `cultmesh_rs::authority`. + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use cultmesh_rs::authority::*; +use p256::ecdsa::Signature; +use serde_json::Value; +use std::path::PathBuf; + +struct Vector { + now: i64, + root: P256PublicKey, + verse_id: String, + route: AuthorityRoute, + request: SessionOpen, + accepted: SessionAccepted, + trust: AuthorityTrustPolicy, +} + +fn s<'a>(value: &'a Value, key: &str) -> &'a str { + value[key].as_str().unwrap_or_else(|| panic!("missing string {key}")) +} + +fn key(value: &Value) -> P256PublicKey { + P256PublicKey::new(s(value, "keyId"), s(value, "x"), s(value, "y")).unwrap() +} + +fn load(name: &str) -> Vector { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../contracts/cultmesh") + .join(name); + let doc: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + let r = &doc["route"]; + let c = &r["certificate"]; + let certificate = RouteCertificate::new( + key(&c["providerKey"]), + s(c, "odinKeyId"), + c["issuedAtUnixMilliseconds"].as_i64().unwrap(), + c["expiresAtUnixMilliseconds"].as_i64().unwrap(), + s(c, "signature"), + ) + .unwrap(); + let protocols: Vec<&str> = r["protocolIds"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); + let route = AuthorityRoute::new( + s(r, "authorityRuntimeId"), + s(r, "endpoint"), + &protocols, + r["priority"].as_i64().unwrap() as i32, + Some(s(r, "generation")), + Some(certificate), + ) + .unwrap(); + let p = &doc["sessionProof"]; + let q = &p["request"]; + let root = key(&doc["odinRoot"]); + Vector { + now: doc["nowUnixMilliseconds"].as_i64().unwrap(), + verse_id: s(r, "verseId").to_owned(), + route, + request: SessionOpen { + client_nonce: s(q, "clientNonce").into(), + message_id: s(q, "messageId").into(), + source_runtime_id: s(q, "sourceRuntimeId").into(), + verse_id: s(q, "verseId").into(), + authority_runtime_id: s(q, "authorityRuntimeId").into(), + protocol_id: s(q, "protocolId").into(), + route_generation: s(q, "routeGeneration").into(), + }, + accepted: SessionAccepted { + client_nonce: s(q, "clientNonce").into(), + provider_key_id: s(p, "providerKeyId").into(), + provider_signature: s(p, "providerSignature").into(), + }, + trust: AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![root.clone()]).unwrap(), + root, + } +} + +impl Vector { + fn certificate(&self) -> &RouteCertificate { + self.route.certificate.as_ref().unwrap() + } + fn with_certificate(&self, edit: impl FnOnce(&mut RouteCertificate)) -> AuthorityRoute { + let mut route = self.route.clone(); + edit(route.certificate.as_mut().unwrap()); + route + } + fn refusal(&self, route: &AuthorityRoute, now: i64) -> String { + let error = self.trust.validate(&self.verse_id, route, now).expect_err("route must be refused"); + assert_eq!(AuthorityRouteRefusal::REASON, "authentication"); + assert_eq!(route.endpoint, error.endpoint); + error.message + } + fn proof(&self, request: &SessionOpen, accepted: &SessionAccepted) -> bool { + verify_session_proof(request, accepted, &self.verse_id, &self.route, &self.trust, self.now).unwrap() + } +} + +const INVALID: &str = "The Odin route certificate signature is invalid."; + +#[test] +fn cs_written_vector_verifies_route_and_session_proof() { + let v = load("authority-route-vectors.json"); + v.trust.validate(&v.verse_id, &v.route, v.now).unwrap(); + assert!(v.proof(&v.request, &v.accepted)); +} + +#[test] +fn ts_signed_vector_verifies_and_tamper_is_refused() { + let v = load("authority-route-vectors.ts-signed.json"); + v.trust.validate(&v.verse_id, &v.route, v.now).unwrap(); + assert!(v.proof(&v.request, &v.accepted)); + let mut tampered = v.route.clone(); + tampered.priority += 1; + assert_eq!(INVALID, v.refusal(&tampered, v.now)); +} + +#[test] +fn canonical_route_is_length_prefixed_and_sorted() { + let v = load("authority-route-vectors.json"); + let bytes = canonical_route(&v.verse_id, &v.route).unwrap(); + assert!(bytes.starts_with(b"\x00\x00\x00\x26gamecult.cultmesh.route-certificate.v1\x00\x00\x00\x08aetheria")); + let needle = b"cultmesh.content.v1\x1fcultmesh.documents.v1"; + assert!(bytes.windows(needle.len()).any(|w| w == needle)); +} + +#[test] +fn tampered_payload_fields_are_refused() { + let v = load("authority-route-vectors.json"); + let edits: Vec> = vec![ + Box::new(|r| r.priority = 8), + Box::new(|r| r.endpoint = "wss://evil.example/mesh".into()), + Box::new(|r| r.protocol_ids = vec!["cultmesh.documents.v1".into()]), + Box::new(|r| r.generation = "other".into()), + Box::new(|r| r.certificate.as_mut().unwrap().expires_at_unix_milliseconds += 1), + ]; + for edit in edits { + let mut route = v.route.clone(); + edit(&mut route); + assert_eq!(INVALID, v.refusal(&route, v.now)); + } + let error = v.trust.validate("other-verse", &v.route, v.now).unwrap_err(); + assert_eq!(INVALID, error.message); +} + +#[test] +fn wrong_root_key_is_refused() { + let v = load("authority-route-vectors.json"); + // The provider's key is a real P-256 key that did not sign this route. + let provider = &v.certificate().provider_key; + let impostor = P256PublicKey::new(&v.root.key_id, &provider.x, &provider.y).unwrap(); + let trust = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![impostor]).unwrap(); + assert_eq!(INVALID, trust.validate(&v.verse_id, &v.route, v.now).unwrap_err().message); +} + +#[test] +fn untrusted_odin_key_id_is_refused() { + let v = load("authority-route-vectors.json"); + let other = P256PublicKey::new("someone-else", &v.root.x, &v.root.y).unwrap(); + let trust = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![other]).unwrap(); + assert_eq!( + "Odin key 'odin-vector' is not trusted by this consumer.", + trust.validate(&v.verse_id, &v.route, v.now).unwrap_err().message + ); +} + +#[test] +fn expired_and_not_yet_valid_are_refused() { + let v = load("authority-route-vectors.json"); + let window = "The Odin route certificate is not currently valid."; + let (issued, expires) = (v.certificate().issued_at_unix_milliseconds, v.certificate().expires_at_unix_milliseconds); + v.trust.validate(&v.verse_id, &v.route, expires - 1).unwrap(); + v.trust.validate(&v.verse_id, &v.route, issued).unwrap(); + assert_eq!(window, v.refusal(&v.route, expires)); + assert_eq!(window, v.refusal(&v.route, issued - 1)); +} + +#[test] +fn unsigned_and_unprotected_routes_are_refused() { + let v = load("authority-route-vectors.json"); + let unsigned_message = "Remote CultMesh routes require an Odin-signed authority certificate."; + let mut unsigned = v.route.clone(); + unsigned.certificate = None; + assert_eq!(unsigned_message, v.refusal(&unsigned, v.now)); + let blank = v.with_certificate(|c| c.signature = " ".into()); + assert_eq!(unsigned_message, v.refusal(&blank, v.now)); + let mut plain = v.route.clone(); + plain.endpoint = "ws://provider.example/mesh".into(); + assert_eq!( + "Authenticated remote CultMesh routes require TLS or QUIC channel protection.", + v.refusal(&plain, v.now) + ); +} + +#[test] +fn local_development_admits_only_unsigned_loopback() { + let v = load("authority-route-vectors.json"); + let dev = AuthorityTrustPolicy::new(TrustMode::LocalDevelopment, vec![]).unwrap(); + let route = |endpoint: &str| AuthorityRoute::new("a", endpoint, &[], 0, None, None).unwrap(); + let loopback = route("ws://127.0.0.1:9/mesh"); + dev.validate(&v.verse_id, &loopback, v.now).unwrap(); + assert!(verify_session_proof(&v.request, &v.accepted, &v.verse_id, &loopback, &dev, v.now).unwrap()); + for endpoint in ["ws://provider.example/mesh", "ws://127.0.0.1./mesh", "ws:127.0.0.1"] { + assert!(dev.validate(&v.verse_id, &route(endpoint), v.now).is_err(), "{endpoint}"); + } + let remote = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![]).unwrap(); + assert!(remote.validate(&v.verse_id, &loopback, v.now).is_err()); +} + +#[test] +fn malformed_signatures_get_their_own_refusals() { + let v = load("authority-route-vectors.json"); + let not_base64 = v.with_certificate(|c| c.signature = "!!not base64!!".into()); + assert_eq!("The Odin route signature is not base64.", v.refusal(¬_base64, v.now)); + let short = v.with_certificate(|c| c.signature = STANDARD.encode([b'x'; 63])); + assert_eq!("The Odin route signature is not IEEE P1363 P-256.", v.refusal(&short, v.now)); +} + +#[test] +fn high_s_signature_verifies_as_it_does_in_dotnet() { + let v = load("authority-route-vectors.json"); + let raw = STANDARD.decode(&v.certificate().signature).unwrap(); + let signature = Signature::from_slice(&raw).unwrap(); + let flipped = Signature::from_scalars(*signature.r().as_ref(), -*signature.s().as_ref()).unwrap(); + let high = v.with_certificate(|c| c.signature = STANDARD.encode(flipped.to_bytes())); + v.trust.validate(&v.verse_id, &high, v.now).unwrap(); +} + +#[test] +fn session_proof_rejects_replay_wrong_key_and_forgery() { + let v = load("authority-route-vectors.json"); + let mut replayed = v.request.clone(); + replayed.client_nonce = "replayed".into(); + let mut accepted = v.accepted.clone(); + accepted.client_nonce = "replayed".into(); + assert!(!v.proof(&replayed, &accepted)); + let mut wrong_id = v.accepted.clone(); + wrong_id.provider_key_id = "other".into(); + assert!(!v.proof(&v.request, &wrong_id)); + let mut wrong_nonce = v.accepted.clone(); + wrong_nonce.client_nonce = "other".into(); + assert!(!v.proof(&v.request, &wrong_nonce)); + let mut empty = v.accepted.clone(); + empty.provider_signature = String::new(); + assert!(!v.proof(&v.request, &empty)); + let mut raw = STANDARD.decode(&v.accepted.provider_signature).unwrap(); + raw[0] ^= 1; + let mut forged = v.accepted.clone(); + forged.provider_signature = STANDARD.encode(raw); + assert!(!v.proof(&v.request, &forged)); + let mut moved = v.request.clone(); + moved.protocol_id = "cultmesh.content.v1".into(); + assert!(!v.proof(&moved, &v.accepted)); + let expires = v.certificate().expires_at_unix_milliseconds; + assert!(verify_session_proof(&v.request, &v.accepted, &v.verse_id, &v.route, &v.trust, expires).is_err()); +} diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index 30fe4678..8d0c864c 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -6,7 +6,7 @@ use cultmesh_rs::{ CultMeshRudpSnapshotQuery, CultMeshRudpSnapshotSource, }; use cultnet_rs::{ - CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, + CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetRudpReliableSendReceipt, CultNetRudpReliableSendStatus, CultNetRudpSocketMode, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract, decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, @@ -343,6 +343,15 @@ fn refusal_seen_by( client: &mut CultNetRudpSocketTransportConnection, receipt: &CultNetRudpReliableSendReceipt, ) -> Result { + let (error, code, details) = typed_refusal_seen_by(client, receipt)?; + assert_eq!((code, details), (None, None)); + Ok(error) +} + +fn typed_refusal_seen_by( + client: &mut CultNetRudpSocketTransportConnection, + receipt: &CultNetRudpReliableSendReceipt, +) -> Result<(String, Option, Option)> { let mut refusal = None; for _ in 0..2_000 { if let Some(frame) = client.receive_once()? { @@ -362,7 +371,6 @@ fn refusal_seen_by( else { panic!("the refused peer was sent no refusal: {refusal:?}"); }; - assert_eq!((code, details), (None, None)); assert!( !error.contains("CANARY"), "a sink's or source's text reached the peer: {error}" @@ -384,7 +392,7 @@ fn refusal_seen_by( client.reliable_send_status(receipt), CultNetRudpReliableSendStatus::Invalidated ); - Ok(error) + Ok((error, code, details)) } /// Reads every datagram waiting at the server, such as a refused peer's @@ -671,10 +679,21 @@ fn application_rejection_is_nonfatal_peer_scoped_refused_to_the_peer_and_unackno ); assert_eq!(server.session_count(), 2); // The caller keeps the sink's text; the peer is sent fixed text, never it. + // A refused put is typed write_not_authorized and names the record; nothing of it reached the sink. assert_eq!( - refusal_seen_by(&mut publisher, &publish_receipt)?, - "the catalog refused the document" + typed_refusal_seen_by(&mut publisher, &publish_receipt)?, + ( + "write_not_authorized: the catalog refused the document".to_string(), + Some(CultNetErrorCode::WriteNotAuthorized), + Some(CultNetErrorDetails { + field: Some("recordKey".into()), + value: Some(document("rejected", vec![0, 255, 1]).record_key), + as_of: None, + current: None, + }) + ) ); + assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "a refused put changes nothing"); drain_until_idle(&mut server)?; let snapshot_receipt = send_reliable( @@ -1525,9 +1544,14 @@ fn a_refusal_reaches_a_peer_missing_an_earlier_packet() -> Result<()> { assert_eq!( refusals, vec![CultNetMessage::Error { - error: "the catalog refused the document".into(), - code: None, - details: None, + error: "write_not_authorized: the catalog refused the document".into(), + code: Some(CultNetErrorCode::WriteNotAuthorized), + details: Some(CultNetErrorDetails { + field: Some("recordKey".into()), + value: Some(document("refused", vec![4, 5, 6]).record_key), + as_of: None, + current: None, + }), }] ); assert!(disconnected, "the refusal is followed by a goodbye"); diff --git a/packages/cultmesh-ts/CHANGELOG.md b/packages/cultmesh-ts/CHANGELOG.md index 4a43ec61..1325b349 100644 --- a/packages/cultmesh-ts/CHANGELOG.md +++ b/packages/cultmesh-ts/CHANGELOG.md @@ -1,8 +1,27 @@ # Changelog -All notable changes to `cultmesh-ts` are recorded here, following +All notable changes to `@gamecult/cultmesh-ts` are recorded here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## Unreleased + +### Breaking + +- The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`. +- `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean. +- `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `` and yield `CultMeshCatalogValue`: an explicit type argument wins, a CultCache document definition names its own value type, and a bare schema descriptor yields `unknown`. A value type is no longer inferred from a value or an expected result. The `parse` option is removed from these calls, and a lookup by type answers with that type's handle or none, not an alias of a compatible schema. +- `createRudpDocumentServer` refuses every peer put and delete unless it is given a `writeAuthority`. A refused write is answered with the typed `write_not_authorized` naming the record key (the bytes pinned in `error-vectors.json`), and `onDocumentPutRaw` is never called; pass `writeAuthority: new CultMeshGrantAllWrites()` to keep the old accept-everything behaviour. The authority is asked before the put is sized or decoded and before any handler runs, with the canonical schema id and the peer's address (the schema id is resolved to its canonical form first, which is the timing residual below); one that throws, rejects or answers anything but `granted: true` refuses. A frame the server cannot read is judged as the write it looks like (a put when it does not decode at all), so an ungranted peer gets `write_not_authorized` for it too; a granted peer gets `onError` and a `malformed_document_put` or `malformed_document_delete` refusal. A put carrying a wrong-typed `shardId` or `shardEpoch` is judged with null shard fields and, once granted, refused with `malformed_document_put`. A refusal's record key and reason are clipped (64 and 96 UTF-8 bytes) so the refusal is at most 309 bytes; a `maxPayloadBytes` below that cannot carry it, and the peer then hears nothing (the failure goes to `onError`). Residual: ungranted peers can still distinguish registered from unregistered schemas by timing. A peer `document_delete` now reaches the new `onDocumentDeleteRaw` once granted (without it, a text refusal) instead of the unsupported-request error. +- Adding a document or collection to a catalog under a schema id or schema name another type already holds is refused with `SchemaConflictError`. + +### Added + +- `CultMeshCatalogValue`, exported. +- `documentById` and `tryDocumentById` on `CultMeshDocumentCatalog`, which address one document by its document id when a catalog holds several documents of one type. + +### Dependencies + +- Needs the cultcache-ts release that ships with this change (its manifest range moves with that release): it imports `schemaIdentityOf` and `SchemaConflictError`, which 0.14.x does not export. + ## [0.2.0] ### Added diff --git a/packages/cultmesh-ts/README.md b/packages/cultmesh-ts/README.md index de7b2794..32ec64ed 100644 --- a/packages/cultmesh-ts/README.md +++ b/packages/cultmesh-ts/README.md @@ -1,6 +1,6 @@ # CultMesh TS -`cultmesh-ts` is the TypeScript projection of CultMesh contracts for local +`@gamecult/cultmesh-ts` is the TypeScript projection of CultMesh contracts for local tools and browser-adjacent runtimes. The C# `GameCult.Mesh` package owns the native CultMesh stream implementation used by Mimir and Fensalir; this package mirrors the typed declaration and negotiation shapes without owning the hot @@ -21,7 +21,7 @@ WASM route. Runtime code can ask for a whole XY rect, then adapt it to the current wire schema at the edge: ```ts -import { CultMesh } from "cultmesh-ts"; +import { CultMesh } from "@gamecult/cultmesh-ts"; const viewport = CultMesh.rectFromBounds(-500, -300, 500, 300); const request = CultMesh.viewportRequest(viewport, [3, 8]); @@ -87,16 +87,16 @@ await input.submitPrediction("pilot-a", predictedInput); const stop = input.watch("pilot-a", latest => reconcileInput(latest)); ``` -Durable nodes resolve same-schema aliases at the boundary. Register the -canonical document that owns storage and replication, then ask the node for the -typed view the current runtime wants: +Durable nodes hand out typed handles for the documents they register. One type +owns each schema: a node refuses a definition it did not register, and refuses +to register a second type for a schema it already holds. ```ts const station = await CultMesh.startNode(statePath, { - documents: [stationStockDocument], + documents: [stationStockDocument, daemonHealthDocument], }); -const stock = station.document(stationStockUiDocument, "station:starbridge:stock"); +const stock = station.document(stationStockDocument, "station:starbridge:stock"); const current = await stock.latest(); await stock.authoritativeWriter().write(updatedStockFromUi); @@ -108,6 +108,20 @@ reactive.update(draft => { }); ``` +`node.get`, `node.getRequired` and the value `node.put` resolves to return the +object the node's cache holds, not a copy: two reads of one record return the +same object. That object belongs to the cache and must not be mutated. Mutating +it changes what later reads see without writing anything to the store. To +change a document, build a new value (or use a writer) and put it: + +```ts +const held = station.getRequired(stationStockDocument, "station:starbridge:stock"); +await station.put(stationStockDocument, "station:starbridge:stock", { + ...held, + availableMissiles: held.availableMissiles - 4, +}); +``` + State pointers are the same kind of managed surface for UI and tools. They can advertise source documents, inherit a Verse route when bound, and resolve through that Verse without caller-side context plumbing: @@ -201,7 +215,7 @@ const stopFeed = viewportFeed.watch(request, "browser-starfire", render); ``` ```ts -import { CultMesh } from "cultmesh-ts"; +import { CultMesh } from "@gamecult/cultmesh-ts"; const verses = CultMesh.createVerseCatalog<{ verseId: string; label: string }>(); const stopWatching = verses.watch((verse) => { @@ -260,7 +274,7 @@ const noteShards = shards.list({ }); ``` -Schema and shard catalog factories delegate to `cultnet-ts`; the branded +Schema and shard catalog factories delegate to `@gamecult/cultnet-ts`; the branded `CultMesh` entrypoint does not create a second owner for schema discovery or topology truth. @@ -276,7 +290,7 @@ command transactions; the injected transport owns CultNet wire details. import { CultMeshProviderRudpTransport, CultMeshProviderSession, -} from "cultmesh-ts"; +} from "@gamecult/cultmesh-ts"; const odinTransport = new CultMeshProviderRudpTransport({ endpoint: "rudp://127.0.0.1:17871", @@ -331,9 +345,11 @@ evidence for the requested provider identity. ## RUDP Helpers +`createRudpDocumentServer` refuses every peer put and delete unless given a `writeAuthority` (`new CultMeshGrantAllWrites()` accepts all). The authority is asked before a write's payload is sized or decoded and before any handler runs, and a refusal is the typed `write_not_authorized`. Residual: ungranted peers can still distinguish registered from unregistered schemas by timing. + Node runtimes can build the shared CultNet reliable-UDP transport from the branded CultMesh entrypoint while the transport semantics stay owned by -`cultnet-ts`: +`@gamecult/cultnet-ts`: ```ts const server = await CultMesh.createRudpServer("ts-server", 0x1020_3040); @@ -386,14 +402,14 @@ const latestHealth = await health.latest(); const syncedHealth = await station.syncDocumentFromPeerSnapshot( () => client, - daemonHealthUiDocument, + daemonHealthDocument, "daemon:aetheria.health.v1", { timeoutMs: 5_000, }, ); const localHealth = await station - .document(daemonHealthUiDocument, "daemon:aetheria.health.v1") + .document(daemonHealthDocument, "daemon:aetheria.health.v1") .latest(); ``` @@ -409,11 +425,11 @@ before using the peer-card endpoint as a dial target. snapshots. It hides the snapshot request message id, response listener, raw payload binary normalization, and MessagePack decode behind the same `CultMeshDocumentHandle` shape as local documents. The helper prefers an exact -schema id match and falls back to the requested record key, so runtimes that -alias the same logical document through different generated schema names can -still read the publication with one CultMesh call. The handle takes a CultCache -document definition, and its `latest()` result is read and parsed through that -definition: a payload is a `DocumentPayload`, which only +schema id match, else a record at the requested key under an id the +definition declares compatible; a record under any other schema id is another +type's and is never read in its place, whatever its payload parses as. The +handle takes a CultCache document definition, and its `latest()` result is read +and parsed through that definition: a payload is a `DocumentPayload`, which only `decodeDocumentPayload(definition, payload)` reads, so no document is read without the definition that says how its bytes are laid out. @@ -421,8 +437,8 @@ Use `node.syncDocumentFromPeerSnapshot(...)` or `CultMesh.syncDocumentFromPeerSnapshot(...)` when a runtime should hydrate its local node from the remote snapshot. The helper requests the raw snapshot, applies it through the node's document registry, and returns the requested -typed definition. Same-schema aliases stay local after the call: the caller can -keep using `node.document(aliasDefinition, key)` and explicitly select +typed definition. The record then lives in the node: the caller can keep using +`node.document(definition, key)` and explicitly select `authoritativeWriter()`, `predictionWriter()`, or `observe()` without re-threading the RUDP peer. @@ -458,7 +474,7 @@ The split is deliberate: The mirrored API shape is `CultMeshStreamCatalog`: ```ts -import { CultMesh } from "cultmesh-ts"; +import { CultMesh } from "@gamecult/cultmesh-ts"; const streams = CultMesh.createStreamCatalog(); const stopWatchingStreams = streams.watch((stream) => { @@ -519,7 +535,7 @@ nonlocal fallback. ## Content Chunks over HTTP(S) -`cultmesh-ts/content` answers the C# HTTPS connector's request, `GET` or `HEAD` +`@gamecult/cultmesh-ts/content` answers the C# HTTPS connector's request, `GET` or `HEAD` `?chunkHash=&recordKey=`. The mount path is the host's; the handler reads only the query. Every chunk is hashed against its name before a byte is sent, so a corrupt or swapped file answers 500 and never leaves the @@ -536,7 +552,7 @@ host store may reuse its buffer). `onServerFault` reports every 500 (`hash-misma ```ts import { readFileSync } from "node:fs"; import { createServer } from "node:https"; -import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener } from "cultmesh-ts/content"; +import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener } from "@gamecult/cultmesh-ts/content"; const handler = createContentChunkHandler(createDirectoryChunkStore("/srv/content"), { onServerFault: fault => console.error("chunk fault", fault.kind, fault.chunkHash), diff --git a/packages/cultmesh-ts/package.json b/packages/cultmesh-ts/package.json index f6559fba..92962cbc 100644 --- a/packages/cultmesh-ts/package.json +++ b/packages/cultmesh-ts/package.json @@ -1,5 +1,5 @@ { - "name": "cultmesh-ts", + "name": "@gamecult/cultmesh-ts", "version": "0.2.0", "description": "CultMesh local typed state and mesh catalog primitives for TypeScript runtimes.", "main": "dist/index.js", @@ -48,8 +48,8 @@ "node": ">=20" }, "dependencies": { - "@gamecult/cultcache-ts": "^0.14.0", - "cultnet-ts": "^0.3.0", + "@gamecult/cultcache-ts": "^0.15.0", + "@gamecult/cultnet-ts": "^0.3.0", "koffi": "3.3.0" }, "devDependencies": { diff --git a/packages/cultmesh-ts/src/content.ts b/packages/cultmesh-ts/src/content.ts index a0f3f06d..b463f5b6 100644 --- a/packages/cultmesh-ts/src/content.ts +++ b/packages/cultmesh-ts/src/content.ts @@ -2,7 +2,7 @@ import { constants } from "node:fs"; import { open } from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; import { join } from "node:path"; -import { DEFAULT_CHUNK_SIZE_BYTES, EMPTY_SHA256, chunkRecordKey, isNullOrWhiteSpaceCSharp, parseSha256Hex, sha256Hex } from "cultnet-ts/content"; +import { DEFAULT_CHUNK_SIZE_BYTES, EMPTY_SHA256, chunkRecordKey, isNullOrWhiteSpaceCSharp, parseSha256Hex, sha256Hex } from "@gamecult/cultnet-ts/content"; /** What the provider reads of a request. The path and the headers are never read: the route is the query alone. */ export interface ContentChunkRequest { method: string; url: string; } diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 810e6919..76c98b5e 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -1,9 +1,11 @@ import { createSocket, type Socket } from "node:dgram"; -import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; +import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; import { CultCache, + SchemaConflictError, SingleFileMessagePackBackingStore, + schemaIdentityOf, type AnyCultCacheDocumentDefinition, type CacheBackingStore, type CultCacheEnvelope, @@ -18,6 +20,8 @@ import { CultNetRudpSocketTransportConnection, rudpClientBindHost, CultNetSchemaCatalog, + cultNetTextError, + cultNetWriteNotAuthorizedError, CultNetShardCatalog, cultNetBuiltinSchemaRegistry, decodeRudpPacket, @@ -29,6 +33,7 @@ import { sendRudpDatagram, type CultNetErrorMessage, type CultNetDocumentBinding, + type CultNetDocumentDeleteMessage, type CultNetMessage, type CultNetOperationRequestMessage, type CultNetOperationResponseMessage, @@ -38,7 +43,7 @@ import { type CultNetSchemaCatalogOptions, type CultNetSnapshotResponseRawMessage, type CultNetWireContract, -} from "cultnet-ts"; +} from "@gamecult/cultnet-ts"; import { CultMeshQuicRealtimeConnector, @@ -207,6 +212,15 @@ export type CultMeshCollectionWatcher = ( callback: (change: CultMeshCollectionChange) => void, ) => CultMeshUnsubscribe; +/** + * The value type a catalog lookup yields. An explicit type argument wins; otherwise a CultCache document definition + * names its own value type, and a bare schema descriptor yields `unknown`. The value type is never inferred from a + * value or an expected result, so a wrong value is an error rather than a new type. + */ +export type CultMeshCatalogValue = [TDocument] extends [never] + ? TSchema extends AnyCultCacheDocumentDefinition ? CultCacheDocumentValue : unknown + : NoInfer; + export interface CultMeshDocumentSchemaDescriptor { readonly type?: string; readonly schemaId?: string; @@ -1019,38 +1033,6 @@ export class CultMeshDocumentHandle { ); } - public asSchemaAlias( - schema: CultMeshDocumentSchemaDescriptor, - options: { parse?: (value: unknown) => TAlias } = {}, - ): CultMeshDocumentHandle { - const aliasSchema = normalizeCultMeshDocumentSchema(schema); - if (!cultMeshSchemasAreCompatible(this.schema, aliasSchema)) { - throw new Error( - `Document '${this.documentId}' schema ${cultMeshSchemaLabel(this.schema)} is not compatible with alias ${cultMeshSchemaLabel(aliasSchema)}.`, - ); - } - - const parse = options.parse ?? ((value: unknown) => value as TAlias); - return new CultMeshDocumentHandle( - this.documentId, - aliasSchema, - async (context) => parse(await this.latest(context)), - { - sources: this.sources, - routeHint: this.routeHint, - watchDocument: this.watchDocument - ? (context, callback) => this.watch(context, value => callback(parse(value))) - : undefined, - replaceDocument: this.replaceDocument - ? (context, value) => this.replace(context, value as unknown as TDocument) - : undefined, - submitPrediction: this.submitPredictionDocument - ? (context, value) => this.submitPrediction(context, value as unknown as TDocument) - : undefined, - }, - ); - } - public bind(verse: CultMeshVerseContext | CultMeshVerse): CultMeshBoundDocumentHandle { return cultMeshBindDocument(verse, this); } @@ -1484,12 +1466,6 @@ export class CultMeshBoundDocumentHandle { }); } - public asSchemaAlias( - schema: CultMeshDocumentSchemaDescriptor, - options: { parse?: (value: unknown) => TAlias } = {}, - ): CultMeshBoundDocumentHandle { - return cultMeshBindDocument(this.verse, this.document.asSchemaAlias(schema, options)); - } } export class CultMeshDocumentCatalog { @@ -1509,6 +1485,7 @@ export class CultMeshDocumentCatalog { } public add(document: CultMeshDocumentHandle): this { + cultMeshRefuseSecondCatalogType(document.schema, this.#byType, this.#bySchemaId, this.#bySchemaNameVersion); this.#byDocumentId.set(document.documentId, document as CultMeshDocumentHandle); if (document.schema.type) { this.#byType.set(document.schema.type, document as CultMeshDocumentHandle); @@ -1523,129 +1500,120 @@ export class CultMeshDocumentCatalog { return this; } - public tryDocument( - schema: CultMeshDocumentSchemaDescriptor, - options: { parse?: (value: unknown) => TDocument } = {}, - ): CultMeshDocumentHandle | undefined { - const descriptor = normalizeCultMeshDocumentSchema(schema); - const exact = - (descriptor.type ? this.#byType.get(descriptor.type) : undefined) ?? - (descriptor.schemaId ? this.#bySchemaId.get(descriptor.schemaId) : undefined) ?? - (() => { - const key = cultMeshSchemaNameVersionKey(descriptor); - return key ? this.#bySchemaNameVersion.get(key) : undefined; - })(); - - if (!exact) { - return undefined; - } - - return cultMeshSchemasAreCompatible(exact.schema, descriptor) - ? exact.asSchemaAlias(descriptor, options) - : undefined; + // A catalog may hold several documents of one type, one per document id. A lookup by schema answers with one of them; + // a lookup by document id addresses each. + public tryDocumentById(documentId: string): CultMeshDocumentHandle | undefined { + return this.#byDocumentId.get(documentId); } - public document( - schema: CultMeshDocumentSchemaDescriptor, - options: { parse?: (value: unknown) => TDocument } = {}, - ): CultMeshDocumentHandle { - const document = this.tryDocument(schema, options); + public documentById(documentId: string): CultMeshDocumentHandle { + const document = this.tryDocumentById(documentId); if (!document) { - throw new Error(`Document catalog has no document for ${cultMeshSchemaLabel(schema)}.`); + throw new Error(`Document catalog has no document with id '${documentId}'.`); } return document; } - public latest( - schema: CultMeshDocumentSchemaDescriptor, + public tryDocument( + schema: TSchema, + ): CultMeshDocumentHandle> | undefined { + const descriptor = normalizeCultMeshDocumentSchema(schema); + // One type owns a schema, so a typed lookup is answered by that type's handle or not at all. + if (descriptor.type) { + return this.#byType.get(descriptor.type); + } + const key = cultMeshSchemaNameVersionKey(descriptor); + return (descriptor.schemaId ? this.#bySchemaId.get(descriptor.schemaId) : undefined) ?? + (key ? this.#bySchemaNameVersion.get(key) : undefined); + } + + public document( + schema: TSchema, + ): CultMeshDocumentHandle> { + return this.#require(schema); + } + + public latest( + schema: TSchema, context: CultMeshQueryContext | string = "local", - options: { parse?: (value: unknown) => TDocument } = {}, - ): Promise { - return this.document(schema, options).latest(context); + ): Promise> { + return this.#require(schema).latest(context); } public canReplace(schema: CultMeshDocumentSchemaDescriptor): boolean { return this.tryDocument(schema)?.canReplace ?? false; } - public replace( - schema: CultMeshDocumentSchemaDescriptor, - value: TDocument, - options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + public replace( + schema: TSchema, + value: CultMeshCatalogValue, + options?: { context?: CultMeshQueryContext | string }, ): Promise; - public replace( - schema: CultMeshDocumentSchemaDescriptor, + public replace( + schema: TSchema, context: CultMeshQueryContext | string, - value: TDocument, - options?: { parse?: (value: unknown) => TDocument }, + value: CultMeshCatalogValue, ): Promise; - public replace( + public replace( schema: CultMeshDocumentSchemaDescriptor, - contextOrValue: CultMeshQueryContext | string | TDocument, - valueOrOptions?: TDocument | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, - maybeOptions: { parse?: (value: unknown) => TDocument } = {}, + contextOrValue: unknown, + valueOrOptions?: unknown, ): Promise { - const hasContext = - typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); - const context = hasContext - ? contextOrValue as CultMeshQueryContext | string - : (valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; - const value = hasContext ? valueOrOptions as TDocument : contextOrValue as TDocument; - const options = hasContext ? maybeOptions : valueOrOptions as { parse?: (value: unknown) => TDocument } | undefined; - return this.document(schema, options).replace(context, value); + const [context, value] = cultMeshCatalogWriteArguments(contextOrValue, valueOrOptions); + return this.#require(schema).replace(context, value); } public canSubmitPrediction(schema: CultMeshDocumentSchemaDescriptor): boolean { return this.tryDocument(schema)?.canSubmitPrediction ?? false; } - public submitPrediction( - schema: CultMeshDocumentSchemaDescriptor, - value: TDocument, - options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + public submitPrediction( + schema: TSchema, + value: CultMeshCatalogValue, + options?: { context?: CultMeshQueryContext | string }, ): Promise; - public submitPrediction( - schema: CultMeshDocumentSchemaDescriptor, + public submitPrediction( + schema: TSchema, context: CultMeshQueryContext | string, - value: TDocument, - options?: { parse?: (value: unknown) => TDocument }, + value: CultMeshCatalogValue, ): Promise; - public submitPrediction( + public submitPrediction( schema: CultMeshDocumentSchemaDescriptor, - contextOrValue: CultMeshQueryContext | string | TDocument, - valueOrOptions?: TDocument | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, - maybeOptions: { parse?: (value: unknown) => TDocument } = {}, + contextOrValue: unknown, + valueOrOptions?: unknown, ): Promise { - const hasContext = - typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); - const context = hasContext - ? contextOrValue as CultMeshQueryContext | string - : (valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; - const value = hasContext ? valueOrOptions as TDocument : contextOrValue as TDocument; - const options = hasContext ? maybeOptions : valueOrOptions as { parse?: (value: unknown) => TDocument } | undefined; - return this.document(schema, options).submitPrediction(context, value); + const [context, value] = cultMeshCatalogWriteArguments(contextOrValue, valueOrOptions); + return this.#require(schema).submitPrediction(context, value); } - public authoritativeWriter( - schema: CultMeshDocumentSchemaDescriptor, - options: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string } = {}, - ): CultMeshDocumentWriter { - return this.document(schema, options).authoritativeWriter(options.context ?? "local"); + public authoritativeWriter( + schema: TSchema, + options: { context?: CultMeshQueryContext | string } = {}, + ): CultMeshDocumentWriter & object> { + return this.#require(schema).authoritativeWriter(options.context ?? "local"); } - public predictionWriter( - schema: CultMeshDocumentSchemaDescriptor, - options: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string } = {}, - ): CultMeshDocumentWriter { - return this.document(schema, options).predictionWriter(options.context ?? "local"); + public predictionWriter( + schema: TSchema, + options: { context?: CultMeshQueryContext | string } = {}, + ): CultMeshDocumentWriter & object> { + return this.#require(schema).predictionWriter(options.context ?? "local"); } - public watch( - schema: CultMeshDocumentSchemaDescriptor, - callback: (value: TDocument) => void, - options: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string } = {}, + public watch( + schema: TSchema, + callback: (value: CultMeshCatalogValue) => void, + options: { context?: CultMeshQueryContext | string } = {}, ): CultMeshUnsubscribe { - return this.document(schema, options).watch(options.context ?? "local", callback); + return this.#require(schema).watch(options.context ?? "local", callback); + } + + #require(schema: CultMeshDocumentSchemaDescriptor): CultMeshDocumentHandle { + const document = this.tryDocument(schema); + if (!document) { + throw new Error(`Document catalog has no document for ${cultMeshSchemaLabel(schema)}.`); + } + return document; } } @@ -1711,35 +1679,6 @@ export class CultMeshCollectionHandle { return this.watchCollection(this.resolveContext(context), callback); } - public asSchemaAlias( - schema: CultMeshDocumentSchemaDescriptor, - options: { parse?: (value: unknown) => TAlias } = {}, - ): CultMeshCollectionHandle { - const aliasSchema = normalizeCultMeshDocumentSchema(schema); - if (!cultMeshSchemasAreCompatible(this.schema, aliasSchema)) { - throw new Error( - `Collection '${this.collectionId}' schema ${cultMeshSchemaLabel(this.schema)} is not compatible with alias ${cultMeshSchemaLabel(aliasSchema)}.`, - ); - } - - const parse = options.parse ?? ((value: unknown) => value as TAlias); - return new CultMeshCollectionHandle( - this.collectionId, - aliasSchema, - async (context) => (await this.latest(context)).map(value => parse(value)), - { - sources: this.sources, - routeHint: this.routeHint, - watchCollection: this.watchCollection - ? (context, callback) => this.watchChanges(context, change => callback({ - ...change, - value: change.value === undefined ? undefined : parse(change.value), - })) - : undefined, - }, - ); - } - public bind(verse: CultMeshVerseContext | CultMeshVerse): CultMeshBoundCollectionHandle { return cultMeshBindCollection(verse, this); } @@ -1772,6 +1711,7 @@ export class CultMeshCollectionCatalog { } public add(collection: CultMeshCollectionHandle): this { + cultMeshRefuseSecondCatalogType(collection.schema, this.#byType, this.#bySchemaId, this.#bySchemaNameVersion); this.#byCollectionId.set(collection.collectionId, collection as CultMeshCollectionHandle); if (collection.schema.type) { this.#byType.set(collection.schema.type, collection as CultMeshCollectionHandle); @@ -1786,54 +1726,47 @@ export class CultMeshCollectionCatalog { return this; } - public tryCollection( - schema: CultMeshDocumentSchemaDescriptor, - options: { parse?: (value: unknown) => TDocument } = {}, - ): CultMeshCollectionHandle | undefined { + public tryCollection( + schema: TSchema, + ): CultMeshCollectionHandle> | undefined { const descriptor = normalizeCultMeshDocumentSchema(schema); - const exact = - (descriptor.type ? this.#byType.get(descriptor.type) : undefined) ?? - (descriptor.schemaId ? this.#bySchemaId.get(descriptor.schemaId) : undefined) ?? - (() => { - const key = cultMeshSchemaNameVersionKey(descriptor); - return key ? this.#bySchemaNameVersion.get(key) : undefined; - })(); - - if (!exact) { - return undefined; + // One type owns a schema, so a typed lookup is answered by that type's handle or not at all. + if (descriptor.type) { + return this.#byType.get(descriptor.type); } - - return cultMeshSchemasAreCompatible(exact.schema, descriptor) - ? exact.asSchemaAlias(descriptor, options) - : undefined; + const key = cultMeshSchemaNameVersionKey(descriptor); + return (descriptor.schemaId ? this.#bySchemaId.get(descriptor.schemaId) : undefined) ?? + (key ? this.#bySchemaNameVersion.get(key) : undefined); } - public collection( - schema: CultMeshDocumentSchemaDescriptor, - options: { parse?: (value: unknown) => TDocument } = {}, - ): CultMeshCollectionHandle { - const collection = this.tryCollection(schema, options); + public collection( + schema: TSchema, + ): CultMeshCollectionHandle> { + return this.#require(schema); + } + + public latest( + schema: TSchema, + context: CultMeshQueryContext | string = "local", + ): Promise>> { + return this.#require(schema).latest(context); + } + + public watchChanges( + schema: TSchema, + callback: (change: CultMeshCollectionChange>) => void, + options: { context?: CultMeshQueryContext | string } = {}, + ): CultMeshUnsubscribe { + return this.#require(schema).watchChanges(options.context ?? "local", callback); + } + + #require(schema: CultMeshDocumentSchemaDescriptor): CultMeshCollectionHandle { + const collection = this.tryCollection(schema); if (!collection) { throw new Error(`Collection catalog has no collection for ${cultMeshSchemaLabel(schema)}.`); } return collection; } - - public latest( - schema: CultMeshDocumentSchemaDescriptor, - context: CultMeshQueryContext | string = "local", - options: { parse?: (value: unknown) => TDocument } = {}, - ): Promise> { - return this.collection(schema, options).latest(context); - } - - public watchChanges( - schema: CultMeshDocumentSchemaDescriptor, - callback: (change: CultMeshCollectionChange) => void, - options: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string } = {}, - ): CultMeshUnsubscribe { - return this.collection(schema, options).watchChanges(options.context ?? "local", callback); - } } export class CultMeshBoundCollectionHandle { @@ -1858,12 +1791,6 @@ export class CultMeshBoundCollectionHandle { return this.collection.watchChanges(cultMeshQueryContextFromVerse(this.verse), callback); } - public asSchemaAlias( - schema: CultMeshDocumentSchemaDescriptor, - options: { parse?: (value: unknown) => TAlias } = {}, - ): CultMeshBoundCollectionHandle { - return cultMeshBindCollection(this.verse, this.collection.asSchemaAlias(schema, options)); - } } export class CultMeshStatePointer { @@ -2415,6 +2342,17 @@ function resolveCultMeshVerseContext(verse: CultMeshVerseContext | CultMeshVerse return verse instanceof CultMeshVerse ? verse.context : verse; } +// A catalog write takes (value, options?) or (context, value); both resolve to one context and one value. +function cultMeshCatalogWriteArguments( + contextOrValue: unknown, + valueOrOptions: unknown, +): [CultMeshQueryContext | string, unknown] { + if (typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue)) { + return [contextOrValue, valueOrOptions]; + } + return [(valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local", contextOrValue]; +} + function isCultMeshQueryContext(value: unknown): value is CultMeshQueryContext { if (typeof value !== "object" || value === null) { return false; @@ -2672,19 +2610,11 @@ export function cultMeshDocumentFromPeerSnapshot> => { const record = await requestCultNetRawSnapshotDocument( peer, - resolvedSchemaId, + cultMeshAcceptedSchemaIds(definition), recordKey, { timeoutMs: options.timeoutMs, messageIdPrefix: options.messageIdPrefix ?? documentId, - accepts: candidate => { - try { - decodeDocumentPayload(definition, candidate.payload); - return true; - } catch { - return false; - } - }, }, ); return decodeDocumentPayload(definition, record.payload); @@ -3872,11 +3802,7 @@ export class CultMeshNode { definition: TDefinition, key: string, ): CultCacheDocumentValue | undefined { - const registered = this.resolveCacheDefinition(definition); - const value = this.cache.get(registered, key); - return value === undefined - ? undefined - : this.parseDocumentValue(definition, value); + return this.cache.get(definition, key); } public getRequired( @@ -3896,17 +3822,14 @@ export class CultMeshNode { key: string, value: CultCacheDocumentValue, ): Promise> { - const registered = this.resolveCacheDefinition(definition); - return this.cache - .put(registered, key, this.parseDocumentValue(definition, value) as never) - .then(stored => this.parseDocumentValue(definition, stored)); + return this.cache.put(definition, key, value); } public delete( definition: TDefinition, key: string, ): Promise { - return this.cache.delete(this.resolveCacheDefinition(definition), key); + return this.cache.delete(definition, key); } public document( @@ -3919,15 +3842,7 @@ export class CultMeshNode { } = {}, ): CultMeshDocumentHandle> { const documentId = options.documentId ?? `${definition.type}:${key}`; - return this.asRequestedDocument( - cultMeshDocumentFromCache( - this.cache, - this.resolveCacheDefinition(definition), - key, - { ...options, documentId }, - ), - definition, - ); + return cultMeshDocumentFromCache(this.cache, definition, key, { ...options, documentId }); } public async syncDocumentFromPeerSnapshot( @@ -3939,7 +3854,6 @@ export class CultMeshNode { messageIdPrefix?: string; } = {}, ): Promise> { - const registered = this.resolveCacheDefinition(definition); const schema = cultMeshSchemaFromDefinition(definition); const schemaId = schema.schemaId ?? schema.type; if (!schemaId) { @@ -3947,19 +3861,11 @@ export class CultMeshNode { } const document = await requestCultNetRawSnapshotDocument( peer, - schemaId, + [schemaId], key, { timeoutMs: options.timeoutMs, messageIdPrefix: options.messageIdPrefix ?? key, - accepts: record => { - try { - decodeDocumentPayload(definition, record.payload); - return true; - } catch { - return false; - } - }, }, ); await this.documents.applyRawDocumentPutMessage(this.cache, { @@ -3967,7 +3873,7 @@ export class CultMeshNode { messageId: options.messageIdPrefix ?? `sync:${key}`, document, }); - return this.parseDocumentValue(definition, this.cache.getRequired(registered, key)); + return this.cache.getRequired(definition, key); } public syncDocumentFromPublication( @@ -4004,14 +3910,7 @@ export class CultMeshNode { } = {}, ): CultMeshDocumentHandle> { const documentId = options.documentId ?? `${definition.type}:global`; - return this.asRequestedDocument( - cultMeshGlobalDocumentFromCache( - this.cache, - this.resolveCacheDefinition(definition), - { ...options, documentId }, - ), - definition, - ); + return cultMeshGlobalDocumentFromCache(this.cache, definition, { ...options, documentId }); } public collection( @@ -4023,46 +3922,12 @@ export class CultMeshNode { } = {}, ): CultMeshCollectionHandle> { const collectionId = options.collectionId ?? definition.type; - return cultMeshCollectionFromCache( - this.cache, - this.resolveCacheDefinition(definition), - { ...options, collectionId }, - ).asSchemaAlias>( - cultMeshSchemaFromDefinition(definition), - { parse: value => this.parseDocumentValue(definition, value) }, - ); + return cultMeshCollectionFromCache(this.cache, definition, { ...options, collectionId }); } public async flush(soft = false): Promise { await this.store.pushAll?.(this.cache.snapshot(), { soft }); } - - private resolveCacheDefinition( - definition: TDefinition, - ): AnyCultCacheDocumentDefinition { - const schema = cultMeshSchemaFromDefinition(definition); - return ( - this.documents.get(definition.type) ?? - (schema.schemaId ? this.documents.getBySchemaId(schema.schemaId) : undefined) - )?.definition ?? definition; - } - - private asRequestedDocument( - document: CultMeshDocumentHandle, - definition: TDefinition, - ): CultMeshDocumentHandle> { - return document.asSchemaAlias>( - cultMeshSchemaFromDefinition(definition), - { parse: value => this.parseDocumentValue(definition, value) }, - ); - } - - private parseDocumentValue( - definition: TDefinition, - value: unknown, - ): CultCacheDocumentValue { - return definition.schema.parse(value) as CultCacheDocumentValue; - } } export class CultMeshVerseCatalog { @@ -4129,6 +3994,63 @@ export type CultMeshRudpDocumentPut = { | { readonly decoded: false; readonly payload: DocumentPayload } ); +/** The peer a write arrived from: the datagram's source, nothing the peer claimed. */ +export interface CultMeshRemoteWritePeer { + address: string; + family: string; + port: number; +} + +export type CultMeshRemoteWriteOperation = "put" | "delete"; + +/** + * One peer write as the authority sees it. `schemaId` is the canonical id the registry resolved for the write (the id + * as sent when no binding takes it), never an alias the peer used when a binding resolves it. `shardId` and + * `shardEpoch` are as a put carried them, `null` when it sent none (always `null` for a delete, which has none); a + * put carrying either with the wrong type is refused before any authority is asked. + */ +export interface CultMeshRemoteWriteRequest { + peer: CultMeshRemoteWritePeer; + operation: CultMeshRemoteWriteOperation; + schemaId: string; + recordKey: string; + shardId: string | null; + shardEpoch: number | null; +} + +export interface CultMeshRemoteWriteDecision { + granted: boolean; + /** Told to the refused peer after `write_not_authorized:`; keep it free of anything secret. */ + reason?: string; +} + +/** + * The one question a document server asks before it applies a peer's put or delete (C# `ICultNetRemoteWriteAuthority`). + * A server with no authority refuses every peer write; one whose authority throws, rejects or answers anything but + * `granted: true` refuses too. + */ +export interface CultMeshRemoteWriteAuthority { + decide(request: CultMeshRemoteWriteRequest): CultMeshRemoteWriteDecision | Promise; +} + +/** + * Grants every peer write. For a host that has already authenticated its peers, or a test rig: it is an explicit + * choice because serving without an authority refuses writes. + */ +export class CultMeshGrantAllWrites implements CultMeshRemoteWriteAuthority { + decide(): CultMeshRemoteWriteDecision { + return { granted: true }; + } +} + +/** A delete as `onDocumentDeleteRaw` receives it, after the authority granted it. */ +export interface CultMeshRudpDocumentDelete { + messageId: string; + schemaId: string; + recordKey: string; + remote: CultMeshRemoteWritePeer; +} + export interface CultMeshRudpDocumentReceipt { binding: CultNetDocumentBinding; recordKey: string; @@ -4168,10 +4090,18 @@ export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOpt documents: CultNetDocumentRegistry; getCache?: () => Promise | CultCache; onError?: (error: Error) => void; + /** + * Who may put and delete. Without it every peer put and delete is refused with a typed `write_not_authorized` + * (and `onDocumentPutRaw` is never called); pass `new CultMeshGrantAllWrites()` to accept them all. The authority is + * asked before the document is sized, resolved or decoded. + */ + writeAuthority?: CultMeshRemoteWriteAuthority; onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) => | CultMeshRudpDocumentReceipt | void | Promise; + /** Applies a delete the authority granted. Without it a granted delete is answered with a text refusal. */ + onDocumentDeleteRaw?: (request: CultMeshRudpDocumentDelete) => void | Promise; onOperationRequest?: ( request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession, @@ -5711,14 +5641,25 @@ export class CultMesh { payload: Uint8Array, ): Promise { if (record.closed) return; - const message = parseCultNetMessage(decodeWireValue(payload), wireContract); + let decoded: unknown; + let message: CultNetMessage; + try { + decoded = decodeWireValue(payload); + message = parseCultNetMessage(decoded, wireContract); + } catch (error) { + // A frame the server cannot read is judged as the write it looks like (a put when it does not even decode), so + // an ungranted peer hears the same refusal for it as for any other write, not a parse error. + const write = readableWrite(decoded); + if (decoded !== undefined && !write) throw error; + if (await writeRefused(record, write?.operation ?? "put", write?.schemaId ?? "", write?.recordKey ?? "", null, null)) return; + reportError(error); + sendSchemaMessage(record, malformedWriteError(write?.operation ?? "put", "CultMesh RUDP document server could not read the write.")); + return; + } switch (message.schemaVersion) { case "cultnet.snapshot_request.v0": { if (!options.getCache) { - sendSchemaMessage(record, { - schemaVersion: "cultnet.error.v0", - error: "CultMesh RUDP document server has no cache for snapshot requests.", - }); + sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no cache for snapshot requests.")); return; } let reply: CultNetMessage; @@ -5747,11 +5688,29 @@ export class CultMesh { sendSchemaMessage(record, cultNetBuiltinSchemaRegistry.createCatalogResponse(message)); return; case "cultnet.document_put_raw.v0": + { + // The parser passes shard fields of any type through a put. The authority is asked first, with the shard + // fields it can trust (null when wrong-typed), so an ungranted peer hears only the refusal; a granted peer + // whose shard fields are wrong-typed is then refused as malformed. + const { shardId, shardEpoch } = message as { shardId?: unknown; shardEpoch?: unknown }; + const malformedShard = + (shardId !== undefined && typeof shardId !== "string") || + (shardEpoch !== undefined && !Number.isSafeInteger(shardEpoch)); + if (await writeRefused( + record, "put", message.document.schemaId, message.document.recordKey, + malformedShard ? null : (shardId as string | undefined) ?? null, + malformedShard ? null : (shardEpoch as number | undefined) ?? null, + )) return; + if (malformedShard) { + sendSchemaMessage(record, malformedWriteError("put", "Raw put messages must carry a string shardId and an integer shardEpoch.")); + return; + } + } if (options.onDocumentPutRaw) { const unservable = unservablePut(message); if (unservable) { reportError(unservable); - sendSchemaMessage(record, { schemaVersion: "cultnet.error.v0", error: unservable.message }); + sendSchemaMessage(record, cultNetTextError(unservable.message)); return; } const receipt = await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote, options.documents)); @@ -5761,32 +5720,108 @@ export class CultMesh { } catch (error) { // The put is stored: the peer must hear that its receipt is not coming. reportError(error); - sendSchemaMessage(record, { - schemaVersion: "cultnet.error.v0", - error: `CultMesh RUDP document server stored a put but could not send its receipt.`, - }); + sendSchemaMessage(record, cultNetTextError(`CultMesh RUDP document server stored a put but could not send its receipt.`)); } } } return; + case "cultnet.document_delete.v0": { + if (await writeRefused(record, "delete", message.schemaId, message.recordKey, null, null)) return; + if (!options.onDocumentDeleteRaw) { + sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no delete handler.")); + return; + } + await options.onDocumentDeleteRaw({ + messageId: message.messageId, + schemaId: message.schemaId, + recordKey: message.recordKey, + remote: { ...record.remote }, + }); + return; + } case "cultnet.operation_request.v0": if (!options.onOperationRequest) { - sendSchemaMessage(record, { - schemaVersion: "cultnet.error.v0", - error: "CultMesh RUDP document server has no operation handler.", - }); + sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no operation handler.")); return; } sendSchemaMessage(record, await options.onOperationRequest(message, publicSession(record))); return; default: - sendSchemaMessage(record, { - schemaVersion: "cultnet.error.v0", - error: `Unsupported CultMesh RUDP document request ${message.schemaVersion}.`, - }); + sendSchemaMessage(record, cultNetTextError(`Unsupported CultMesh RUDP document request ${message.schemaVersion}.`)); } } + function clipUtf8(text: string, maxBytes: number): string { + const bytes = Buffer.from(text, "utf8"); + return bytes.byteLength <= maxBytes ? text : new TextDecoder().decode(bytes.subarray(0, maxBytes)).replace(/\uFFFD+$/, ""); + } + + /** The put or delete a decoded frame claims to be, with the ids it carries when they are strings. */ + function readableWrite(value: unknown): { operation: CultMeshRemoteWriteOperation; schemaId: string; recordKey: string } | undefined { + if (typeof value !== "object" || value === null) return undefined; + const frame = value as { schemaVersion?: unknown; schemaId?: unknown; recordKey?: unknown; document?: unknown }; + const str = (v: unknown): string => (typeof v === "string" ? v : ""); + if (frame.schemaVersion === "cultnet.document_delete.v0") { + return { operation: "delete", schemaId: str(frame.schemaId), recordKey: str(frame.recordKey) }; + } + if (frame.schemaVersion === "cultnet.document_put_raw.v0") { + const document = (typeof frame.document === "object" && frame.document !== null ? frame.document : {}) as { schemaId?: unknown; recordKey?: unknown }; + return { operation: "put", schemaId: str(document.schemaId), recordKey: str(document.recordKey) }; + } + return undefined; + } + + // Not a `code`: malformed_document_put/delete are outside the schema's closed code enum, which TS and Rust peers + // refuse to decode (docs/cultnet-error-contract-cut.md), so the name leads the text of a codeless error instead. + function malformedWriteError(operation: CultMeshRemoteWriteOperation, text: string): CultNetMessage { + return cultNetTextError(`malformed_document_${operation}: ${text}`); + } + + /** + * Asks the write authority about a put or delete, before anything of the write is sized, resolved or decoded, and + * answers a refusal with the typed `write_not_authorized` naming the record. True when the write is refused. + * No authority refuses; an authority that throws, rejects or does not say `granted: true` refuses too (fail + * closed), and a failure is reported without quoting it. + */ + async function writeRefused( + record: SessionRecord, + operation: CultMeshRemoteWriteOperation, + schemaId: string, + recordKey: string, + shardId: string | null, + shardEpoch: number | null, + ): Promise { + let reason: string | undefined; + const authority = options.writeAuthority; + if (!authority) { + reason = "no remote write authority is configured"; + } else { + try { + const decision = await authority.decide({ + peer: { ...record.remote }, + operation, + schemaId: options.documents.canonicalSchemaId(schemaId) ?? schemaId, + recordKey, + shardId, + shardEpoch, + }); + if (decision?.granted === true) return false; + reason = typeof decision?.reason === "string" ? decision.reason : undefined; + } catch (error) { + reportError(new Error(`CultMesh remote write authority failed (${error instanceof Error ? error.name : typeof error}); the write was refused.`)); + reason = "the write authority failed"; + } + } + try { + // The key is the peer's and the reason the authority's: both are clipped so the refusal fits `maxPayloadBytes` + // whatever they are (the peer is told the clipped key; a `maxPayloadBytes` under about 512 can still not carry it). + sendSchemaMessage(record, cultNetWriteNotAuthorizedError(clipUtf8(recordKey, 64), reason === undefined ? undefined : clipUtf8(reason, 96))); + } catch (error) { + reportError(error); + } + return true; + } + function publicSession(record: SessionRecord): CultMeshRudpServerSession { return { sessionId: record.sessionId, @@ -5879,7 +5914,7 @@ export class CultMesh { */ function refuse(record: SessionRecord, text: string): void { if (record.closed) return; - const payload = encodeSchemaMessage({ schemaVersion: "cultnet.error.v0", error: text }); + const payload = encodeSchemaMessage(cultNetTextError(text)); for (const packet of record.session.sendMany("schema", payload, { reliable: false, ordered: false, @@ -6281,12 +6316,8 @@ export class CultMesh { function cultMeshSchemaFromDefinition( definition: AnyCultCacheDocumentDefinition, ): CultMeshDocumentSchemaDescriptor { - return { - type: definition.type, - schemaId: definition.schemaId ?? definition.type, - schemaName: definition.schemaName, - schemaVersion: definition.schemaVersion, - }; + const { schemaId, schemaName, schemaVersion } = schemaIdentityOf(definition); + return { type: definition.type, schemaId, schemaName, schemaVersion }; } function normalizeCultMeshDocumentSchema( @@ -6308,6 +6339,32 @@ function normalizeCultMeshDocumentSchema( return normalized; } +// One type owns a schema in a catalog, as in a registry. A handle whose type differs from the one already holding its +// schema id or schema name and version, or whose type already holds another schema, is refused. Handles of one type +// for one schema may repeat (one per record), and a handle without a type claims no type. +function cultMeshRefuseSecondCatalogType( + schema: CultMeshDocumentSchemaDescriptor, + byType: ReadonlyMap, + bySchemaId: ReadonlyMap, + bySchemaNameVersion: ReadonlyMap, +): void { + if (!schema.type) { + return; + } + const key = cultMeshSchemaNameVersionKey(schema); + const sameType = byType.get(schema.type)?.schema; + const holder = + (sameType?.schemaId && schema.schemaId && sameType.schemaId !== schema.schemaId ? sameType : undefined) ?? + [schema.schemaId ? bySchemaId.get(schema.schemaId) : undefined, key ? bySchemaNameVersion.get(key) : undefined] + .map(held => held?.schema) + .find(held => held?.type !== undefined && held.type !== schema.type); + if (holder) { + const name = (held: CultMeshDocumentSchemaDescriptor) => held.schemaName ?? held.type ?? held.schemaId ?? ""; + throw new SchemaConflictError(schema.schemaId ?? schema.type, [name(holder), name(schema)], "", + `CultMesh catalog schema ${cultMeshSchemaLabel(schema)} is held by type "${holder.type}" and cannot also be held by type "${schema.type}".`); + } +} + function cultMeshSchemaNameVersionKey( schema: CultMeshDocumentSchemaDescriptor, ): string | undefined { @@ -6316,26 +6373,6 @@ function cultMeshSchemaNameVersionKey( : undefined; } -function cultMeshSchemasAreCompatible( - left: CultMeshDocumentSchemaDescriptor, - right: CultMeshDocumentSchemaDescriptor, -): boolean { - if (left.schemaId && right.schemaId && left.schemaId === right.schemaId) { - return true; - } - - if ( - left.schemaName && - right.schemaName && - left.schemaName === right.schemaName && - (left.schemaVersion ?? "") === (right.schemaVersion ?? "") - ) { - return true; - } - - return Boolean(left.type && right.type && left.type === right.type); -} - function inferCultMeshSchemaName(schemaId: string | undefined): string | undefined { if (!schemaId) { return undefined; @@ -6348,53 +6385,23 @@ function inferCultMeshSchemaName(schemaId: string | undefined): string | undefin return /^\d+$/.test(version) ? schemaId.slice(0, marker) : undefined; } +// The record a store document reads: the one under the handle's schema id, else one under an id the definition lists as +// compatible, as CultCache resolves it. A record under any other id is not this document, whatever +// its payload parses as. function resolveCultMeshStoreDocumentRecord( records: readonly CultCacheEnvelope[], schema: CultMeshDocumentSchemaDescriptor, definition: AnyCultCacheDocumentDefinition, ): CultCacheEnvelope | undefined { - const schemaId = schema.schemaId ?? schema.type; - return ( - records.find(candidate => schemaId !== undefined && candidate.schemaId === schemaId) ?? - records.find(candidate => - cultMeshEnvelopeMatchesSchema(candidate, schema) && cultMeshEnvelopeParsesAsDefinition(candidate, definition)) ?? - records.find(candidate => cultMeshEnvelopeParsesAsDefinition(candidate, definition)) - ); + const own = schema.schemaId ?? schema.type; + const accepted = [own, ...cultMeshAcceptedSchemaIds(definition)]; + return records.find(candidate => candidate.schemaId === own) + ?? records.find(candidate => candidate.schemaId !== undefined && accepted.includes(candidate.schemaId)); } -function cultMeshEnvelopeMatchesSchema( - envelope: CultCacheEnvelope, - schema: CultMeshDocumentSchemaDescriptor, -): boolean { - const entry = envelope.catalogEntry; - if (!entry) { - return false; - } - if ( - schema.schemaId && - (entry.schemaId === schema.schemaId || - envelope.schemaId === schema.schemaId || - entry.compatibleSchemaIds?.includes(schema.schemaId)) - ) { - return true; - } - return Boolean( - schema.schemaName && - entry.schemaName === schema.schemaName && - (schema.schemaVersion ?? "") === (entry.schemaVersion ?? ""), - ); -} - -function cultMeshEnvelopeParsesAsDefinition( - envelope: CultCacheEnvelope, - definition: AnyCultCacheDocumentDefinition, -): boolean { - try { - decodeDocumentPayload(definition, envelope.payload); - return true; - } catch { - return false; - } +// The ids a definition's records may be stored under: its own first, then the ids it declares compatible. +function cultMeshAcceptedSchemaIds(definition: AnyCultCacheDocumentDefinition): readonly string[] { + return schemaIdentityOf(definition).compatibleSchemaIds; } function cultMeshSchemaLabel(schema: CultMeshDocumentSchemaDescriptor): string { @@ -6441,14 +6448,15 @@ function normalizeRudpDocumentPut( }; } +// The peer's record at the key under the first schema id, else under another id in the list. A record at the key under any +// other id is another type's, and is never taken in its place, whatever its payload parses as; none is not-found. async function requestCultNetRawSnapshotDocument( peerOrProvider: CultNetPeer | (() => CultNetPeer | Promise), - schemaId: string, + schemaIds: readonly string[], recordKey: string, options: { timeoutMs?: number; messageIdPrefix?: string; - accepts?: (document: CultNetRawDocumentRecord) => boolean; } = {}, ): Promise { const peer = await resolveCultNetPeer(peerOrProvider); @@ -6457,13 +6465,11 @@ async function requestCultNetRawSnapshotDocument( messageId, timeoutMs: options.timeoutMs, }); - const candidates = response.documents.filter(candidate => candidate.recordKey === recordKey); - const document = - candidates.find(candidate => candidate.schemaId === schemaId) ?? - (options.accepts ? candidates.find(options.accepts) : undefined) ?? - candidates[0]; + const atKey = response.documents.filter(candidate => candidate.recordKey === recordKey); + const document = atKey.find(candidate => candidate.schemaId === schemaIds[0]) + ?? atKey.find(candidate => schemaIds.includes(candidate.schemaId)); if (!document) { - throw new Error(`CultMesh peer snapshot did not return ${schemaId} at ${recordKey}.`); + throw new Error(`CultMesh peer snapshot did not return ${schemaIds[0]} at ${recordKey}.`); } return document; } diff --git a/packages/cultmesh-ts/src/provider-rudp-transport.ts b/packages/cultmesh-ts/src/provider-rudp-transport.ts index fc0d75c0..b193825f 100644 --- a/packages/cultmesh-ts/src/provider-rudp-transport.ts +++ b/packages/cultmesh-ts/src/provider-rudp-transport.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { createSocket, type Socket } from "node:dgram"; import { documentPayloadFromBytes } from "@gamecult/cultcache-ts"; -import { encodeWireValue } from "cultnet-ts/wire"; +import { encodeWireValue } from "@gamecult/cultnet-ts/wire"; import { CultNetPeer, CultNetRudpSocketTransportConnection, @@ -9,7 +9,7 @@ import { type CultNetOperationRequestMessage, type CultNetOperationResponseMessage, type CultNetRawDocumentRecord, -} from "cultnet-ts"; +} from "@gamecult/cultnet-ts"; import type { CultMeshProviderCommand, diff --git a/packages/cultmesh-ts/src/provider-session-broker.ts b/packages/cultmesh-ts/src/provider-session-broker.ts index 75240a14..c427b83a 100644 --- a/packages/cultmesh-ts/src/provider-session-broker.ts +++ b/packages/cultmesh-ts/src/provider-session-broker.ts @@ -1,11 +1,11 @@ import { encodeDocumentPayload } from "@gamecult/cultcache-ts"; -import { encodeWireValue } from "cultnet-ts/wire"; +import { encodeWireValue } from "@gamecult/cultnet-ts/wire"; import type { CultNetDocumentPutRawMessage, CultNetOperationRequestMessage, CultNetOperationResponseMessage, CultNetRawDocumentRecord, -} from "cultnet-ts"; +} from "@gamecult/cultnet-ts"; import type { CultMeshRudpServerSession } from "./index"; import { diff --git a/packages/cultmesh-ts/src/provider-session-wire.ts b/packages/cultmesh-ts/src/provider-session-wire.ts index d4884694..3cffb13e 100644 --- a/packages/cultmesh-ts/src/provider-session-wire.ts +++ b/packages/cultmesh-ts/src/provider-session-wire.ts @@ -1,6 +1,6 @@ import { decodeDocumentPayload, defineDocumentType } from "@gamecult/cultcache-ts"; -import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; -import type { CultNetRawDocumentRecord } from "cultnet-ts"; +import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; +import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts"; export const CULTMESH_PROVIDER_SESSION_SERVICE_ID = "gamecult.mesh.provider_session"; diff --git a/packages/cultmesh-ts/src/realtime-quic.ts b/packages/cultmesh-ts/src/realtime-quic.ts index c368c96f..32b500d2 100644 --- a/packages/cultmesh-ts/src/realtime-quic.ts +++ b/packages/cultmesh-ts/src/realtime-quic.ts @@ -21,8 +21,8 @@ import { trimCSharp, verifyAuthorityRoute, type CultMeshAuthorityTrustPolicy, -} from "cultnet-ts"; -import type { CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; +} from "@gamecult/cultnet-ts"; +import type { CultMeshVerseDescriptorMessage } from "@gamecult/cultnet-ts/contracts"; import { decodeRealtimeFrame, diff --git a/packages/cultmesh-ts/src/realtime-wire.ts b/packages/cultmesh-ts/src/realtime-wire.ts index 9fc99789..e87df641 100644 --- a/packages/cultmesh-ts/src/realtime-wire.ts +++ b/packages/cultmesh-ts/src/realtime-wire.ts @@ -16,7 +16,7 @@ // agree with itself. import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts"; -import { isNullOrWhiteSpaceCSharp } from "cultnet-ts"; +import { isNullOrWhiteSpaceCSharp } from "@gamecult/cultnet-ts"; /** `CultMeshRealtimeWireProtocol.ApplicationProtocolName`: the QUIC ALPN. */ export const CULTMESH_REALTIME_ALPN = "cultmesh-state-v1"; @@ -70,7 +70,7 @@ const INT64_MAX = 2n ** 63n - 1n; * `CultMeshRealtimeFrame.Validate`. The C# reading of "non-empty" is * `string.IsNullOrWhiteSpace`, whose whitespace set is not * `String.prototype.trim`'s, so this borrows the one TypeScript implementation - * of that set from `cultnet-ts` rather than keeping a second reading here. + * of that set from `@gamecult/cultnet-ts` rather than keeping a second reading here. * * The range check has no C# counterpart because `long` gives it for free; a * `bigint` does not, and a `DataView` would otherwise throw a bare `RangeError` diff --git a/packages/cultmesh-ts/test/codec-documents.test.ts b/packages/cultmesh-ts/test/codec-documents.test.ts index 4843c09b..d9c8d2e1 100644 --- a/packages/cultmesh-ts/test/codec-documents.test.ts +++ b/packages/cultmesh-ts/test/codec-documents.test.ts @@ -15,13 +15,12 @@ import { documentPayloadFromBytes, encodeDocumentPayload, } from "@gamecult/cultcache-ts"; -import type { CacheBackingStore, CultCacheEnvelope } from "@gamecult/cultcache-ts"; import { CultNetDocumentRegistry, defineCultNetDocumentBinding, type CultNetPeer, -} from "cultnet-ts"; -import { CultMesh, cultMeshDocumentFromSingleFile, cultMeshDocumentFromStore, type CultMeshRudpDocumentPut } from "../src/index"; +} from "@gamecult/cultnet-ts"; +import { CultMesh, CultMeshGrantAllWrites, cultMeshDocumentFromSingleFile, type CultMeshRudpDocumentPut } from "../src/index"; // A document written in the reference's positional shape, and a plain map document beside it. Every mesh reader of a // payload reads it the way the cache does, so both read through the mesh. @@ -47,15 +46,6 @@ const codecReceipt = defineDocumentType({ }, "Tests.CodecReceipt"), }); -// The same record under another schema id: the store holds it as codec_note, so it is found by parsing, not by id. -const codecNoteAlias = defineDocumentType({ - type: "cultmesh.codec_note_alias", - schemaId: "cultmesh.codec_note_alias.v0", - schema: z.object({ noteId: z.string(), body: z.string() }), - name: "noteId", - codec: codecNote.codec, -}); - const plainNote = defineDocumentType({ type: "cultmesh.plain_note", schemaId: "cultmesh.plain_note.v0", @@ -73,17 +63,6 @@ test("the mesh reads a stored codec document as the cache does, and a plain docu await plainNode.put(plainNote, "n2", { noteId: "n2", body: "map" }); assert.deepEqual(await cultMeshDocumentFromSingleFile(codecPath, codecNote).latest(), { noteId: "n1", body: "positional" }); - assert.deepEqual(await cultMeshDocumentFromSingleFile(codecPath, codecNoteAlias).latest(), { noteId: "n1", body: "positional" }); - // A store holding a plain record and a codec record, the alias found by parsing takes the codec one whichever the - // single-file store (which sorts records by key) puts first: the probe has to refuse the plain record, not meet it - // second. - for (const [plainKey, codecKey] of [["a-plain", "z-codec"], ["z-plain", "a-codec"]] as const) { - const bothPath = join(dir, `both-${plainKey}.ccmp`); - const bothNode = await CultMesh.startNode(bothPath, { documents: [plainNote, codecNote] }); - await bothNode.put(plainNote, plainKey, { noteId: plainKey, body: "map" }); - await bothNode.put(codecNote, codecKey, { noteId: codecKey, body: "positional" }); - assert.deepEqual(await cultMeshDocumentFromSingleFile(bothPath, codecNoteAlias).latest(), { noteId: codecKey, body: "positional" }); - } assert.deepEqual(await cultMeshDocumentFromSingleFile(plainPath, plainNote).latest(), { noteId: "n2", body: "map" }); }); @@ -92,16 +71,13 @@ test("the mesh syncs a codec document from a peer snapshot and returns a codec r const dir = await mkdtemp(join(tmpdir(), "cultmesh-ts-codec-sync-")); const source = await CultMesh.startNode(join(dir, "source.ccmp"), { documents: [codecNote, plainNote] }); await source.put(codecNote, "n1", { noteId: "n1", body: "synced positional" }); - // Two records at one key, in two shapes: a peer snapshot handle for the alias schema (which neither holds) accepts the - // one that parses as the codec. - await source.put(plainNote, "n3", { noteId: "n3", body: "map" }); - await source.put(codecNote, "n3", { noteId: "n3", body: "positional" }); const target = await CultMesh.startNode(join(dir, "target.ccmp"), { documents: [codecNote] }); const commandBinding = defineCultNetDocumentBinding({ definition: codecNote }); const plainBinding = defineCultNetDocumentBinding({ definition: plainNote }); const receiptBinding = defineCultNetDocumentBinding({ definition: codecReceipt }); const puts: unknown[] = []; const server = CultMesh.createRudpDocumentServer("cultmesh-ts-codec-server", connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry([commandBinding, plainBinding, receiptBinding]), getCache: () => source.cache, bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5, @@ -117,18 +93,12 @@ test("the mesh syncs a codec document from a peer snapshot and returns a codec r peer = await CultMesh.createRudpPeer("cultmesh-ts-codec-client", connectionId, `rudp://127.0.0.1:${server.bind.port}`, { resendDelayMs: 25, resendPollMs: 5, connectTimeoutMs: 1_000, }); - // The accepts probe reads the snapshot's payload as the codec: a plain decode would reject every codec snapshot. const synced = await target.syncDocumentFromPeerSnapshot(peer, codecNote, "n1", { timeoutMs: 1_000 }); assert.deepEqual(synced, { noteId: "n1", body: "synced positional" }); assert.deepEqual(target.getRequired(codecNote, "n1"), { noteId: "n1", body: "synced positional" }); - // A peer snapshot handle reads the same way, and accepts the snapshot by parsing it as the codec. + // A peer snapshot handle reads the same way. assert.deepEqual(await CultMesh.documentFromPeerSnapshot(peer, codecNote, "n1", { timeoutMs: 1_000 }).latest(), { noteId: "n1", body: "synced positional" }); - assert.deepEqual( - await CultMesh.documentFromPeerSnapshot(peer, codecNoteAlias, "n3", { timeoutMs: 1_000 }).latest(), - { noteId: "n3", body: "positional" }, - ); - const receipt = await CultMesh.publishRudpDocumentAndWaitForReceipt( "cultmesh-ts-codec-receipt", connectionId, `rudp://127.0.0.1:${server.bind.port}`, commandBinding, "n2", { noteId: "n2", body: "command" }, receiptBinding, @@ -162,6 +132,7 @@ test("a RUDP put naming a schema the server has no definition for reaches the ha const errors: Error[] = []; const puts: CultMeshRudpDocumentPut[] = []; const server = CultMesh.createRudpDocumentServer("cultmesh-ts-unregistered-server", connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry([known]), bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5, onError: (error) => errors.push(error), @@ -192,27 +163,6 @@ test("a RUDP put naming a schema the server has no definition for reaches the ha } }); -test("a store read takes the record the catalog matches to the schema and the definition parses, not the first that merely parses", async () => { - const target = defineDocumentType({ type: "cultmesh.pick", schemaId: "cultmesh.pick.v1", schema: z.object({ n: z.number() }) }); - const lenient = defineDocumentType({ type: "cultmesh.pick", schemaId: "cultmesh.pick.v1", schema: { parse: (value: unknown) => value } }); - const entry = (schemaId: string, compatibleSchemaIds?: readonly string[]) => ({ - schemaId, schemaName: schemaId, schemaVersion: `${schemaId}.v1`, contentHash: "h", canonicalSchemaJson: "{}", compatibleSchemaIds, - }); - const envelope = (key: string, schemaId: string, value: unknown, compatibleSchemaIds?: readonly string[]): CultCacheEnvelope => ({ - key, type: "cultmesh.pick", schemaId, storedAt: "2026-10-11T00:00:00.000Z", - payload: encodeDocumentPayload(lenient, value), catalogEntry: entry(schemaId, compatibleSchemaIds), - }); - // None carries the definition's own schema id, so the exact-id find misses. The first parses and is foreign; the - // second is matched by the catalog and does not parse; the third is matched and parses. - const records = [ - envelope("a", "foreign.v1", { n: 1 }), - envelope("b", "alias-bad.v1", { n: "x" }, ["cultmesh.pick.v1"]), - envelope("c", "alias-good.v1", { n: 2 }, ["cultmesh.pick.v1"]), - ]; - const store: CacheBackingStore = { pullAll: async () => records, push: async () => undefined, delete: async () => undefined }; - assert.deepEqual(await cultMeshDocumentFromStore(store, target).latest(), { n: 2 }); -}); - // A payload nested past the limit: MAX_PAYLOAD_DEPTH + 1 one-element arrays around a nil. const pastLimitPayload = () => documentPayloadFromBytes(Uint8Array.from([...new Array(MAX_PAYLOAD_DEPTH + 1).fill(0x91), 0xc0])); @@ -223,6 +173,7 @@ test("a payload past the nesting limit is refused at the network doors: a RUDP p const errors: unknown[] = []; const puts: CultMeshRudpDocumentPut[] = []; const server = CultMesh.createRudpDocumentServer("cultmesh-ts-depth-server", connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), documents: registry, bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5, onError: (error) => errors.push(error), diff --git a/packages/cultmesh-ts/test/content.test.ts b/packages/cultmesh-ts/test/content.test.ts index 9662bb86..7e3a357a 100644 --- a/packages/cultmesh-ts/test/content.test.ts +++ b/packages/cultmesh-ts/test/content.test.ts @@ -8,7 +8,7 @@ import { join } from "node:path"; import test, { mock } from "node:test"; import { execFileSync } from "node:child_process"; -import { EMPTY_SHA256, chunkRecordKey, packContent, sha256Hex } from "cultnet-ts/content"; +import { EMPTY_SHA256, chunkRecordKey, packContent, sha256Hex } from "@gamecult/cultnet-ts/content"; import { closeSync, constants as fsConstants, openSync, symlinkSync } from "node:fs"; import { diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 611492a3..90ff38f9 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -4,14 +4,15 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; -import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; +import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; import { z } from "zod"; -import { defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts"; +import { SchemaConflictError, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts"; import { CultNetDocumentRegistry, CultNetPeer, CultNetRudpSession, cultNetBuiltinSchemaRegistry, + cultNetTextError, decodeRudpPacket, defineCultNetDocumentBinding, encodeRudpPacket, @@ -19,8 +20,8 @@ import { type CultNetRudpPacket, type CultNetMessage, type CultNetSnapshotResponseRawMessage, -} from "cultnet-ts"; -import { CultMesh, CultMeshRudpUnservableDocumentError } from "../src/index"; +} from "@gamecult/cultnet-ts"; +import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpUnservableDocumentError } from "../src/index"; // A record's payload as the bytes a peer would put on the wire. const payloadOf = (value: unknown) => documentPayloadFromBytes(encodeWireValue(value)); @@ -35,7 +36,11 @@ const noteDocument = defineDocumentType({ name: "noteId", }); -const noteAliasDocument = defineDocumentType({ +type Note = z.infer; +type CultMeshDocumentSchemaDescriptorLike = { type?: string; schemaId?: string; schemaName?: string; schemaVersion?: string }; + +// Another type claiming noteDocument's schema. It is never registered beside noteDocument: one type owns a schema. +const otherTypeForNoteSchema = defineDocumentType({ type: "cultmesh.note.ui", schemaId: "cultmesh.note.v0", schema: z.object({ @@ -134,36 +139,142 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti await waitFor(() => observed.includes("updated"), "document handle update"); unsubscribe(); - const alias = bound.asSchemaAlias(noteAliasDocument, { - parse: value => noteAliasDocument.schema.parse(value), - }); - assert.equal((await alias.latest()).body, "updated"); - const catalog = CultMesh.documents(document); - assert.equal(catalog.canReplace(noteAliasDocument), true); - await catalog.authoritativeWriter(noteAliasDocument, { - parse: value => noteAliasDocument.schema.parse(value), + assert.equal(catalog.canReplace(noteDocument), true); + await catalog.authoritativeWriter(noteDocument, { context: "browser-client", }).write({ noteId: "note:1", body: "catalog-updated", }); assert.equal( - (await catalog.latest(noteAliasDocument, "browser-client", { - parse: value => noteAliasDocument.schema.parse(value), - })).body, + (await catalog.latest(noteDocument, "browser-client")).body, "catalog-updated", ); + // A typed lookup is answered by its own type's handle; a handle of another type for the same schema is not it. + assert.equal(catalog.tryDocument(otherTypeForNoteSchema), undefined); + assert.equal(catalog.canReplace(otherTypeForNoteSchema), false); + assert.throws(() => catalog.document(otherTypeForNoteSchema), /no document for/); const incremented = await bound.authoritativeWriter().update(value => ({ ...value, body: `${value.body}:updated-through-bound-set`, })); assert.equal(incremented.body, "catalog-updated:updated-through-bound-set"); assert.equal((await document.latest()).body, "catalog-updated:updated-through-bound-set"); - assert.throws( - () => document.asSchemaAlias({ schemaId: "cultmesh.other.v0" }), - /not compatible/, - ); +}); + +test("CultMesh TS catalogs infer a lookup's value type from its document definition", async () => { + const documents = CultMesh.documents(CultMesh.document("note:typed", noteDocument, async () => ({ noteId: "note:typed", body: "typed" }))); + const collections = CultMesh.collections(CultMesh.collection("notes:typed", noteDocument, async () => [{ noteId: "note:typed", body: "typed" }])); + const inferred: Note = await documents.latest(noteDocument); + const inferredRows: readonly Note[] = await collections.latest(noteDocument); + assert.equal(inferred.body, "typed"); + assert.equal(inferredRows[0]?.body, "typed"); + // @ts-expect-error a definition names its value type: body is a string + const wrong: number = (await documents.latest(noteDocument)).body; + // @ts-expect-error a collection lookup infers its rows the same way + const wrongRows: readonly number[] = await collections.latest(noteDocument); + // @ts-expect-error a bare schema descriptor yields unknown + void (await documents.latest({ schemaId: "cultmesh.note.v0" })).body; + const explicit: { body: string } = await documents.latest<{ body: string }>({ schemaId: "cultmesh.note.v0" }); + assert.equal(explicit.body, "typed"); + void wrong; + void wrongRows; + + // Writes are checked against the definition's value type. Never called: the handle is read-only. + const typeOnly = () => { + // @ts-expect-error replace checks the value against the definition + void documents.replace(noteDocument, { noteId: "note:typed", body: 1 }); + // @ts-expect-error so does a prediction, with a context + void documents.submitPrediction(noteDocument, "local", { noteId: "note:typed" }); + documents.watch(noteDocument, value => { + // @ts-expect-error a watched value is the definition's value + const n: number = value.body; + void n; + }); + // @ts-expect-error a writer writes the definition's value + void documents.authoritativeWriter(noteDocument).write({ noteId: 1, body: "x" }); + }; + void typeOnly; +}); + +test("CultMesh TS catalogs hold one type per schema and refuse a second, typed", () => { + const sameNameVersion = { type: "cultmesh.note.named", schemaId: "sha256:note-named", schemaName: "cultmesh.note", schemaVersion: "cultmesh.note.v0" }; + const sameTypeOtherSchema = { type: "cultmesh.note", schemaId: "cultmesh.note.v9" }; + const note = { noteId: "note:held", body: "held" }; + const kinds = [ + { + catalog: (...schemas: CultMeshDocumentSchemaDescriptorLike[]) => + CultMesh.documents(...schemas.map((schema, index) => CultMesh.document(`doc:${index}`, schema, async () => note))), + handle: (schema: CultMeshDocumentSchemaDescriptorLike, id: string) => CultMesh.document(id, schema, async () => note), + find: (catalog: any, schema: CultMeshDocumentSchemaDescriptorLike) => catalog.document(schema).documentId, + }, + { + catalog: (...schemas: CultMeshDocumentSchemaDescriptorLike[]) => + CultMesh.collections(...schemas.map((schema, index) => CultMesh.collection(`doc:${index}`, schema, async () => [note]))), + handle: (schema: CultMeshDocumentSchemaDescriptorLike, id: string) => CultMesh.collection(id, schema, async () => [note]), + find: (catalog: any, schema: CultMeshDocumentSchemaDescriptorLike) => catalog.collection(schema).collectionId, + }, + ]; + for (const kind of kinds) { + for (const claimant of [otherTypeForNoteSchema, sameNameVersion, sameTypeOtherSchema]) { + for (const [first, second] of [[noteDocument, claimant], [claimant, noteDocument]] as const) { + const catalog = kind.catalog(first); + assert.throws(() => catalog.add(kind.handle(second, "doc:second") as any), (error: unknown) => { + assert.ok(error instanceof SchemaConflictError, String(error)); + assert.equal(error.schemaId, second.schemaId); + assert.deepEqual(error.schemaNames, ["cultmesh.note", "cultmesh.note"]); + assert.ok(error.message.includes(`type "${second.type}"`) && error.message.includes(`type "${first.type}"`), error.message); + assert.equal(error.recordKey, ""); + return true; + }); + assert.throws(() => kind.catalog(first, second), SchemaConflictError); + // The refused handle left nothing behind: the first type still answers. + assert.equal(kind.find(catalog, first), "doc:0"); + assert.equal(kind.find(catalog, { schemaId: first.schemaId }), "doc:0"); + } + } + // Handles of one type for one schema may repeat, the same handle may be added again, and an untyped handle claims no type. + const held = kind.handle(noteDocument, "doc:held"); + const catalog = kind.catalog(noteDocument); + catalog.add(held as any).add(held as any); + catalog.add(kind.handle({ schemaId: "cultmesh.note.v0" }, "doc:untyped") as any); + assert.equal(kind.find(catalog, noteDocument), "doc:held"); + } +}); + +test("CultMesh TS catalog writes take (value, options) or (context, value)", async () => { + const writes: string[] = []; + let current = { noteId: "note:writes", body: "initial" }; + const record = (kind: string) => async (context: { runtimeId: string }, value: Note) => { + writes.push(`${kind}:${context.runtimeId}:${value.body}`); + current = value; + }; + const catalog = CultMesh.documents(CultMesh.document("note:writes", noteDocument, async () => current, { + replaceDocument: record("replace"), + submitPrediction: record("predict"), + })); + const note = (body: string) => ({ noteId: "note:writes", body }); + + await catalog.replace(noteDocument, note("r1")); + await catalog.replace(noteDocument, note("r2"), {}); + await catalog.replace(noteDocument, note("r3"), { context: "pilot-a" }); + await catalog.replace(noteDocument, "pilot-b", note("r4")); + await catalog.replace(noteDocument, CultMesh.queryContext("pilot-c"), note("r5")); + await catalog.submitPrediction(noteDocument, note("p1")); + await catalog.submitPrediction(noteDocument, note("p2"), { context: "pilot-a" }); + await catalog.submitPrediction(noteDocument, "pilot-b", note("p3")); + + assert.deepEqual(writes, [ + "replace:local:r1", + "replace:local:r2", + "replace:pilot-a:r3", + "replace:pilot-b:r4", + "replace:pilot-c:r5", + "predict:local:p1", + "predict:pilot-a:p2", + "predict:pilot-b:p3", + ]); }); test("CultMesh TS document handles submit predictions through configured authority hooks", async () => { @@ -193,10 +304,10 @@ test("CultMesh TS document handles submit predictions through configured authori assert.equal(document.canReplace, false); assert.equal(document.canSubmitPrediction, true); assert.equal(bound.canSubmitPrediction, true); - assert.equal(catalog.canSubmitPrediction(noteAliasDocument), true); + assert.equal(catalog.canSubmitPrediction(noteDocument), true); + assert.equal(catalog.canSubmitPrediction(otherTypeForNoteSchema), false); - await catalog.predictionWriter(noteAliasDocument, { - parse: value => noteAliasDocument.schema.parse(value), + await catalog.predictionWriter(noteDocument, { context: "pilot-a", }).write({ noteId: "note:prediction", @@ -207,23 +318,19 @@ test("CultMesh TS document handles submit predictions through configured authori assert.deepEqual(predictions, ["predicted"]); assert.equal((await bound.latest()).body, "predicted"); - const alias = bound.asSchemaAlias(noteAliasDocument, { - parse: value => noteAliasDocument.schema.parse(value), - }); - assert.equal(alias.canSubmitPrediction, true); - await alias.predictionWriter().write({ + await bound.predictionWriter().write({ noteId: "note:prediction", - body: "alias-predicted", + body: "bound-predicted", }); - assert.deepEqual(predictions, ["predicted", "alias-predicted"]); - assert.equal((await document.latest("pilot-a")).body, "alias-predicted"); + assert.deepEqual(predictions, ["predicted", "bound-predicted"]); + assert.equal((await document.latest("pilot-a")).body, "bound-predicted"); const updated = await document.predictionWriter("pilot-a").update(value => ({ ...value, body: "updated-as-prediction", })); assert.equal(updated.body, "updated-as-prediction"); - assert.deepEqual(predictions, ["predicted", "alias-predicted", "updated-as-prediction"]); + assert.deepEqual(predictions, ["predicted", "bound-predicted", "updated-as-prediction"]); assert.throws( () => document.replace({ @@ -262,57 +369,42 @@ test("CultMesh TS documents with both capabilities require an immutable writer c assert.deepEqual(writes, ["authoritative:canonical", "prediction:predicted"]); }); -test("CultMesh TS store document handles choose compatible foreign schema payloads", async () => { - const store = { - async pullAll() { - return [ - { - key: "note:foreign", - type: "runtime.generated.incompatible-note", - schemaId: "runtime.generated.incompatible-note.v1", - storedAt: "2026-06-27T00:00:00Z", - payload: payloadOf({ nope: "not the requested shape" }), - catalogEntry: { - schemaId: "runtime.generated.incompatible-note.v1", - schemaName: "runtime.generated.incompatible-note", - schemaVersion: "runtime.generated.incompatible-note.v1", - contentHash: "runtime.generated.incompatible-note.v1", - canonicalSchemaJson: "{}", - compatibleSchemaIds: ["runtime.generated.incompatible-note.v1"], - }, - }, - { - key: "note:foreign", - type: "runtime.generated.cultmesh.note", - schemaId: "runtime.generated.cultmesh.note.ui.42", - storedAt: "2026-06-27T00:00:01Z", - payload: payloadOf({ - noteId: "note:foreign", - body: "foreign schema id, local store shape", - }), - catalogEntry: { - schemaId: "runtime.generated.cultmesh.note.ui.42", - schemaName: "cultmesh.note", - schemaVersion: "cultmesh.note.v1", - contentHash: "runtime.generated.cultmesh.note.ui.42", - canonicalSchemaJson: "{}", - compatibleSchemaIds: ["runtime.generated.cultmesh.note.ui.42"], - }, - }, - ]; - }, +// A store document reads the record under its own schema id, or under an id its definition lists as compatible. A record +// under any other id is another type's, and is never read in its place because its payload happens to parse. +test("CultMesh TS store document handles read only records under an id their definition declares", async () => { + const envelope = (schemaId: string, value: unknown) => ({ + key: "note:store", + type: schemaId, + schemaId, + storedAt: "2026-06-27T00:00:00Z", + payload: payloadOf(value), + }); + const storeOf = (...records: ReturnType[]) => ({ + async pullAll() { return records; }, async push() {}, async delete() {}, - }; - - const document = CultMesh.documentFromStore(store, noteAliasDocument, { - documentId: "note:foreign.store", }); + const foreign = envelope("runtime.generated.cultmesh.note.ui.42", { noteId: "note:store", body: "foreign id, local shape" }); - assert.deepEqual(await document.latest(), { - noteId: "note:foreign", - body: "foreign schema id, local store shape", + await assert.rejects( + CultMesh.documentFromStore(storeOf(foreign), noteDocument).latest(), + /did not contain schema cultmesh\.note\.v0/, + ); + + const own = envelope("cultmesh.note.v0", { noteId: "note:store", body: "own id" }); + assert.equal((await CultMesh.documentFromStore(storeOf(foreign, own), noteDocument).latest()).body, "own id"); + + const renamed = defineDocumentType({ + type: "cultmesh.note-renamed", + schemaId: "cultmesh.note-renamed.v1", + compatibleSchemaIds: ["cultmesh.note.v0"], + schema: noteDocument.schema, }); + assert.equal((await CultMesh.documentFromStore(storeOf(foreign, own), renamed).latest()).body, "own id"); + await assert.rejects(CultMesh.documentFromStore(storeOf(foreign), renamed).latest(), /did not contain schema/); + // A record under the definition's own id wins over one under an id it lists, wherever each sits in the store. + const renamedOwn = envelope("cultmesh.note-renamed.v1", { noteId: "note:store", body: "renamed own id" }); + assert.equal((await CultMesh.documentFromStore(storeOf(own, renamedOwn), renamed).latest()).body, "renamed own id"); }); test("CultMesh TS reactive documents submit predictions from explicit updates", async () => { @@ -401,7 +493,7 @@ test("CultMesh TS reactive updates publish nested edits while snapshot mutation reactive.dispose(); }); -test("CultMesh TS node opens same-schema aliases with one call", async () => { +test("CultMesh TS node opens its registered document with one call", async () => { const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-node-reactive-")), "node.ccmp"); const node = await CultMesh.startNode(filePath, { documents: [noteDocument], @@ -411,34 +503,25 @@ test("CultMesh TS node opens same-schema aliases with one call", async () => { body: "initial", }); - const aliasHandle = node.document(noteAliasDocument, "note:node-reactive", { + const handle = node.document(noteDocument, "note:node-reactive", { pollMs: 5, }); - assert.equal(aliasHandle.documentId, "cultmesh.note.ui:note:node-reactive"); - assert.equal((await aliasHandle.latest()).body, "initial"); - await aliasHandle.authoritativeWriter().write({ + assert.equal(handle.documentId, "cultmesh.note:note:node-reactive"); + assert.equal((await handle.latest()).body, "initial"); + await handle.authoritativeWriter().write({ noteId: "note:node-reactive", - body: "edited-through-alias-handle", - }); - assert.equal( - node.getRequired(noteAliasDocument, "note:node-reactive").body, - "edited-through-alias-handle", - ); - - await node.put(noteAliasDocument, "note:node-reactive", { - noteId: "note:node-reactive", - body: "edited-through-alias-put", + body: "edited-through-handle", }); assert.equal( node.getRequired(noteDocument, "note:node-reactive").body, - "edited-through-alias-put", + "edited-through-handle", ); assert.deepEqual( - (await node.collection(noteAliasDocument).latest()).map(note => note.body), - ["edited-through-alias-put"], + (await node.collection(noteDocument).latest()).map(note => note.body), + ["edited-through-handle"], ); - const reactive = aliasHandle.authoritativeWriter( + const reactive = handle.authoritativeWriter( CultMesh.queryContext("browser-client"), ).reactive({ watch: false, @@ -449,18 +532,12 @@ test("CultMesh TS node opens same-schema aliases with one call", async () => { }); await waitFor( () => node.getRequired(noteDocument, "note:node-reactive").body === "edited-through-node-helper", - "node-level reactive alias write", + "node-level reactive write", ); assert.equal(reactive.snapshot.noteId, "note:node-reactive"); assert.equal( - node.document(noteDocument, "note:node-reactive").asSchemaAlias(noteAliasDocument, { - parse: value => noteAliasDocument.schema.parse(value), - }).canReplace, - true, - ); - assert.equal( - (await node.document(noteAliasDocument, "note:node-reactive").observe({ + (await node.document(noteDocument, "note:node-reactive").observe({ watch: false, }).ready).body, "edited-through-node-helper", @@ -468,6 +545,33 @@ test("CultMesh TS node opens same-schema aliases with one call", async () => { reactive.dispose(); }); +test("CultMesh TS node refuses a type it did not register, even one claiming a registered schema", async () => { + const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-node-other-type-")), "node.ccmp"); + const node = await CultMesh.startNode(filePath, { + documents: [noteDocument], + }); + await node.put(noteDocument, "note:owned", { noteId: "note:owned", body: "owned" }); + const notRegistered = /"cultmesh\.note\.ui" is not registered/; + + assert.throws(() => node.get(otherTypeForNoteSchema, "note:owned"), notRegistered); + await assert.rejects( + node.put(otherTypeForNoteSchema, "note:owned", { noteId: "note:owned", body: "other" }), + notRegistered, + ); + await assert.rejects(node.delete(otherTypeForNoteSchema, "note:owned"), notRegistered); + await assert.rejects(node.document(otherTypeForNoteSchema, "note:owned").latest(), notRegistered); + await assert.rejects(node.collection(otherTypeForNoteSchema).latest(), notRegistered); + assert.equal(node.getRequired(noteDocument, "note:owned").body, "owned"); + + // The node cannot come to hold the other type beside its owner either. + await assert.rejects( + CultMesh.startNode(join(await mkdtemp(join(tmpdir(), "cultmesh-ts-node-two-types-")), "node.ccmp"), { + documents: [noteDocument, otherTypeForNoteSchema], + }), + (error: unknown) => error instanceof SchemaConflictError && error.schemaId === "cultmesh.note.v0", + ); +}); + test("CultMesh TS reactive documents store reconciliation deltas after misprediction", async () => { let current = { noteId: "note:reconcile", @@ -949,15 +1053,13 @@ test("CultMesh TS binds publication document catalogs from source resolvers", as ["daemon:first", "daemon:second"], ); assert.equal( - (await catalog.document(noteAliasDocument, { - parse: value => noteAliasDocument.schema.parse(value), - }).latest()).body, + (await catalog.document(noteDocument).latest()).body, "second source", ); assert.equal(catalog.document(noteDocument).routeHint.description, "publication catalog"); }); -test("CultMesh TS syncs configured publications into local node aliases", async () => { +test("CultMesh TS syncs configured publications into a local node", async () => { const sourcePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-source-")), "source.ccmp"); const targetPath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-target-")), "target.ccmp"); const source = await CultMesh.startNode(sourcePath, { @@ -965,7 +1067,7 @@ test("CultMesh TS syncs configured publications into local node aliases", async }); await source.put(noteDocument, "note:published", { noteId: "note:published", - body: "publication source hydrates local alias", + body: "publication source hydrates the local node", }); await source.flush(); const target = await CultMesh.startNode(targetPath, { @@ -977,7 +1079,7 @@ test("CultMesh TS syncs configured publications into local node aliases", async kind: "single-file", path: sourcePath, }, - noteAliasDocument, + noteDocument, "note:published", ); const facadeSynced = await CultMesh.syncDocumentFromPublication( @@ -986,21 +1088,20 @@ test("CultMesh TS syncs configured publications into local node aliases", async kind: "single-file", path: sourcePath, }, - noteAliasDocument, + noteDocument, "note:published", ); assert.deepEqual(synced, { noteId: "note:published", - body: "publication source hydrates local alias", + body: "publication source hydrates the local node", }); assert.deepEqual(facadeSynced, synced); - assert.equal(target.getRequired(noteDocument, "note:published").body, "publication source hydrates local alias"); - assert.equal(target.getRequired(noteAliasDocument, "note:published").body, "publication source hydrates local alias"); - assert.equal((await target.document(noteAliasDocument, "note:published").observe({ watch: false }).ready).body, "publication source hydrates local alias"); + assert.equal(target.getRequired(noteDocument, "note:published").body, "publication source hydrates the local node"); + assert.equal((await target.document(noteDocument, "note:published").observe({ watch: false }).ready).body, "publication source hydrates the local node"); }); -test("CultMesh TS syncs configured publication catalogs into local node aliases", async () => { +test("CultMesh TS syncs configured publication catalogs into a local node", async () => { const firstPath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-catalog-first-")), "first.ccmp"); const secondPath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-catalog-second-")), "second.ccmp"); const targetPath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-publication-sync-catalog-target-")), "target.ccmp"); @@ -1027,7 +1128,7 @@ test("CultMesh TS syncs configured publication catalogs into local node aliases" CultMesh.publicationDocument(noteDocument, "note:first-sync", { documentId: "local:first", }), - CultMesh.publicationDocument(noteAliasDocument, "note:second-sync", { + CultMesh.publicationDocument(noteDocument, "note:second-sync", { documentId: "local:second", source: { kind: "single-file", @@ -1056,37 +1157,27 @@ test("CultMesh TS syncs configured publication catalogs into local node aliases" bindings, ); + // Two documents of one type, each addressed by its own id whatever order the catalog lists them in. assert.deepEqual( - catalog.documents.map(document => document.documentId), + catalog.documents.map(document => document.documentId).sort(), ["local:first", "local:second"], ); - assert.equal( - await catalog.document(noteDocument, { - parse: value => noteDocument.schema.parse(value), - }).latest().then(value => value.body), - "first synced publication", - ); - assert.equal( - await catalog.document(noteAliasDocument, { - parse: value => noteAliasDocument.schema.parse(value), - }).latest().then(value => value.body), - "second synced publication", - ); - assert.equal(catalog.document(noteAliasDocument).routeHint.description, "publication sync catalog"); + const secondSynced = catalog.documentById("local:second"); + assert.equal((await catalog.documentById("local:first").latest()).body, "first synced publication"); + assert.equal((await secondSynced.latest()).body, "second synced publication"); + assert.equal(secondSynced.documentId, "local:second"); + assert.equal(secondSynced.routeHint.description, "publication sync catalog"); assert.equal(target.getRequired(noteDocument, "note:second-sync").body, "second synced publication"); - assert.equal(target.getRequired(noteAliasDocument, "note:second-sync").body, "second synced publication"); - assert.equal( - await facadeCatalog.document(noteAliasDocument, { - parse: value => noteAliasDocument.schema.parse(value), - }).latest().then(value => value.body), - "second synced publication", - ); + assert.equal((await facadeCatalog.documentById("local:second").latest()).body, "second synced publication"); + assert.equal((await facadeCatalog.documentById("local:first").latest()).body, "first synced publication"); + assert.equal(catalog.tryDocumentById("note:second-sync"), undefined); + assert.throws(() => catalog.documentById("local:third"), /no document with id 'local:third'/); }); test("CultMesh TS document catalogs resolve semantic schema versions passed as schema ids", async () => { const current = { noteId: "note:semantic", - body: "semantic alias", + body: "semantic version", }; const document = CultMesh.document( "daemon:semantic-note", @@ -1097,18 +1188,18 @@ test("CultMesh TS document catalogs resolve semantic schema versions passed as s }, async () => current, { - routeHint: CultMesh.routeHint("shared-memory", "semantic alias catalog"), + routeHint: CultMesh.routeHint("shared-memory", "semantic version catalog"), }, ); const catalog = CultMesh.documents(document); - const alias = catalog.document({ + const bySchemaVersion = catalog.document({ schemaId: "cultmesh.note.v1", }); - assert.equal(alias.documentId, "daemon:semantic-note"); - assert.equal(alias.routeHint.description, "semantic alias catalog"); - assert.deepEqual(await alias.latest(), current); + assert.equal(bySchemaVersion.documentId, "daemon:semantic-note"); + assert.equal(bySchemaVersion.routeHint.description, "semantic version catalog"); + assert.deepEqual(await bySchemaVersion.latest(), current); }); test("CultMesh TS collection handles expose typed snapshots and reset watches", async () => { @@ -1138,19 +1229,14 @@ test("CultMesh TS collection handles expose typed snapshots and reset watches", await waitFor(() => changes.length >= 2, "collection reset after update"); unsubscribe(); - assert.deepEqual( - (await collection.asSchemaAlias(noteAliasDocument, { - parse: value => noteAliasDocument.schema.parse(value), - }).latest()).map(note => note.body).sort(), - ["alpha", "bravo"], - ); const catalog = CultMesh.collections(collection); assert.deepEqual( - (await catalog.latest(noteAliasDocument, "local", { - parse: value => noteAliasDocument.schema.parse(value), - })).map(note => note.body).sort(), + (await catalog.latest(noteDocument, "local")).map(note => note.body).sort(), ["alpha", "bravo"], ); + // A typed lookup is answered by its own type's handle; a handle of another type for the same schema is not it. + assert.equal(catalog.tryCollection(otherTypeForNoteSchema), undefined); + assert.throws(() => catalog.collection(otherTypeForNoteSchema), /no collection for/); assert.equal( catalog.collection({ schemaId: "cultmesh.note.v0" }).collectionId, collection.collectionId, @@ -1159,7 +1245,7 @@ test("CultMesh TS collection handles expose typed snapshots and reset watches", assert.ok(changes.every(kind => kind === "reset")); }); -test("CultMesh TS collection catalogs resolve same-schema alias watches", async () => { +test("CultMesh TS collection catalogs resolve watches by schema version", async () => { const current = [{ noteId: "note:catalog-watch", body: "initial", @@ -1185,27 +1271,25 @@ test("CultMesh TS collection catalogs resolve same-schema alias watches", async ); const catalog = CultMesh.collections(collection); const observed: string[] = []; - const semanticAlias = { schemaId: "cultmesh.note.v1" }; - const unsubscribe = catalog.watchChanges>(semanticAlias, change => { + const bySchemaVersion = { schemaId: "cultmesh.note.v1" }; + const unsubscribe = catalog.watchChanges>(bySchemaVersion, change => { if (change.value) { observed.push(change.value.body); } - }, { - parse: value => noteAliasDocument.schema.parse(value), }); watcher?.({ kind: "updated", value: { noteId: "note:catalog-watch", - body: "through-catalog-alias", + body: "through-catalog", }, }); unsubscribe(); - assert.deepEqual(observed, ["through-catalog-alias"]); + assert.deepEqual(observed, ["through-catalog"]); assert.equal(watcher, undefined); - assert.equal(catalog.collection(semanticAlias).routeHint.description, "collection catalog"); + assert.equal(catalog.collection(bySchemaVersion).routeHint.description, "collection catalog"); }); test("CultMesh TS local authority leases do not trust peer cards by contact alone", () => { @@ -2773,6 +2857,7 @@ test("CultMesh TS RUDP document server accepts raw document puts from multiple p "cultmesh-ts-document-server", connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry(), bindHost: "127.0.0.1", bindPort: 0, @@ -2861,6 +2946,7 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async () "cultmesh-ts-odin-catalog", connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry(), bindHost: "127.0.0.1", bindPort: 0, @@ -2936,6 +3022,7 @@ test("CultMesh TS returns a correlated provider receipt after a RUDP document mu const commandBinding = defineCultNetDocumentBinding({ definition: noteDocument }); const receiptBinding = defineCultNetDocumentBinding({ definition: noteReceiptDocument }); const server = CultMesh.createRudpDocumentServer("cultmesh-ts-receipt-server", connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry([commandBinding, receiptBinding]), bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5, onDocumentPutRaw: async (document) => ({ @@ -2971,6 +3058,7 @@ test("CultMesh TS reads remote RUDP snapshots through document handles", async ( "cultmesh-ts-rudp-snapshot-server", connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry([ defineCultNetDocumentBinding({ definition: noteDocument }), ]), @@ -3021,33 +3109,31 @@ test("CultMesh TS reads remote RUDP snapshots through document handles", async ( body: "remote handles feel local", }); assert.equal(document.schema.type, "cultmesh.note"); - - const alias = CultMesh.documentFromPeerSnapshot( - peer, - defineDocumentType({ - type: "cultmesh.note.alias", - schemaId: "cultmesh.note.alias.v1", - schema: noteDocument.schema, - name: "noteId", - }), - "note:remote", - { - documentId: "note:remote.alias", - timeoutMs: 1_000, - }, - ); - - assert.deepEqual(await alias.latest(), { - noteId: "note:remote", - body: "remote handles feel local", - }); } finally { peer?.close(); server.close(); } }); -test("CultMesh TS syncs remote RUDP snapshots into a local node with same-schema aliases", async () => { +// What ships (F8): a node's reads and puts return the object its cache holds, not a copy, and the README says that object +// belongs to the cache. The caller's own value is never the one held. +test("CultMesh TS node reads and puts return the cache's own object", async () => { + const node = await CultMesh.startNode( + join(await mkdtemp(join(tmpdir(), "cultmesh-ts-held-object-")), "node.ccmp"), + { documents: [noteDocument] }, + ); + const written = { noteId: "note:held", body: "held" }; + const put = await node.put(noteDocument, "note:held", written); + assert.notEqual(put, written); + assert.deepEqual(put, written); + assert.equal(node.get(noteDocument, "note:held"), put); + assert.equal(node.getRequired(noteDocument, "note:held"), put); + assert.equal(node.get(noteDocument, "note:held"), node.get(noteDocument, "note:held")); + written.body = "changed by the caller"; + assert.equal(node.getRequired(noteDocument, "note:held").body, "held"); +}); + +test("CultMesh TS syncs remote RUDP snapshots into a local node", async () => { const connectionId = 0x1020304a; const source = await CultMesh.startNode( join(await mkdtemp(join(tmpdir(), "cultmesh-ts-rudp-sync-source-")), "node.ccmp"), @@ -3070,6 +3156,7 @@ test("CultMesh TS syncs remote RUDP snapshots into a local node with same-schema "cultmesh-ts-rudp-sync-server", connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry([ defineCultNetDocumentBinding({ definition: noteDocument }), ]), @@ -3101,7 +3188,7 @@ test("CultMesh TS syncs remote RUDP snapshots into a local node with same-schema const synced = await target.syncDocumentFromPeerSnapshot( peer, - noteAliasDocument, + noteDocument, "note:sync-remote", { timeoutMs: 1_000, @@ -3110,7 +3197,7 @@ test("CultMesh TS syncs remote RUDP snapshots into a local node with same-schema const facadeSynced = await CultMesh.syncDocumentFromPeerSnapshot( target, peer, - noteAliasDocument, + noteDocument, "note:sync-remote", { timeoutMs: 1_000, @@ -3123,39 +3210,40 @@ test("CultMesh TS syncs remote RUDP snapshots into a local node with same-schema }); assert.deepEqual(facadeSynced, synced); assert.equal(target.getRequired(noteDocument, "note:sync-remote").body, "synced once, read as local"); - assert.equal(target.getRequired(noteAliasDocument, "note:sync-remote").body, "synced once, read as local"); - assert.equal((await target.document(noteAliasDocument, "note:sync-remote").latest()).body, "synced once, read as local"); + assert.equal((await target.document(noteDocument, "note:sync-remote").latest()).body, "synced once, read as local"); } finally { peer?.close(); server.close(); } }); -test("CultMesh TS peer snapshot handles choose compatible foreign schema payloads", async () => { +// A peer snapshot is read, and synced, only under the requested type's schema id. A record the peer holds at the key under +// another id is another type's: it is not-found for this type, and syncing never writes it anywhere. +test("CultMesh TS peer snapshots never take another type's record at the key", async () => { const connectionId = 0x10203049; - const node = await CultMesh.startNode( + const source = await CultMesh.startNode( join(await mkdtemp(join(tmpdir(), "cultmesh-ts-rudp-foreign-snapshot-")), "node.ccmp"), - { - documents: [incompatibleNoteDocument, foreignNoteDocument], - }, + { documents: [incompatibleNoteDocument, foreignNoteDocument] }, ); - await node.put(incompatibleNoteDocument, "note:foreign", { - nope: "not the requested shape", - }); - await node.put(foreignNoteDocument, "note:foreign", { - noteId: "note:foreign", - body: "foreign schema id, local shape", - }); + await source.put(incompatibleNoteDocument, "note:b", { nope: "remote b" }); + // Parses as a note: shape routing would store it as one. + await source.put(foreignNoteDocument, "note:c", { noteId: "note:c", body: "remote c, note-shaped" }); + const target = await CultMesh.startNode( + join(await mkdtemp(join(tmpdir(), "cultmesh-ts-rudp-foreign-target-")), "node.ccmp"), + { documents: [noteDocument, incompatibleNoteDocument] }, + ); + await target.put(incompatibleNoteDocument, "note:b", { nope: "local b" }); const server = CultMesh.createRudpDocumentServer( "cultmesh-ts-rudp-foreign-snapshot-server", connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry([ defineCultNetDocumentBinding({ definition: incompatibleNoteDocument }), defineCultNetDocumentBinding({ definition: foreignNoteDocument }), ]), - getCache: () => node.cache, + getCache: () => source.cache, bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, @@ -3181,20 +3269,22 @@ test("CultMesh TS peer snapshot handles choose compatible foreign schema payload }, ); - const alias = CultMesh.documentFromPeerSnapshot( - peer, - noteAliasDocument, - "note:foreign", - { - documentId: "note:foreign.alias", - timeoutMs: 1_000, - }, + for (const key of ["note:b", "note:c"]) { + const notFound = new RegExp(`did not return cultmesh\\.note\\.v0 at ${key}`); + await assert.rejects( + CultMesh.documentFromPeerSnapshot(peer, noteDocument, key, { timeoutMs: 1_000 }).latest(), + notFound, + ); + await assert.rejects(target.syncDocumentFromPeerSnapshot(peer, noteDocument, key, { timeoutMs: 1_000 }), notFound); + assert.equal(target.get(noteDocument, key), undefined, key); + } + // The failed sync of a note at "note:b" left the local record of the other type there untouched. + assert.deepEqual(target.getRequired(incompatibleNoteDocument, "note:b"), { nope: "local b" }); + // Its own type's record under its own id still syncs. + assert.deepEqual( + await target.syncDocumentFromPeerSnapshot(peer, incompatibleNoteDocument, "note:b", { timeoutMs: 1_000 }), + { nope: "remote b" }, ); - - assert.deepEqual(await alias.latest(), { - noteId: "note:foreign", - body: "foreign schema id, local shape", - }); } finally { peer?.close(); server.close(); @@ -3390,6 +3480,7 @@ async function withServedBoundRig( const messages: CultNetMessage[] = []; const unparsed: Error[] = []; const server = CultMesh.createRudpDocumentServer(SERVED_BOUND_SERVER, connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), documents: registry, getCache: () => node.cache, bindHost: "127.0.0.1", @@ -3551,6 +3642,7 @@ async function withRawPeerRig( for (const put of stored) await registry.applyRawDocumentPutMessage(node.cache, put); const errors: Error[] = []; const server = CultMesh.createRudpDocumentServer(SERVED_BOUND_SERVER, connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), documents: registry, getCache: () => (getCache ? getCache(node.cache) : node.cache) as typeof node.cache, bindHost: "127.0.0.1", @@ -3625,7 +3717,7 @@ test("CultMesh TS RUDP document server answers a peer whose reliable queue is fu while (!refusalIn(rig.inbox) && Date.now() - startedAt < 3_000) await delay(5); const refusal = refusalIn(rig.inbox); assert.ok(refusal, "the refusal reaches the peer"); - assert.deepEqual(refusal.message, { schemaVersion: "cultnet.error.v0", error: "the snapshot response could not be queued" }); + assert.deepEqual(refusal.message, cultNetTextError("the snapshot response could not be queued")); for (const part of refusal.parts) { assert.ok(refused.every((sequence) => !acknowledges(part, sequence)), `ack ${part.ack} mask ${part.ackMask}`); } @@ -3642,7 +3734,7 @@ test("CultMesh TS RUDP document server refuses a snapshot with fixed text, never while (!refusalIn(rig.inbox) && Date.now() - startedAt < 3_000) await delay(5); const refusal = refusalIn(rig.inbox); assert.ok(refusal, "the refusal reaches the peer"); - assert.deepEqual(refusal.message, { schemaVersion: "cultnet.error.v0", error: "the snapshot source failed" }); + assert.deepEqual(refusal.message, cultNetTextError("the snapshot source failed")); assert.match(rig.errors[0]?.message ?? "", /CANARY-7f3a/); }); }); diff --git a/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts b/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts index e7e04c1a..f22d5af3 100644 --- a/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts +++ b/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts @@ -18,8 +18,8 @@ // harness can dial a peer late and exercise Cut C's retained-state seed // instead of the provider always tearing down the moment it is done sending. // -// Neither mode imports `cultmesh-ts` itself (the harness drives processes, -// and the dependency direction is `cultmesh-ts` -> `cultnet-ts`, never the +// Neither mode imports `@gamecult/cultmesh-ts` itself (the harness drives processes, +// and the dependency direction is `@gamecult/cultmesh-ts` -> `@gamecult/cultnet-ts`, never the // reverse); this script reaches the provider/connector through its own // package's `src/`. diff --git a/packages/cultmesh-ts/test/payload-type.test.ts b/packages/cultmesh-ts/test/payload-type.test.ts index cdba3505..f04541da 100644 --- a/packages/cultmesh-ts/test/payload-type.test.ts +++ b/packages/cultmesh-ts/test/payload-type.test.ts @@ -8,8 +8,8 @@ import { documentPayloadFromBytes, type DocumentPayload, } from "@gamecult/cultcache-ts"; -import { decodeWireValue } from "cultnet-ts/wire"; -import type { CultNetRawDocumentRecord } from "cultnet-ts"; +import { decodeWireValue } from "@gamecult/cultnet-ts/wire"; +import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts"; import type { CultMeshRudpDocumentPut } from "../src/index"; import { decodeRealtimeFrame, encodeRealtimeFrame, type CultMeshRealtimeFrame } from "../src/realtime-wire"; diff --git a/packages/cultmesh-ts/test/provider-rudp-transport.test.ts b/packages/cultmesh-ts/test/provider-rudp-transport.test.ts index e9a9f1c5..aa4db341 100644 --- a/packages/cultmesh-ts/test/provider-rudp-transport.test.ts +++ b/packages/cultmesh-ts/test/provider-rudp-transport.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createSocket } from "node:dgram"; import { documentPayloadBytes } from "@gamecult/cultcache-ts"; -import { decodeWireValue } from "cultnet-ts/wire"; +import { decodeWireValue } from "@gamecult/cultnet-ts/wire"; import { CultNetDocumentRegistry, CultNetRudpSession, @@ -10,10 +10,11 @@ import { encodeRudpPacket, type CultNetOperationRequestMessage, type CultNetRudpPacket, -} from "cultnet-ts"; +} from "@gamecult/cultnet-ts"; import { CultMesh, + CultMeshGrantAllWrites, CultMeshProviderRudpTransport, CultMeshProviderSessionBroker, CULTMESH_PROVIDER_SESSION_SERVICE_ID, @@ -64,6 +65,7 @@ test("RUDP provider transport requires broker acceptance for the full lifecycle" }, }); const server = CultMesh.createRudpDocumentServer("odin-test", 0x43554c54, { + writeAuthority: new CultMeshGrantAllWrites(), bindPort: 0, documents: new CultNetDocumentRegistry(), onOperationRequest: (request, session) => broker.handle(request, session), @@ -256,6 +258,7 @@ test("a replaced RUDP generation drops operations queued behind an active handle let handlerCalls = 0; let closedSessions = 0; const server = CultMesh.createRudpDocumentServer("odin-queue-test", connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), bindPort: 0, documents: new CultNetDocumentRegistry(), onOperationRequest: async request => { @@ -326,6 +329,7 @@ test("a client restarted on the same address with the same Connect payload repla const connectionId = 0x43554c54; let closedSessions = 0; const server = CultMesh.createRudpDocumentServer("odin-restart-test", connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), bindPort: 0, documents: new CultNetDocumentRegistry(), onSessionClosed: () => { closedSessions += 1; }, @@ -364,6 +368,7 @@ test("a client restarted on the same address with the same Connect payload repla test("the RUDP document server does not acknowledge by name a reliable packet its session did not receive", async () => { const connectionId = 0x43554c55; const server = CultMesh.createRudpDocumentServer("odin-ack-test", connectionId, { + writeAuthority: new CultMeshGrantAllWrites(), bindPort: 0, documents: new CultNetDocumentRegistry(), onError: () => undefined, diff --git a/packages/cultmesh-ts/test/provider-session-wire.test.ts b/packages/cultmesh-ts/test/provider-session-wire.test.ts index ee196f41..4905f161 100644 --- a/packages/cultmesh-ts/test/provider-session-wire.test.ts +++ b/packages/cultmesh-ts/test/provider-session-wire.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { documentPayloadBytes, documentPayloadFromBytes, encodeDocumentPayload, type DocumentPayload } from "@gamecult/cultcache-ts"; -import { encodeWireValue } from "cultnet-ts/wire"; +import { encodeWireValue } from "@gamecult/cultnet-ts/wire"; import { cultMeshProviderSessionSchemas, diff --git a/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts b/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts index acf06a41..f48dec74 100644 --- a/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts +++ b/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts @@ -21,8 +21,8 @@ import { type CultMeshAuthorityRouteView, type CultMeshAuthorityTrustPolicy, type CultMeshP256PublicKey, -} from "cultnet-ts"; -import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; +} from "@gamecult/cultnet-ts"; +import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "@gamecult/cultnet-ts/contracts"; import { CultMeshQuicNativeRuntime, diff --git a/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts b/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts index e1843466..f636fc95 100644 --- a/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts +++ b/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts @@ -14,8 +14,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { CultMeshAuthorityTrustPolicy } from "cultnet-ts"; -import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; +import type { CultMeshAuthorityTrustPolicy } from "@gamecult/cultnet-ts"; +import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "@gamecult/cultnet-ts/contracts"; import { CultMeshQuicRealtimeSessionManager, diff --git a/packages/cultmesh-ts/test/remote-write-authority.test.ts b/packages/cultmesh-ts/test/remote-write-authority.test.ts new file mode 100644 index 00000000..359107ac --- /dev/null +++ b/packages/cultmesh-ts/test/remote-write-authority.test.ts @@ -0,0 +1,461 @@ +// A peer's put or delete is applied only when the server's write authority grants it. Ports the behaviours of +// cultmesh-py test_remote_write_authority.py (no forwarding: this server does not forward non-primary writes). +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { z } from "zod"; +import { documentPayloadFromBytes, defineDocumentType } from "@gamecult/cultcache-ts"; +import { encodeWireValue } from "@gamecult/cultnet-ts/wire"; +import { + CultNetDocumentRegistry, + CultNetPeer, + cultNetWriteNotAuthorizedError, + defineCultNetDocumentBinding, + type CultNetMessage, +} from "@gamecult/cultnet-ts"; +import { + CultMesh, + CultMeshGrantAllWrites, + type CultMeshRemoteWriteAuthority, + type CultMeshRemoteWriteDecision, + type CultMeshRemoteWriteRequest, + type CultMeshRudpDocumentDelete, + type CultMeshRudpDocumentPut, +} from "../src/index"; + +const SCHEMA_ID = "cultmesh.authority_note.v0"; +const ALIAS = "runtime.generated.authority-note.ui.42"; +const KEY_A = "tests:authority:a"; +const KEY_B = "tests:authority:b"; + +const note = defineDocumentType({ + type: "cultmesh.authority_note", + schemaId: SCHEMA_ID, + compatibleSchemaIds: [ALIAS], + schema: z.object({ noteId: z.string(), text: z.string() }), + name: "noteId", +}); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function put(recordKey: string, extra: Record = {}, schemaId = SCHEMA_ID, payload?: Uint8Array) { + return { + schemaVersion: "cultnet.document_put_raw.v0" as const, + messageId: `put-${recordKey}`, + document: { + schemaId, + recordKey, + storedAt: "2026-11-07T00:00:00Z", + payloadEncoding: "messagepack" as const, + payload: documentPayloadFromBytes(payload ?? encodeWireValue({ noteId: recordKey, text: "peer" })), + }, + ...extra, + } as CultNetMessage; +} + +function del(recordKey: string, schemaId = SCHEMA_ID): CultNetMessage { + return { schemaVersion: "cultnet.document_delete.v0", messageId: `delete-${recordKey}`, schemaId, recordKey }; +} + +class Recording implements CultMeshRemoteWriteAuthority { + readonly requests: CultMeshRemoteWriteRequest[] = []; + constructor(private readonly answer: (request: CultMeshRemoteWriteRequest) => unknown) {} + decide(request: CultMeshRemoteWriteRequest): CultMeshRemoteWriteDecision | Promise { + this.requests.push(request); + return this.answer(request) as CultMeshRemoteWriteDecision | Promise; + } +} + +const grant = () => ({ granted: true }); + +interface Rig { + puts: CultMeshRudpDocumentPut[]; + deletes: CultMeshRudpDocumentDelete[]; + errors: Error[]; + replies: CultNetMessage[]; + peer: CultNetPeer; + settle(): Promise; + close(): void; +} + +let nextConnection = 0x10204000; + +async function rig(writeAuthority?: CultMeshRemoteWriteAuthority, extra: { maxPayloadBytes?: number } = {}): Promise { + const connectionId = nextConnection++; + const puts: CultMeshRudpDocumentPut[] = []; + const deletes: CultMeshRudpDocumentDelete[] = []; + const errors: Error[] = []; + const server = CultMesh.createRudpDocumentServer("authority-server", connectionId, { + documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]), + writeAuthority, + bindHost: "127.0.0.1", + bindPort: 0, + resendDelayMs: 25, + resendPollMs: 5, + onDocumentPutRaw: (document) => { puts.push(document); }, + onDocumentDeleteRaw: (request) => { deletes.push(request); }, + onError: (error) => errors.push(error), + ...extra, + }); + await server.start(); + const peer: CultNetPeer = await CultMesh.createRudpPeer("authority-peer", connectionId, `rudp://127.0.0.1:${server.bind.port}`, { + resendDelayMs: 25, + resendPollMs: 5, + connectTimeoutMs: 2_000, + }); + const replies: CultNetMessage[] = []; + peer.on("message", (message) => replies.push(message)); + return { + puts, deletes, errors, replies, peer, + // A refusal is a reply; a grant sends none, so settle on a quiet period after the last send. + settle: () => sleep(250), + close: () => { peer.close(); server.close(); }, + }; +} + +async function using(r: Rig, body: (r: Rig) => Promise): Promise { + try { await body(r); } finally { r.close(); } +} + +function assertRefused(reply: CultNetMessage | undefined, key: string, error?: string): void { + assert.ok(reply && reply.schemaVersion === "cultnet.error.v0"); + assert.equal(reply.code, "write_not_authorized"); + assert.ok(reply.error.startsWith("write_not_authorized")); + assert.deepEqual(reply.details, { field: "recordKey", value: key, asOf: null, current: null }); + if (error !== undefined) assert.equal(reply.error, error); +} + +test("with no authority a peer's put and delete are refused and the handlers never run", async () => { + await using(await rig(undefined), async (r) => { + r.peer.send(put(KEY_A)); + r.peer.send(del(KEY_A)); + r.peer.send(put(KEY_B)); + await r.settle(); + assert.equal(r.replies.length, 3); + assertRefused(r.replies[0], KEY_A); + assertRefused(r.replies[1], KEY_A); + assertRefused(r.replies[2], KEY_B); + assert.deepEqual([r.puts.length, r.deletes.length], [0, 0]); + assert.deepEqual(r.errors, []); + }); +}); + +test("a granted put and delete reach their handlers, send nothing back, and the authority saw this peer", async () => { + const authority = new Recording(grant); + await using(await rig(authority), async (r) => { + r.peer.send(put(KEY_A)); + r.peer.send(del(KEY_A)); + await r.settle(); + assert.deepEqual(r.replies, []); + assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A]); + assert.deepEqual(r.deletes.map((d) => d.recordKey), [KEY_A]); + assert.equal(authority.requests.length, 2); + for (const request of authority.requests) { + assert.equal(request.peer.address, "127.0.0.1"); + assert.ok(request.peer.port > 0); + } + assert.deepEqual(authority.requests[1]!.peer, r.deletes[0]!.remote, "the delete handler sees the peer the authority was asked about"); + }); +}); + +test("the request carries the resolved write, and scoped grants refuse their neighbours", async () => { + const authority = new Recording((request) => + request.operation === "put" && request.recordKey === KEY_A ? { granted: true } : { granted: false, reason: "scoped" }); + await using(await rig(authority), async (r) => { + r.peer.send(put(KEY_A, { shardId: "authority-shard", shardEpoch: 1 }, ALIAS)); + await r.settle(); + const request = authority.requests[0]!; + assert.equal(request.schemaId, SCHEMA_ID, "the authority sees the canonical id, not the alias as sent"); + assert.equal(request.operation, "put"); + assert.equal(request.recordKey, KEY_A); + assert.equal(request.shardId, "authority-shard"); + assert.equal(request.shardEpoch, 1); + assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A]); + + r.peer.send(put(KEY_B)); + r.peer.send(del(KEY_A)); + await r.settle(); + assertRefused(r.replies[0], KEY_B, "write_not_authorized: scoped"); + assertRefused(r.replies[1], KEY_A, "write_not_authorized: scoped"); + assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A], "a grant scoped to key a refuses key b"); + assert.deepEqual(r.deletes, [], "a grant scoped to put refuses delete"); + assert.equal(authority.requests.at(-1)!.operation, "delete"); + assert.equal(authority.requests.at(-1)!.schemaId, SCHEMA_ID); + assert.equal(authority.requests.at(-1)!.shardId, null); + }); +}); + +test("a delete sent under a schema alias asks the authority with the canonical id", async () => { + const authority = new Recording(grant); + await using(await rig(authority), async (r) => { + r.peer.send(del(KEY_A, ALIAS)); + await r.settle(); + assert.equal(authority.requests[0]!.schemaId, SCHEMA_ID); + assert.equal(r.deletes.length, 1); + }); +}); + +test("refusal precedes every other judgment of the put", async () => { + await using(await rig(undefined, { maxPayloadBytes: 256 }), async (r) => { + r.peer.send(put("unrelated:authority", {}, "sha256:nobody-owns-this")); + r.peer.send(put(KEY_A, {}, SCHEMA_ID, new Uint8Array(20_000))); + await r.settle(); + assertRefused(r.replies[0], "unrelated:authority"); + assertRefused(r.replies[1], KEY_A); + assert.deepEqual(r.errors, [], "the gate ran before the put was sized or resolved"); + assert.equal(r.puts.length, 0); + }); + const granting = new Recording(grant); + await using(await rig(granting, { maxPayloadBytes: 256 }), async (r) => { + r.peer.send(put(KEY_A, {}, SCHEMA_ID, new Uint8Array(20_000))); + await r.settle(); + assert.equal(r.replies[0]?.schemaVersion, "cultnet.error.v0"); + assert.notEqual((r.replies[0] as { code?: string }).code, "write_not_authorized", "a granted put still meets the size judgment"); + assert.equal(r.errors.length, 1); + }); +}); + +test("an authority that throws or rejects refuses, fails closed, and is reported without being quoted", async () => { + for (const decide of [ + () => { throw new Error("authority exploded"); }, + () => Promise.reject(new Error("authority exploded")), + ]) { + await using(await rig(new Recording(decide)), async (r) => { + r.peer.send(put(KEY_A)); + r.peer.send(del(KEY_A)); + await r.settle(); + assertRefused(r.replies[0], KEY_A); + assertRefused(r.replies[1], KEY_A); + assert.deepEqual([r.puts.length, r.deletes.length], [0, 0]); + assert.equal(r.errors.length, 2); + assert.ok(r.errors.every((e) => e.message.includes("Error") && !e.message.includes("exploded"))); + }); + } +}); + +test("a refusal with no reason still says why, and a missing decision refuses", async () => { + await using(await rig(new Recording(() => ({ granted: false }))), async (r) => { + r.peer.send(put(KEY_A)); + await r.settle(); + assertRefused(r.replies[0], KEY_A, "write_not_authorized"); + }); + for (const answer of [null, undefined, {}, { granted: "yes" }, { granted: 1 }]) { + await using(await rig(new Recording(() => answer)), async (r) => { + r.peer.send(put(KEY_A)); + r.peer.send(del(KEY_A)); + await r.settle(); + assertRefused(r.replies[0], KEY_A); + assertRefused(r.replies[1], KEY_A); + assert.deepEqual([r.puts.length, r.deletes.length], [0, 0]); + }); + } +}); + +test("the refusal on the wire is the pinned error-vectors bytes", async () => { + const vectors = JSON.parse(readFileSync(join(__dirname, "../../../../contracts/cultnet/interop/error-vectors.json"), "utf8")).vectors; + const vector = vectors.find((v: { name: string }) => v.name === "write_not_authorized"); + const expected = Buffer.from(vector.messagePackHex, "hex"); + assert.deepEqual(Buffer.from(encodeWireValue(cultNetWriteNotAuthorizedError("some:key", "not granted"))), expected); + await using(await rig(new Recording(() => ({ granted: false, reason: "not granted" }))), async (r) => { + r.peer.send(put("some:key")); + await r.settle(); + assert.deepEqual(Buffer.from(encodeWireValue(r.replies[0])), expected); + }); +}); + +test("wrong-typed shard fields on a put are judged with null shard fields, then refused as malformed once granted", async () => { + const authority = new Recording(grant); + await using(await rig(authority), async (r) => { + const bad: Record[] = [ + { shardId: { not: "a string" } }, { shardId: 5 }, { shardEpoch: "7" }, { shardEpoch: true }, { shardEpoch: 1.5 }, + ]; + for (const fields of bad) r.peer.send(put(KEY_A, fields)); + await r.settle(); + assert.equal(r.replies.length, bad.length); + assert.ok(r.replies.every((reply) => reply.schemaVersion === "cultnet.error.v0" && reply.code === null && reply.error.startsWith("malformed_document_put:"))); + assert.equal(authority.requests.length, bad.length, "the authority is asked first"); + assert.ok(authority.requests.every((q) => q.shardId === null && q.shardEpoch === null), "with null shard fields"); + assert.equal(r.puts.length, 0); + }); + await using(await rig(undefined), async (r) => { + r.peer.send(put(KEY_A, { shardId: 5 })); + await r.settle(); + assertRefused(r.replies[0], KEY_A); + }); +}); + +test("a refusal fits maxPayloadBytes however long the record key and reason are", async () => { + const longKey = "k".repeat(3_000) + "é"; + await using(await rig(new Recording(() => ({ granted: false, reason: "r".repeat(100_000) })), { maxPayloadBytes: 1024 }), async (r) => { + r.peer.send(put(longKey)); + r.peer.send(del(longKey)); + await r.settle(); + assert.equal(r.replies.length, 2); + for (const reply of r.replies) { + assert.equal((reply as { code?: string }).code, "write_not_authorized"); + assert.ok(encodeWireValue(reply).byteLength <= 1024); + assert.ok(longKey.startsWith((reply as { details: { value: string } }).details.value)); + } + assert.deepEqual(r.errors, []); + }); +}); + +test("well-typed shard fields reach the authority as sent", async () => { + const authority = new Recording(grant); + await using(await rig(authority), async (r) => { + r.peer.send(put(KEY_A, { shardId: "authority-shard", shardEpoch: 1 })); + await r.settle(); + assert.deepEqual([authority.requests[0]!.shardId, authority.requests[0]!.shardEpoch], ["authority-shard", 1]); + }); +}); + +test("grant-all is an explicit choice that grants every write", async () => { + await using(await rig(new CultMeshGrantAllWrites()), async (r) => { + r.peer.send(put(KEY_A)); + r.peer.send(del(KEY_A)); + await r.settle(); + assert.deepEqual([r.puts.length, r.deletes.length, r.replies.length], [1, 1, 0]); + }); +}); + +test("a granted delete with no delete handler is answered with a text refusal", async () => { + const connectionId = nextConnection++; + const server = CultMesh.createRudpDocumentServer("no-delete", connectionId, { + documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]), + writeAuthority: new CultMeshGrantAllWrites(), + bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5, + }); + await server.start(); + const peer = await CultMesh.createRudpPeer("p", connectionId, `rudp://127.0.0.1:${server.bind.port}`, { resendDelayMs: 25, resendPollMs: 5, connectTimeoutMs: 2_000 }); + const replies: CultNetMessage[] = []; + peer.on("message", (m) => replies.push(m)); + try { + peer.send(del(KEY_A)); + await sleep(250); + assert.equal(replies.length, 1); + assert.notEqual((replies[0] as { code?: string }).code, "write_not_authorized"); + } finally { peer.close(); server.close(); } +}); + +// Hostile frames, sent as raw bytes the typed peer would never build. +const bin = (n: number, ...tail: number[]) => [0xc6, (n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255, ...tail]; // bin32 header +const str = (t: string) => [0xa0 | t.length, ...Buffer.from(t)]; +function rawPut(payloadBytes: number[], key = KEY_A): Uint8Array { + // {schemaVersion, messageId, document: {schemaId, recordKey, storedAt, payloadEncoding, payload: }} + return Uint8Array.from([ + 0x83, ...str("schemaVersion"), ...str("cultnet.document_put_raw.v0"), ...str("messageId"), ...str("raw"), + ...str("document"), 0x85, ...str("schemaId"), ...str(SCHEMA_ID), ...str("recordKey"), ...str(key), + ...str("storedAt"), ...str("2026-11-07T00:00:00Z"), ...str("payloadEncoding"), ...str("messagepack"), + ...str("payload"), ...payloadBytes, + ]); +} + +async function rawRig(writeAuthority?: CultMeshRemoteWriteAuthority) { + const connectionId = nextConnection++; + const puts: CultMeshRudpDocumentPut[] = []; + const deletes: CultMeshRudpDocumentDelete[] = []; + const errors: Error[] = []; + const server = CultMesh.createRudpDocumentServer("authority-raw", connectionId, { + documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]), + writeAuthority, bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5, + onDocumentPutRaw: (d) => { puts.push(d); }, onDocumentDeleteRaw: (d) => { deletes.push(d); }, onError: (e) => errors.push(e), + }); + await server.start(); + const client = await CultMesh.createRudpClient("raw", connectionId, `rudp://127.0.0.1:${server.bind.port}`, { resendDelayMs: 25, resendPollMs: 5 }); + client.connect(); + const started = Date.now(); + while (!client.connected) { + if (Date.now() - started > 2_000) throw new Error("not connected"); + await sleep(5); + } + const replies: CultNetMessage[] = []; + new CultNetPeer(client, { wireContract: "cultnet.schema.v0" }).on("message", (m) => replies.push(m)); + return { puts, deletes, errors, replies, send: (b: Uint8Array) => client.send("schema", b), close: () => { client.close(); server.close(); } }; +} + +const float64 = (v: number) => { const b = Buffer.alloc(9); b[0] = 0xcb; b.writeDoubleBE(v, 1); return [...b]; }; + +// Each hostile frame, the operation it is judged as, and the ids the authority is told (empty when the frame does not carry them). +const hostile: { name: string; frame: Uint8Array; operation: "put" | "delete"; schemaId: string; recordKey: string }[] = [ + { name: "an int payload", frame: rawPut([0x2a]), operation: "put", schemaId: SCHEMA_ID, recordKey: KEY_A }, + { name: "a uint64-max integer payload", frame: rawPut([0xcf, 255, 255, 255, 255, 255, 255, 255, 255]), operation: "put", schemaId: SCHEMA_ID, recordKey: KEY_A }, + { name: "a float64 2**70 payload", frame: rawPut(float64(2 ** 70)), operation: "put", schemaId: SCHEMA_ID, recordKey: KEY_A }, + { name: "a bin whose claimed length exceeds the frame", frame: rawPut(bin(1_000_000_000, 1, 2, 3)), operation: "put", schemaId: "", recordKey: "" }, + { name: "a frame that does not decode at all", frame: Uint8Array.from([0xc1]), operation: "put", schemaId: "", recordKey: "" }, + { name: "a put whose document is not a map", frame: Uint8Array.from([0x82, ...str("schemaVersion"), ...str("cultnet.document_put_raw.v0"), ...str("document"), 0x05]), operation: "put", schemaId: "", recordKey: "" }, + { name: "a delete whose recordKey is not a string", frame: Uint8Array.from([0x84, ...str("schemaVersion"), ...str("cultnet.document_delete.v0"), ...str("messageId"), ...str("d"), ...str("schemaId"), ...str(SCHEMA_ID), ...str("recordKey"), 0x07]), operation: "delete", schemaId: SCHEMA_ID, recordKey: "" }, +]; + +/** Records the size of every buffer a frame makes the process allocate, so a transient allocation is seen after it is freed. */ +function watchAllocations(): { largest: () => number; stop: () => void } { + let largest = 0; + const note = (n: unknown) => { if (typeof n === "number" && n > largest) largest = n; }; + const originals = { alloc: Buffer.alloc, unsafe: Buffer.allocUnsafe, slow: Buffer.allocUnsafeSlow, u8: globalThis.Uint8Array, ab: globalThis.ArrayBuffer }; + Buffer.alloc = ((n: number, ...rest: never[]) => { note(n); return originals.alloc.call(Buffer, n, ...rest); }) as typeof Buffer.alloc; + Buffer.allocUnsafe = ((n: number) => { note(n); return originals.unsafe.call(Buffer, n); }) as typeof Buffer.allocUnsafe; + Buffer.allocUnsafeSlow = ((n: number) => { note(n); return originals.slow.call(Buffer, n); }) as typeof Buffer.allocUnsafeSlow; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const watched = (ctor: any): any => new Proxy(ctor, { construct: (target, args, newTarget) => { note(args[0]); return Reflect.construct(target, args, newTarget); } }); + (globalThis as { Uint8Array: unknown }).Uint8Array = watched(originals.u8); + (globalThis as { ArrayBuffer: unknown }).ArrayBuffer = watched(originals.ab); + return { + largest: () => largest, + stop: () => { + Buffer.alloc = originals.alloc; Buffer.allocUnsafe = originals.unsafe; Buffer.allocUnsafeSlow = originals.slow; + (globalThis as { Uint8Array: unknown }).Uint8Array = originals.u8; + (globalThis as { ArrayBuffer: unknown }).ArrayBuffer = originals.ab; + }, + }; +} + +test("an ungranted peer's unreadable write is refused with write_not_authorized naming what it carried, and nothing is allocated for it", async () => { + for (const h of hostile) { + const authority = new Recording(() => ({ granted: false })); + for (const withAuthority of [false, true]) { + const r = await rawRig(withAuthority ? authority : undefined); + const watch = watchAllocations(); + try { + r.send(h.frame); + await sleep(250); + } finally { watch.stop(); } + try { + assert.equal(r.replies.length, 1, h.name); + assertRefused(r.replies[0], h.recordKey); + assert.ok(watch.largest() < 10_000_000, `${h.name}: allocated ${watch.largest()} bytes`); + assert.deepEqual([r.puts.length, r.deletes.length, r.errors.length], [0, 0, 0], h.name); + } finally { r.close(); } + } + assert.equal(authority.requests.length, 1, h.name); + assert.deepEqual( + [authority.requests[0]!.operation, authority.requests[0]!.schemaId, authority.requests[0]!.recordKey], + [h.operation, h.schemaId, h.recordKey], h.name); + } +}); + +test("the allocation watch sees a transient allocation", () => { + const watch = watchAllocations(); + try { void Buffer.alloc(20_000_000); } finally { watch.stop(); } + assert.ok(watch.largest() >= 20_000_000); + const typed = watchAllocations(); + try { void new Uint8Array(20_000_000); } finally { typed.stop(); } + assert.ok(typed.largest() >= 20_000_000); +}); + +test("a granted peer's unreadable write reaches onError and gets a malformed refusal, and no handler runs", async () => { + for (const h of hostile) { + const authority = new Recording(grant); + const r = await rawRig(authority); + try { + r.send(h.frame); + await sleep(250); + assert.equal(r.replies.length, 1, h.name); + const reply = r.replies[0] as { code: unknown; error: string }; + assert.equal(reply.code, null, h.name); + assert.ok(reply.error.startsWith(`malformed_document_${h.operation}:`), h.name); + assert.equal(r.errors.length, 1, h.name); + assert.deepEqual([r.puts.length, r.deletes.length], [0, 0], h.name); + assert.deepEqual([authority.requests.length, authority.requests[0]!.operation, authority.requests[0]!.recordKey], [1, h.operation, h.recordKey], h.name); + } finally { r.close(); } + } +}); diff --git a/packages/cultmesh-ts/test/rudp-document-server-send-failure.test.ts b/packages/cultmesh-ts/test/rudp-document-server-send-failure.test.ts index 713a3bee..21864a97 100644 --- a/packages/cultmesh-ts/test/rudp-document-server-send-failure.test.ts +++ b/packages/cultmesh-ts/test/rudp-document-server-send-failure.test.ts @@ -6,15 +6,15 @@ import assert from "node:assert/strict"; import { createSocket, type RemoteInfo, type Socket } from "node:dgram"; import test from "node:test"; import { documentPayloadFromBytes } from "@gamecult/cultcache-ts"; -import { encodeWireValue } from "cultnet-ts/wire"; +import { encodeWireValue } from "@gamecult/cultnet-ts/wire"; import { CultNetDocumentRegistry, CultNetRudpSession, decodeRudpPacket, encodeRudpPacket, type CultNetPeer, -} from "cultnet-ts"; -import { CultMesh } from "../src/index"; +} from "@gamecult/cultnet-ts"; +import { CultMesh, CultMeshGrantAllWrites } from "../src/index"; async function waitFor(predicate: () => boolean, description: string): Promise { const startedAt = Date.now(); @@ -64,6 +64,7 @@ async function startServer(connectionId: number, socket: Socket): Promise bool: def unsupported_schema_version_error(message: dict[str, Any]) -> dict[str, Any]: - schema_version = message.get("schemaVersion") - return { - "schemaVersion": "cultnet.error.v0", - "messageId": str(message.get("messageId") or ""), - "error": f"Unsupported CultNet message schema: {schema_version!r}.", - "code": "unsupported_schema_version", - "details": {"schemaVersion": schema_version}, - } + return _unsupported_schema_version_error(message.get("schemaVersion")).to_wire() def handle_database_subscribe(state: PeerState, message: dict[str, Any], subscriptions: dict[str, DatabaseSubscription]) -> list[dict[str, Any]]: @@ -1316,6 +1310,7 @@ def witness_artifact_bundle_schema_json() -> str: "required": ["role", "schemaId"], "additionalProperties": False, "properties": { + "id": {"type": "string", "minLength": 1}, "role": {"type": "string", "minLength": 1}, "schemaId": {"type": "string", "minLength": 1}, "schemaVersion": {"type": "string", "minLength": 1}, @@ -1327,6 +1322,7 @@ def witness_artifact_bundle_schema_json() -> str: "required": ["role", "uri", "mediaType"], "additionalProperties": False, "properties": { + "id": {"type": "string", "minLength": 1}, "role": {"type": "string", "minLength": 1}, "uri": {"type": "string", "minLength": 1}, "mediaType": {"type": "string", "minLength": 1}, @@ -1340,6 +1336,7 @@ def witness_artifact_bundle_schema_json() -> str: "required": ["stage", "startedAt", "completedAt", "latencyMs"], "additionalProperties": False, "properties": { + "id": {"type": "string", "minLength": 1}, "stage": {"type": "string", "minLength": 1}, "startedAt": {"type": "string", "minLength": 1}, "completedAt": {"type": "string", "minLength": 1}, diff --git a/packages/cultnet-py/src/cultnet_py/messages.py b/packages/cultnet-py/src/cultnet_py/messages.py index 4fa8d336..bf21b985 100644 --- a/packages/cultnet-py/src/cultnet_py/messages.py +++ b/packages/cultnet-py/src/cultnet_py/messages.py @@ -28,9 +28,62 @@ def parse_message(payload: bytes) -> CultNetMessage: raise ValueError("CultNet schema-v0 messages must declare schemaVersion") body = dict(decoded) del body["schemaVersion"] + if schema_version == "cultnet.error.v0": + _validate_error_body(body) return CultNetMessage(schema_version=schema_version, body=body) +# Closed refusal-code set of cultnet.error.v0 (contracts/cultnet/cultnet.error.schema.json). Adding a +# code is one coordinated change across every runtime; a peer refuses a code it does not know. +CULTNET_ERROR_CODES: tuple[str, ...] = ( + "selection_invalid", + "cursor_stale", + "cursor_invalid", + "reference_outside_target", + "variant_unsupported", + "unowned_schema", + "write_not_authorized", + "unsupported_schema_version", +) + +_ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current") + + +def _is_uint(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def _validate_error_body(body: dict[str, Any]) -> None: + """Refuse a cultnet.error.v0 body that is not the full-key shape the schema requires.""" + expected = ("error", "routingHint", "code", "details") + if set(body) != set(expected): + raise ValueError(f"cultnet.error.v0 must carry exactly {list(expected)}, got {sorted(body)}") + if not isinstance(body["error"], str) or not body["error"]: + raise ValueError("cultnet.error.v0 error must be a non-empty string") + code = body["code"] + if code is not None and (not isinstance(code, str) or code not in CULTNET_ERROR_CODES): + raise ValueError(f"cultnet.error.v0 code {code!r} is not a recognized refusal code") + hint = body["routingHint"] + if hint is not None: + if ( + not isinstance(hint, dict) + or set(hint) != {"shard", "reason"} + or not isinstance(hint["reason"], str) + or not (hint["shard"] is None or isinstance(hint["shard"], dict)) + ): + raise ValueError("cultnet.error.v0 routingHint must be null or {shard, reason}") + details = body["details"] + if details is not None: + if not isinstance(details, dict) or set(details) != set(_ERROR_DETAILS_KEYS): + raise ValueError(f"cultnet.error.v0 details must be null or carry exactly {list(_ERROR_DETAILS_KEYS)}") + for key in ("field", "value"): + if details[key] is not None and not isinstance(details[key], str): + raise ValueError(f"cultnet.error.v0 details.{key} must be a string or null") + for key in ("asOf", "current"): + if details[key] is not None and not _is_uint(details[key]): + raise ValueError(f"cultnet.error.v0 details.{key} must be a non-negative integer or null") + + def hello( *, runtime_id: str, @@ -99,6 +152,39 @@ def verify_session( ) +def error_message( + error: str, + *, + code: str | None = None, + details: dict[str, Any] | None = None, + routing_hint: dict[str, Any] | None = None, +) -> CultNetMessage: + """Build cultnet.error.v0 with every key written (nil included), as the C# map formatter does. + + ``details`` may name any of field/value/asOf/current; the rest are written as null. + """ + body = { + "error": error, + "routingHint": routing_hint, + "code": code, + "details": None if details is None else {key: details.get(key) for key in _ERROR_DETAILS_KEYS}, + } + if details is not None and set(details) - set(_ERROR_DETAILS_KEYS): + raise ValueError(f"cultnet.error.v0 details has unknown keys {sorted(set(details) - set(_ERROR_DETAILS_KEYS))}") + _validate_error_body(body) + return CultNetMessage("cultnet.error.v0", body) + + +def unsupported_schema_version_error(schema_version: Any) -> CultNetMessage: + """The refusal for a message whose schemaVersion this peer does not speak (C# ForUnsupportedSchemaVersion).""" + value = None if schema_version is None else str(schema_version) + return error_message( + f"unsupported_schema_version: {value or ''}", + code="unsupported_schema_version", + details={"field": "schemaVersion", "value": value}, + ) + + def login_success( *, nonce: str, diff --git a/packages/cultnet-py/src/cultnet_py/replication.py b/packages/cultnet-py/src/cultnet_py/replication.py index 6450d4f9..531c0012 100644 --- a/packages/cultnet-py/src/cultnet_py/replication.py +++ b/packages/cultnet-py/src/cultnet_py/replication.py @@ -37,11 +37,9 @@ def apply_raw_document_record( ) -> CultNetAppliedRecord: wire = record.to_wire() if isinstance(record, CultNetRawDocumentRecord) else record schema_id = str(wire["schemaId"]) - document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( - documents_by_schema_id, - schema_id, - wire, - ) + document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, wire) + # The record is applied, stamped and reported under the local schema id, as the cache stores it and a delete reports it. + resolved_schema_id = document.catalog_entry().schema_id envelope = raw_record_to_envelope(document, resolved_schema_id, wire) value = cache.put_envelope(document, envelope) return CultNetAppliedRecord( @@ -82,11 +80,8 @@ def apply_raw_snapshot( if not isinstance(record, dict): continue schema_id = str(record["schemaId"]) - document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( - documents_by_schema_id, - schema_id, - record, - ) + document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, record) + resolved_schema_id = document.catalog_entry().schema_id envelope = raw_record_to_envelope(document, resolved_schema_id, record) batch = batches.setdefault(document.type, (document, [])) batch[1].append((resolved_schema_id, envelope)) @@ -159,27 +154,19 @@ def can_resolve_document_for_raw_record( return False +# routes a replicated raw record to a local binding; the store always persists the local schema id; this is not store-reader recovery. def resolve_document_for_raw_record( documents_by_schema_id: dict[str, DocumentDefinition[Any]], schema_id: str, record: dict[str, Any], ) -> DocumentDefinition[Any]: - return resolve_document_and_schema_id_for_raw_record(documents_by_schema_id, schema_id, record)[0] - - -def resolve_document_and_schema_id_for_raw_record( - documents_by_schema_id: dict[str, DocumentDefinition[Any]], - schema_id: str, - record: dict[str, Any], -) -> tuple[DocumentDefinition[Any], str]: document = documents_by_schema_id.get(schema_id) if document is not None: - return document, schema_id + return document schema_version = _infer_schema_version_from_payload(bytes(record["payload"])) schema_name = _infer_schema_name(schema_version) if schema_version is not None else None if schema_name is not None and schema_name in documents_by_schema_id: - document = documents_by_schema_id[schema_name] - return document, document.catalog_entry().schema_id + return documents_by_schema_id[schema_name] raise KeyError(schema_id) diff --git a/packages/cultnet-py/src/cultnet_py/rudp_server.py b/packages/cultnet-py/src/cultnet_py/rudp_server.py new file mode 100644 index 00000000..2b162043 --- /dev/null +++ b/packages/cultnet-py/src/cultnet_py/rudp_server.py @@ -0,0 +1,345 @@ +"""Multi-peer RUDP listener: one UDP socket, one reliable session per remote endpoint. + +Matches the C# `CultNetRudpSocketTransportServer` (the reference): a Connect opens a peer's +session, a Connect from the same endpoint that is not a repeat replaces only that peer, and +a peer that ends, is refused or cannot be answered never disturbs the others. The caller +drives it: `try_receive_once` consumes one datagram, `poll_resends` sends what is due. +Everything runs on the caller's thread, so no session needs a lock. +""" +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +import socket + +from .transport import ( + REFUSED_PACKET_REASON, + CultNetRudpPacket, + CultNetRudpPacketType, + CultNetRudpSession, + CultNetRudpSessionOptions, + CultNetReconnectPolicy, + CultNetTransportFrame, + CultNetTransportStats, + _channel_send_options, + _now_ms, + create_rudp_transport_profile, + decode_rudp_packet, + encode_rudp_packet, + is_permanent_send_error, +) + +REPLACED_PEER_REASON = b"replaced by a new Connect generation" + + +@dataclass +class CultNetRudpSocketServerOptions: + runtime_id: str + socket: socket.socket + connection_id: int + initial_sequence: int | None = None + resend_delay_ms: int = 250 + transport_id: str = "rudp" + max_payload_bytes: int | None = None + max_fragment_bytes: int | None = None + max_pending_reliable_packets: int | None = None + reconnect_policy: CultNetReconnectPolicy | None = None + accept_payload: bytes = b"" + + +class CultNetRudpSocketServerPeer: + """One remote endpoint's session. A peer that reconnects from the same endpoint is a new object.""" + + def __init__(self, remote_addr: tuple, connect_payload: bytes, session: CultNetRudpSession) -> None: + self.remote_addr = remote_addr + self.connect_payload = connect_payload + self.session = session + # The last transport-level reason the session ended with, once it has. + self.disconnect_reason: bytes | None = None + + @property + def connected(self) -> bool: + return self.session.connected + + @property + def pending_reliable_packet_count(self) -> int: + return self.session.outstanding_reliable_packet_count + + +@dataclass(frozen=True) +class CultNetRudpSocketServerFrame: + peer: CultNetRudpSocketServerPeer + frame: CultNetTransportFrame + + +def _remote_key(addr: tuple) -> tuple: + return tuple(addr[:2]) + + +class CultNetRudpSocketTransportServer: + def __init__(self, options: CultNetRudpSocketServerOptions) -> None: + if not options.runtime_id.strip(): + raise ValueError("Runtime id is required.") + self.socket = options.socket + self._options = options + host, port = self.socket.getsockname()[:2] + self.profile = create_rudp_transport_profile( + options.runtime_id, + transport_id=options.transport_id, + host=host, + port=port, + max_payload_bytes=options.max_payload_bytes, + max_fragment_bytes=options.max_fragment_bytes, + max_pending_reliable_packets=options.max_pending_reliable_packets, + reconnect_policy=options.reconnect_policy, + ) + self._peers: dict[tuple, CultNetRudpSocketServerPeer] = {} + self._delivered: deque[CultNetRudpSocketServerFrame] = deque() + self._bytes_received = 0 + self._bytes_sent = 0 + self._frames_received = 0 + self._frames_sent = 0 + self._packets_dropped = 0 + self._send_failures = 0 + self._closed = False + self.peer_disconnected: list = [] # callbacks taking the peer + + @property + def peers(self) -> tuple[CultNetRudpSocketServerPeer, ...]: + return tuple(self._peers.values()) + + @property + def stats(self) -> CultNetTransportStats: + return CultNetTransportStats( + bytes_received=self._bytes_received, + bytes_sent=self._bytes_sent, + frames_received=self._frames_received, + frames_sent=self._frames_sent, + packets_dropped=self._packets_dropped, + send_failures=self._send_failures, + ) + + def send(self, peer: CultNetRudpSocketServerPeer, channel_id: str, payload: bytes) -> None: + """Sends to one peer. Raises when that peer's session has ended or was replaced, or when a + datagram can never be sent as built: the first fragment failing queues nothing, a later one + ends the session, as the peer has seen part of the frame.""" + if self._peers.get(_remote_key(peer.remote_addr)) is not peer: + raise ConnectionError("RUDP peer is not connected.") + first_sequence = peer.session.next_sequence + packets = peer.session.send_many( + channel_id, + payload, + _channel_send_options(channel_id, _now_ms()), + max_fragment_bytes=self._options.max_fragment_bytes, + ) + for index, packet in enumerate(packets): + error = self._send_packet(peer.remote_addr, packet) + if error is None: + continue + if index == 0: + peer.session.withdraw_send(first_sequence) + else: + self._end_unsendable_peer(peer, error) + raise error + self._frames_sent += 1 + + def send_schema(self, peer: CultNetRudpSocketServerPeer, payload: bytes) -> None: + self.send(peer, "schema", payload) + + def broadcast(self, channel_id: str, payload: bytes) -> list[CultNetRudpSocketServerPeer]: + """Sends to every connected peer and returns the peers that could not be sent to; one + peer's failure never stops the others.""" + failed = [] + for peer in self.peers: + try: + self.send(peer, channel_id, payload) + except (OSError, ConnectionError, ValueError): + failed.append(peer) + return failed + + def disconnect(self, peer: CultNetRudpSocketServerPeer, reason: bytes = b"") -> bool: + """Ends one peer's session, telling it why. False when the session had already ended.""" + return self.end_peer_session(peer, reason) + + def end_peer_session(self, peer: CultNetRudpSocketServerPeer, reason: bytes) -> bool: + """Ends the session of a peer whose delivered frame the application could not decode or + handle, with a goodbye carrying the application's reason. The frame is already + acknowledged, so the session cannot be kept; the peer's frames still queued are not + delivered. A session that already ended owes no goodbye, since the endpoint may belong + to a new session; the call then returns False.""" + self._packets_dropped += 1 + if peer.session.ended: + return False + self._send_packet(peer.remote_addr, peer.session.end_with(reason)) + self._delivered = deque(item for item in self._delivered if item.peer is not peer) + self._retire(peer, reason) + return True + + def receive_once(self) -> CultNetRudpSocketServerFrame | None: + return self.try_receive_once()[1] + + def try_receive_once(self) -> tuple[bool, CultNetRudpSocketServerFrame | None]: + """Consumes one transport work item. Returns whether progress was made, independently of + whether it completed an application frame.""" + if self._delivered: + return True, self._delivered.popleft() + try: + wire, remote = self.socket.recvfrom(65535) + except (TimeoutError, BlockingIOError, ConnectionResetError): + return False, None + self._bytes_received += len(wire) + + try: + packet = decode_rudp_packet(wire) + except ValueError: + self._packets_dropped += 1 + return True, None + if packet.connection_id != self._options.connection_id: + self._packets_dropped += 1 + return True, None + + key = _remote_key(remote) + if packet.packet_type == CultNetRudpPacketType.CONNECT: + self._receive_connect(packet, remote, key) + return True, None + peer = self._peers.get(key) + if peer is None: + self._packets_dropped += 1 + return True, None + self._receive_from_peer(peer, key, packet) + return True, (self._delivered.popleft() if self._delivered else None) + + def poll_resends(self) -> None: + """Sends the reliable packets whose resend timers are due, for every peer.""" + for peer in self.peers: + if self._peers.get(_remote_key(peer.remote_addr)) is not peer: + continue + for packet in peer.session.due_resends(_now_ms()): + error = self._send_packet(peer.remote_addr, packet) + if error is not None: + self._end_unsendable_peer(peer, error) + break + + def close(self) -> None: + if self._closed: + return + self._closed = True + self.socket.close() + + def __enter__(self) -> "CultNetRudpSocketTransportServer": + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def _receive_connect(self, packet: CultNetRudpPacket, remote: tuple, key: tuple) -> None: + admitted = self._peers.get(key) + accept_payload = self._options.accept_payload + # The admitted session decides whether this Connect repeats the one it accepted: a repeat + # gets the Accept already owed and queues nothing. Any other Connect is a new generation + # and ends the admitted session here. + if admitted is not None: + if admitted.session.connect_repeats(packet): + try: + reply = admitted.session.accept_connect(packet, _now_ms(), accept_payload) + except ValueError: + self._packets_dropped += 1 + return + error = self._send_packet(admitted.remote_addr, reply) + if error is not None: + self._end_unsendable_peer(admitted, error) + return + admitted.session.reset_peer_state() + + peer = CultNetRudpSocketServerPeer( + tuple(remote), + packet.payload, + CultNetRudpSession( + CultNetRudpSessionOptions( + connection_id=self._options.connection_id, + initial_sequence=self._options.initial_sequence, + resend_delay_ms=self._options.resend_delay_ms, + max_pending_reliable_packets=self._options.max_pending_reliable_packets, + ) + ), + ) + try: + accept = peer.session.accept_connect(packet, _now_ms(), accept_payload) + except ValueError: + self._packets_dropped += 1 + accept = None + # A peer that cannot be answered gets no session, and the one it replaced has already ended. + if accept is None or self._send_packet(peer.remote_addr, accept) is not None: + self._peers.pop(key, None) + if accept is not None: + self._packets_dropped += 1 + else: + self._peers[key] = peer + if admitted is not None: + admitted.disconnect_reason = REPLACED_PEER_REASON + self._notify_disconnected(admitted) + + def _receive_from_peer(self, peer: CultNetRudpSocketServerPeer, key: tuple, packet: CultNetRudpPacket) -> None: + session = peer.session + try: + result = session.receive(packet, _now_ms()) + except ValueError: + # receive() has already recorded the packet's reliable sequence, so the session cannot + # be kept: a retransmit would be acknowledged and the frame silently lost. + self._packets_dropped += 1 + self._send_packet(peer.remote_addr, session.end_with(REFUSED_PACKET_REASON)) + self._retire(peer, REFUSED_PACKET_REASON) + return + error = None + if result.reply is not None: + error = self._send_packet(peer.remote_addr, result.reply) + for ready in result.ready_to_send: + if error is None: + error = self._send_packet(peer.remote_addr, ready) + if result.disconnected: + self._retire(peer, result.disconnect_reason) + return + for frame in result.delivered: + self._delivered.append( + CultNetRudpSocketServerFrame(peer, CultNetTransportFrame(frame.channel_id, frame.payload)) + ) + self._frames_received += 1 + if error is None and packet.packet_type == CultNetRudpPacketType.DATA: + error = self._send_packet(peer.remote_addr, session.create_ack_for_received(packet.sequence)) + if error is not None: + self._end_unsendable_peer(peer, error) + + def _end_unsendable_peer(self, peer: CultNetRudpSocketServerPeer, error: OSError) -> None: + """An admitted peer owes a packet that can never be sent as built: its session ends, never + the poll, the peer is told, and the disconnect reason names the error. A session that has + already ended owes no goodbye, since the endpoint may belong to a new session.""" + if peer.session.ended: + return + goodbye = peer.session.end_unsendable_session(error) + self._send_packet(peer.remote_addr, goodbye) + self._retire(peer, goodbye.payload) + + def _retire(self, peer: CultNetRudpSocketServerPeer, reason: bytes | None) -> None: + # A peer a new Connect already replaced was reported then; its key now names the successor. + key = _remote_key(peer.remote_addr) + if self._peers.get(key) is not peer: + return + del self._peers[key] + peer.disconnect_reason = reason + self._notify_disconnected(peer) + + def _notify_disconnected(self, peer: CultNetRudpSocketServerPeer) -> None: + for callback in list(self.peer_disconnected): + callback(peer) + + def _send_packet(self, remote_addr: tuple, packet: CultNetRudpPacket) -> OSError | None: + """A transient failure is that peer's lost datagram: counted, never raised. A permanent + one (`is_permanent_send_error`) is returned for the caller to refuse or end the session.""" + wire = encode_rudp_packet(packet) + try: + self._bytes_sent += self.socket.sendto(wire, remote_addr) + except OSError as error: + if is_permanent_send_error(error): + return error + self._send_failures += 1 + return None diff --git a/packages/cultnet-py/src/cultnet_py/schema_catalog.py b/packages/cultnet-py/src/cultnet_py/schema_catalog.py index 6d71b3cd..fc03c8a2 100644 --- a/packages/cultnet-py/src/cultnet_py/schema_catalog.py +++ b/packages/cultnet-py/src/cultnet_py/schema_catalog.py @@ -12,6 +12,18 @@ PEER_EXCHANGE_REQUEST = "cultmesh.peer_exchange_request.v0" TRANSPORT_PROFILE_SCHEMA_VERSION = "cultnet.transport_profile.v0" TRANSPORT_PROFILE_SCHEMA_ID = f"{CULTNET_SCHEMA_BASE}/cultnet.transport-profile.schema.json" +# The closed code set of contracts/cultnet/cultnet.error.schema.json; a code outside it is one coordinated change across every runtime. +CULTNET_ERROR_CODES = ( + "selection_invalid", + "cursor_stale", + "cursor_invalid", + "reference_outside_target", + "variant_unsupported", + "unowned_schema", + "write_not_authorized", + "unsupported_schema_version", +) + WIRE_MESSAGE_SCHEMA_VERSIONS = ( ("cultnet.hello.v0", "CultNet Hello Message", f"{CULTNET_SCHEMA_BASE}/cultnet.hello.schema.json"), ("cultnet.error.v0", "CultNet Error Message", f"{CULTNET_SCHEMA_BASE}/cultnet.error.schema.json"), @@ -269,7 +281,7 @@ def shared_contract_schema_json(schema_id: str, title: str, schema_version: str) def wire_message_required_fields(schema_version: str) -> list[str]: required = { "cultnet.hello.v0": ["schemaVersion", "runtimeId"], - "cultnet.error.v0": ["schemaVersion", "error"], + "cultnet.error.v0": ["schemaVersion", "error", "routingHint", "code", "details"], "cultnet.login.v0": ["schemaVersion", "nonce", "auth", "password"], "cultnet.register.v0": ["schemaVersion", "nonce", "email", "password", "name"], "cultnet.verify.v0": ["schemaVersion", "nonce", "session"], @@ -355,10 +367,10 @@ def wire_message_schema_properties(schema_version: str) -> dict[str, Any]: "supportsSchemaCatalog": {"type": "boolean"}, }, "cultnet.error.v0": { - "messageId": common["messageId"], - "error": {"type": "string"}, - "code": {"type": "string"}, - "details": {"type": "object", "additionalProperties": True}, + "error": {"type": "string", "minLength": 1}, + "routingHint": {"type": ["object", "null"]}, + "code": {"type": ["string", "null"], "enum": [None, *CULTNET_ERROR_CODES]}, + "details": {"type": ["object", "null"]}, }, "cultnet.login.v0": { "nonce": {"type": "string", "minLength": 1}, diff --git a/packages/cultnet-py/src/cultnet_py/transport.py b/packages/cultnet-py/src/cultnet_py/transport.py index 76b38bf5..3ecfa31b 100644 --- a/packages/cultnet-py/src/cultnet_py/transport.py +++ b/packages/cultnet-py/src/cultnet_py/transport.py @@ -29,6 +29,9 @@ class CultNetTransportStats: # transport: malformed frames, another session's connection id, a sender # that is not the peer, or a packet the session refuses. packets_dropped: int = 0 + # Datagrams a multi-peer server could not send to one peer and lost: each is + # a transient failure, never a reason to end a session. + send_failures: int = 0 @dataclass(frozen=True) @@ -437,6 +440,7 @@ def create_rudp_transport_profile( UNSENDABLE_PACKET_REASON = "packet could not be sent" +REFUSED_PACKET_REASON = b"session refused a packet" # The fixed name of each permanent send failure, keyed by errno, Winsock equivalents included. _PERMANENT_SEND_ERROR_CODES = { @@ -600,6 +604,28 @@ class CultNetRudpSession: self._fragment_buffers.clear() self._fragment_sets_evicted = 0 + @property + def next_sequence(self) -> int: + return self._next_sequence + + def withdraw_send(self, first_sequence: int) -> None: + """Undoes a send that never reached the wire: every packet it created at or after + `first_sequence`, pending or queued, leaves no trace and its sequences are reissued, + so the peer never sees a gap. Only valid while nothing else has been sent since.""" + for sequence in [s for s in self._pending_reliable if s >= first_sequence]: + del self._pending_reliable[sequence] + kept = [p for p in self._queued_reliable if p.sequence < first_sequence] + self._queued_reliable.clear() + self._queued_reliable.extend(kept) + self._next_sequence = first_sequence + + def end_with(self, reason: bytes) -> CultNetRudpPacket: + """Ends a session whose peer's frame was refused or failed after the session acknowledged + it. The peer's sequences are forgotten before the goodbye is built, or its ack field would + acknowledge the very frame that was refused.""" + self.reset_peer_state() + return self.create_disconnect(reason) + def create_connect(self, now_ms: int = 0, payload: bytes = b"") -> CultNetRudpPacket: # A session that has had a peer starts a new generation: nothing it # learned from that peer describes the one this Connect reaches, and @@ -1397,11 +1423,8 @@ class CultNetRudpSocketTransportConnection: self.socket.close() def _end_refused_session(self) -> None: - reason = b"session refused a packet" - # The goodbye is built after the reset, or its ack field would acknowledge - # the very frame the session refused. - self.session.reset_peer_state() - goodbye = self.session.create_disconnect(reason) + reason = REFUSED_PACKET_REASON + goodbye = self.session.end_with(reason) self.disconnect_reason = reason try: self._send_packet(goodbye) diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 3c5793c1..6e8d2b71 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -2102,6 +2102,51 @@ class CultNetTests(unittest.TestCase): self.assertEqual(applied_delete[0].change_kind, "removed") self.assertIsNone(cache.get(document, "policy:1")) + # A compatible schema id applies through the real replication function, the record is stamped with the registered id, and + # the store it leaves reopens and takes the next write. + def test_cultnet_raw_replication_under_a_compatible_schema_id_leaves_a_store_that_reopens(self) -> None: + import tempfile + from pathlib import Path + + from cultcache_py import SingleFileMessagePackBackingStore + + document = define_database_entry_type( + "replica.compatible-policy", + [("schema_version", 0), ("name", 1)], + schema_id="replica.compatible-policy.current", + schema_name="replica.compatible_policy", + schema_version="replica.compatible_policy.v1", + compatible_schema_ids=["replica.compatible-policy.current", "replica.compatible-policy.older"], + ) + value = {"schema_version": "replica.compatible_policy.v1", "name": "synced"} + record = { + "schemaId": "replica.compatible-policy.older", + "recordKey": "policy:1", + "storedAt": "2026-06-25T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload(value), + } + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "store.msgpack" + + def open_cache() -> CultCache: + cache = CultCache.builder().register_document_type(document).add_generic_store( + SingleFileMessagePackBackingStore(path) + ).build() + cache.pull_all_backing_stores() + return cache + + cache = open_cache() + applied = apply_raw_document_record(cache, schema_document_map([document]), record) + self.assertEqual(applied.schema_id, document.catalog_entry().schema_id) + self.assertEqual( + cache.get_required_envelope(document, "policy:1").schema_id, document.catalog_entry().schema_id + ) + reopened = open_cache() + self.assertEqual(reopened.get_required(document, "policy:1")["name"], "synced") + reopened.put(document, "policy:2", {**value, "name": "next"}) + self.assertEqual(open_cache().get_required(document, "policy:2")["name"], "next") + def test_cultnet_document_delete_helper_matches_schema_v0_shape(self) -> None: message = document_delete( message_id="delete-1", @@ -2190,9 +2235,11 @@ class CultNetTests(unittest.TestCase): self.assertEqual(delete_schema["properties"]["recordKey"]["type"], "string") error_schema = json.loads(by_version["cultnet.error.v0"]["schemaJson"]) - self.assertEqual(error_schema["required"], ["schemaVersion", "error"]) + contract = json.loads((Path(__file__).resolve().parents[3] / "contracts" / "cultnet" / "cultnet.error.schema.json").read_text(encoding="utf-8")) + self.assertEqual(error_schema["required"], contract["required"]) + self.assertEqual(sorted(error_schema["properties"]), sorted(contract["properties"])) self.assertEqual(error_schema["properties"]["error"]["type"], "string") - self.assertIn("details", error_schema["properties"]) + self.assertEqual(error_schema["properties"]["code"]["enum"], contract["properties"]["code"]["enum"]) login_schema = json.loads(by_version["cultnet.login.v0"]["schemaJson"]) self.assertEqual(login_schema["required"], ["schemaVersion", "nonce", "auth", "password"]) @@ -2425,9 +2472,9 @@ class CultNetTests(unittest.TestCase): witness_kind="interop-proof", captured_at="2026-06-13T00:00:03Z", subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"}, - contracts=[{"role": "payload", "schemaId": "schema-a"}], - artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}], - timing_witnesses=[{"stage": "roundtrip", "startedAt": "2026-06-13T00:00:03Z", "completedAt": "2026-06-13T00:00:04Z", "latencyMs": 1.0}], + contracts=[{"id": "c0e1a2b3c4d5", "role": "payload", "schemaId": "schema-a"}], + artifacts=[{"id": "a0e1a2b3c4d5", "role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}], + timing_witnesses=[{"id": "e0e1a2b3c4d5", "stage": "roundtrip", "startedAt": "2026-06-13T00:00:03Z", "completedAt": "2026-06-13T00:00:04Z", "latencyMs": 1.0}], provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-test"}, ) payload = encode_witness_artifact_bundle_payload(bundle) @@ -2443,6 +2490,8 @@ class CultNetTests(unittest.TestCase): self.assertEqual(slots[2], "2026-06-13T00:00:03Z") self.assertEqual(slots[3]["subjectId"], "note:python") self.assertEqual(slots[4][0]["schemaId"], "schema-a") + self.assertEqual([slots[4][0]["id"], slots[5][0]["id"], slots[6][0]["id"]], ["c0e1a2b3c4d5", "a0e1a2b3c4d5", "e0e1a2b3c4d5"]) + self.assertEqual(typed_decoded.contracts[0]["id"], "c0e1a2b3c4d5") self.assertEqual(slots[5][0]["uri"], "cultcache://bundle-1/log") self.assertEqual(slots[6][0]["latencyMs"], 1.0) self.assertEqual(slots[7]["runtimeId"], "python-test") @@ -2462,7 +2511,7 @@ class CultNetTests(unittest.TestCase): runtime_kind="python", display_name="Python Interop Test", agent_id="python-interop-test-agent", - schema_path=str(Path("contracts/cultnet/interop/cultnet.interop-note.schema.json")), + schema_path=str(Path(__file__).resolve().parents[3] / "contracts" / "cultnet" / "interop" / "cultnet.interop-note.schema.json"), ) binding = state.bindings["note"] logged_value = { @@ -2531,7 +2580,8 @@ class CultNetTests(unittest.TestCase): self.assertEqual(len(responses), 1) self.assertEqual(responses[0]["schemaVersion"], "cultnet.error.v0") self.assertEqual(responses[0]["code"], "unsupported_schema_version") - self.assertEqual(responses[0]["messageId"], "v1-refused") + self.assertEqual(responses[0]["details"]["value"], schema_version) + self.assertNotIn("messageId", responses[0]) def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None: from cultnet_py import CultNetSimulationConsensus diff --git a/packages/cultnet-py/tests/test_cultnet_error.py b/packages/cultnet-py/tests/test_cultnet_error.py new file mode 100644 index 00000000..2904e679 --- /dev/null +++ b/packages/cultnet-py/tests/test_cultnet_error.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +import msgpack + +from cultnet_py import CULTNET_ERROR_CODES, error_message, parse_message, unsupported_schema_version_error + +# contracts/cultnet/interop/error-vectors.json: bytes captured from MessagePack C# 3.1.7 and pinned +# by cultnet-rs/tests/error_message.rs (Rust encodes to exactly these bytes). +_REPO = Path(__file__).resolve().parents[3] +_VECTORS = json.loads((_REPO / "contracts/cultnet/interop/error-vectors.json").read_text(encoding="utf-8"))["vectors"] +_SCHEMA = json.loads((_REPO / "contracts/cultnet/cultnet.error.schema.json").read_text(encoding="utf-8")) + + +class CultNetErrorMessageTests(unittest.TestCase): + def test_closed_code_set_equals_the_schema_enum(self) -> None: + schema_codes = [c for c in _SCHEMA["properties"]["code"]["enum"] if c is not None] + self.assertEqual(sorted(CULTNET_ERROR_CODES), sorted(schema_codes)) + + def test_vectors_cover_every_code(self) -> None: + self.assertEqual({v["message"]["code"] for v in _VECTORS}, set(CULTNET_ERROR_CODES)) + + def test_decodes_csharp_and_rust_written_bytes_with_every_key(self) -> None: + for vector in _VECTORS: + message = parse_message(bytes.fromhex(vector["messagePackHex"])) + self.assertEqual(message.to_wire(), vector["message"], vector["name"]) + self.assertEqual( + list(message.to_wire()), ["schemaVersion", "error", "routingHint", "code", "details"], vector["name"] + ) + + def test_reencode_is_byte_identical(self) -> None: + for vector in _VECTORS: + expected = bytes.fromhex(vector["messagePackHex"]) + self.assertEqual(parse_message(expected).to_bytes(), expected, vector["name"]) + + def test_builder_produces_the_csharp_bytes(self) -> None: + by_name = {v["name"]: bytes.fromhex(v["messagePackHex"]) for v in _VECTORS} + stale = error_message( + "cursor_stale: cursor is stale", code="cursor_stale", details={"asOf": 1, "current": 2} + ) + self.assertEqual(stale.to_bytes(), by_name["cursor_stale"]) + invalid = error_message("cursor_invalid: cannot parse cursor", code="cursor_invalid") + self.assertEqual(invalid.to_bytes(), by_name["cursor_invalid"]) + selection = error_message( + "selection_invalid: blank key entry", + code="selection_invalid", + details={"field": "keys", "value": " "}, + ) + self.assertEqual(selection.to_bytes(), by_name["selection_invalid_field_value"]) + self.assertEqual(unsupported_schema_version_error("cultnet.hello.v1").to_bytes(), by_name["unsupported_schema_version"]) + + def test_builder_writes_nil_keys_and_accepts_a_null_code(self) -> None: + wire = error_message("boom").to_wire() + self.assertEqual(wire, {"schemaVersion": "cultnet.error.v0", "error": "boom", "routingHint": None, "code": None, "details": None}) + + def test_unknown_code_is_refused_by_builder_and_decoder(self) -> None: + with self.assertRaises(ValueError): + error_message("x", code="not_a_code") + bad = msgpack.packb( + {"schemaVersion": "cultnet.error.v0", "error": "x", "routingHint": None, "code": "nope", "details": None}, + use_bin_type=True, + ) + with self.assertRaises(ValueError): + parse_message(bad) + + def test_dropping_code_or_details_is_refused(self) -> None: + vector = next(v for v in _VECTORS if v["name"] == "cursor_stale") + for dropped in ("code", "details", "routingHint"): + wire = dict(vector["message"]) + del wire[dropped] + with self.assertRaises(ValueError, msg=dropped): + parse_message(msgpack.packb(wire, use_bin_type=True)) + + def test_malformed_details_are_refused(self) -> None: + base = dict(next(v for v in _VECTORS if v["name"] == "cursor_stale")["message"]) + for details in ( + {"field": None, "value": None, "asOf": 1}, + {"field": None, "value": None, "asOf": -1, "current": 2}, + {"field": None, "value": None, "asOf": 1, "current": 2, "extra": 1}, + ): + with self.assertRaises(ValueError, msg=str(details)): + parse_message(msgpack.packb({**base, "details": details}, use_bin_type=True)) + with self.assertRaises(ValueError): + error_message("x", code="cursor_stale", details={"asOf": 1, "bogus": 2}) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultnet-py/tests/test_first_call.py b/packages/cultnet-py/tests/test_first_call.py new file mode 100644 index 00000000..9815043c --- /dev/null +++ b/packages/cultnet-py/tests/test_first_call.py @@ -0,0 +1,72 @@ +"""The Python CultNet first call from docs/start/README.md. The docs show the region between the +`snippet` markers verbatim, and test_docs_show_the_tested_snippet fails when they differ.""" + +import unittest +from pathlib import Path + +# snippet:begin +import socket +from dataclasses import asdict, dataclass + +from cultcache_py import CultCache, define_document_type +from cultnet_py import ( + apply_raw_document_record, + document_put_raw, + encode_frame, + parse_message, + read_frame, + schema_document_map, +) + + +@dataclass +class Note: + title: str + body: str + + +note_doc = define_document_type( + "note", + encode=lambda note: asdict(note), + decode=lambda raw: Note(**raw), + name="title", +) + + +def first_call() -> Note: + sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP + + # Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame. + put = document_put_raw( + message_id="put-1", + key="note:hello", + schema_id=note_doc.catalog_entry().schema_id, + stored_at="2026-01-01T00:00:00Z", + payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")), + ) + sender.sendall(encode_frame(put.to_bytes())) + + # Receive: read the frame, parse the message, apply its record to a second cache. + message = parse_message(read_frame(receiver.makefile("rb"))) + assert message.schema_version == "cultnet.document_put_raw.v0" + inbox = CultCache.builder().register_document_type(note_doc).build() + apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"]) + sender.close() + receiver.close() + return inbox.get_required(note_doc, "note:hello") +# snippet:end + + +class FirstCallTests(unittest.TestCase): + def test_a_typed_document_crosses_a_pipe_into_a_second_cache(self) -> None: + self.assertEqual(first_call(), Note("Hello", "sent over CultNet")) + + def test_docs_show_the_tested_snippet(self) -> None: + source = Path(__file__).read_text(encoding="utf-8") + snippet = source.split("# snippet:begin\n", 1)[1].split("# snippet:end\n", 1)[0] + docs = (Path(__file__).parents[3] / "docs" / "start" / "README.md").read_text(encoding="utf-8") + self.assertIn("```python\n" + snippet + "```", docs) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultnet-py/tests/test_rudp_multipeer_server.py b/packages/cultnet-py/tests/test_rudp_multipeer_server.py new file mode 100644 index 00000000..3bb14640 --- /dev/null +++ b/packages/cultnet-py/tests/test_rudp_multipeer_server.py @@ -0,0 +1,350 @@ +"""One UDP socket serves many peers, each in its own session, as the C# reference does. + +Sockets bind port 0, so no test races another for a port. +""" +from __future__ import annotations + +import errno +import socket +import time +import unittest +from pathlib import Path + +from cultnet_py.rudp_server import ( + CultNetRudpSocketServerOptions, + CultNetRudpSocketTransportServer, +) +from cultnet_py.transport import ( + CultNetRudpPacketType, + CultNetRudpSendOptions, + CultNetRudpSession, + CultNetRudpSessionOptions, + decode_rudp_packet, + encode_rudp_packet, +) + +CONNECTION_ID = 0x43554C54 +VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "rudp-multipeer-server" / "transcript.txt" + + +def bind() -> socket.socket: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.bind(("127.0.0.1", 0)) + sock.settimeout(0.03) + return sock + + +def make_server(**kwargs) -> CultNetRudpSocketTransportServer: + options = dict(runtime_id="py-test", socket=bind(), connection_id=CONNECTION_ID, resend_delay_ms=5) + options.update(kwargs) + return CultNetRudpSocketTransportServer(CultNetRudpSocketServerOptions(**options)) + + +def pump(server: CultNetRudpSocketTransportServer) -> list: + """Consumes datagrams until the socket is idle, returning the frames delivered.""" + frames = [] + while True: + progressed, frame = server.try_receive_once() + if frame is not None: + frames.append(frame) + if not progressed: + return frames + + +def drain(sock: socket.socket) -> list[bytes]: + wires = [] + while True: + try: + wires.append(sock.recvfrom(65535)[0]) + except TimeoutError: + return wires + + +class Client: + def __init__(self, server: CultNetRudpSocketTransportServer, initial_sequence: int) -> None: + self.server = server + self.socket = bind() + self.session = CultNetRudpSession( + CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=initial_sequence) + ) + + @property + def addr(self) -> tuple: + return self.socket.getsockname() + + def transmit(self, packet) -> None: + self.socket.sendto(encode_rudp_packet(packet), self.server.socket.getsockname()) + + def connect(self, payload: bytes = b"") -> None: + self.transmit(self.session.create_connect(0, payload)) + pump(self.server) + self.receive() + + def receive(self) -> list: + packets = [decode_rudp_packet(wire) for wire in drain(self.socket)] + for packet in packets: + if self.session.connected or packet.packet_type == CultNetRudpPacketType.ACCEPT: + self.session.receive(packet, 0) + return packets + + def write(self, text: str) -> None: + self.transmit( + self.session.send("schema", text.encode(), CultNetRudpSendOptions(reliable=True, ordered=True)) + ) + + def peer(self): + return next(p for p in self.server.peers if p.remote_addr == self.addr) + + def close(self) -> None: + self.socket.close() + + +class MultiPeerServerTests(unittest.TestCase): + def setUp(self) -> None: + self.server = make_server(initial_sequence=500, accept_payload=b"acc") + self.addCleanup(self.server.close) + self.clients: list[Client] = [] + + def client(self, initial_sequence: int) -> Client: + client = Client(self.server, initial_sequence) + self.clients.append(client) + self.addCleanup(client.close) + return client + + def test_interleaved_peers_each_get_their_own_session_and_frames(self) -> None: + x, y = self.client(10), self.client(20) + x.connect(b"hx") + y.connect(b"hy") + self.assertEqual(len(self.server.peers), 2) + self.assertEqual(x.peer().connect_payload, b"hx") + self.assertEqual(y.peer().connect_payload, b"hy") + + x.write("x1") + y.write("y1") + x.write("x2") + y.write("y2") + frames = pump(self.server) + by_peer = {} + for item in frames: + by_peer.setdefault(item.peer.remote_addr, []).append(item.frame.payload) + self.assertEqual(by_peer, {x.addr: [b"x1", b"x2"], y.addr: [b"y1", b"y2"]}) + + self.server.send(x.peer(), "schema", b"to-x") + self.server.send(y.peer(), "schema", b"to-y") + for client, expected in ((x, b"to-x"), (y, b"to-y")): + data = [p for p in client.receive() if p.packet_type == CultNetRudpPacketType.DATA] + self.assertEqual([p.payload for p in data], [expected]) + + def test_one_peer_ending_does_not_disturb_the_other(self) -> None: + x, y = self.client(10), self.client(20) + x.connect() + y.connect() + y_peer = y.peer() + ended = [] + self.server.peer_disconnected.append(ended.append) + + x.transmit(x.session.create_disconnect(b"bye")) + pump(self.server) + self.assertEqual(len(self.server.peers), 1) + self.assertEqual([p.disconnect_reason for p in ended], [b"bye"]) + self.assertIs(y.peer(), y_peer) + + # Datagrams from the ended endpoint belong to no session. + before = self.server.stats.packets_dropped + x_late = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=99)) + x_late.accept_connect(x.session.create_connect(0), 0) + x.transmit(x_late.send("schema", b"late", CultNetRudpSendOptions(reliable=True, ordered=True))) + pump(self.server) + self.assertEqual(self.server.stats.packets_dropped, before + 1) + + y.write("still here") + self.assertEqual([f.frame.payload for f in pump(self.server)], [b"still here"]) + self.server.send(y_peer, "schema", b"reply") + self.assertEqual( + [p.payload for p in y.receive() if p.packet_type == CultNetRudpPacketType.DATA], [b"reply"] + ) + + def test_a_new_connect_from_the_same_endpoint_replaces_only_that_peer(self) -> None: + x, y = self.client(10), self.client(20) + x.connect() + y.connect() + old_y = y.peer() + ended = [] + self.server.peer_disconnected.append(ended.append) + + y.session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=30)) + y.connect(b"again") + self.assertEqual(ended, [old_y]) + self.assertEqual(old_y.disconnect_reason, b"replaced by a new Connect generation") + self.assertIsNot(y.peer(), old_y) + self.assertEqual(y.peer().connect_payload, b"again") + with self.assertRaises(ConnectionError): + self.server.send(old_y, "schema", b"to the old session") + self.assertEqual(len(self.server.peers), 2) + + def test_a_repeated_connect_is_answered_with_the_owed_accept_and_starts_nothing(self) -> None: + x = self.client(10) + connect = x.session.create_connect(0, b"h") + x.transmit(connect) + pump(self.server) + first = x.peer() + x.transmit(connect) + pump(self.server) + self.assertIs(x.peer(), first) + accepts = [p for p in x.receive() if p.packet_type == CultNetRudpPacketType.ACCEPT] + self.assertEqual(len(accepts), 2) + self.assertEqual(accepts[0].sequence, accepts[1].sequence) + + def test_strays_are_dropped_and_counted(self) -> None: + x = self.client(10) + x.socket.sendto(b"not rudp", self.server.socket.getsockname()) + other = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID + 1, initial_sequence=1)) + x.transmit(other.create_connect(0)) + x.transmit( + CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=1)).create_ping() + ) + pump(self.server) + self.assertEqual(self.server.stats.packets_dropped, 3) + self.assertEqual(self.server.peers, ()) + + def test_resends_go_only_to_peers_with_unacknowledged_writes(self) -> None: + x, y = self.client(10), self.client(20) + x.connect() + y.connect() + self.server.send(x.peer(), "schema", b"owed") + x.receive() # the first transmission is lost to the test: x never acks + y.receive() + time.sleep(0.02) + self.server.poll_resends() + x_data = [p for p in x.receive() if p.packet_type == CultNetRudpPacketType.DATA] + self.assertEqual([p.payload for p in x_data], [b"owed"]) + self.assertEqual([p for p in y.receive() if p.packet_type == CultNetRudpPacketType.DATA], []) + + def test_broadcast_reaches_every_peer(self) -> None: + x, y = self.client(10), self.client(20) + x.connect() + y.connect() + self.assertEqual(self.server.broadcast("schema", b"all"), []) + for client in (x, y): + self.assertEqual( + [p.payload for p in client.receive() if p.packet_type == CultNetRudpPacketType.DATA], [b"all"] + ) + + def test_an_application_ending_a_session_tells_the_peer_and_drops_its_queued_frames(self) -> None: + x, y = self.client(10), self.client(20) + x.connect() + y.connect() + x.write("x1") + y.write("y1") + # Both frames are decoded into the server's queue; ending x discards x's. + progressed, first = self.server.try_receive_once() + self.assertTrue(progressed) + pump_rest = pump(self.server) + frames = [first] + pump_rest if first else pump_rest + peer_x = x.peer() + self.assertTrue(self.server.end_peer_session(peer_x, b"server could not handle a message")) + self.assertFalse(self.server.end_peer_session(peer_x, b"again"), "an ended session owes no second goodbye") + goodbye = [p for p in x.receive() if p.packet_type == CultNetRudpPacketType.DISCONNECT] + self.assertEqual([p.payload for p in goodbye], [b"server could not handle a message"]) + self.assertEqual(len(self.server.peers), 1) + self.assertEqual({f.peer.remote_addr for f in frames}, {x.addr, y.addr}) + + def test_a_datagram_too_large_to_send_is_that_callers_error_and_queues_nothing(self) -> None: + x = self.client(10) + x.connect() + peer = x.peer() + owed = peer.pending_reliable_packet_count # the Accept, not yet acknowledged + with self.assertRaises(OSError) as raised: + self.server.send(peer, "schema", bytes(70_000)) + self.assertEqual(raised.exception.errno, errno.EMSGSIZE) + self.assertEqual(peer.pending_reliable_packet_count, owed) + self.assertEqual(len(self.server.peers), 1) + # The refused write left no gap in the sequence space the peer is waiting on. + self.server.send(peer, "schema", b"fine") + data = [p for p in x.receive() if p.packet_type == CultNetRudpPacketType.DATA] + self.assertEqual([(p.sequence, p.payload) for p in data], [(501, b"fine")]) + + +class UnsendableAfterFirstFragmentSocket: + """Delegates to a real socket; fails permanently once its budget of sends to one address is spent.""" + + def __init__(self, real: socket.socket, addr: tuple, budget: int) -> None: + self._real, self._addr, self._budget = real, addr, budget + + def sendto(self, wire: bytes, addr: tuple) -> int: + if tuple(addr) == self._addr: + if self._budget == 0: + raise OSError(errno.EMSGSIZE, "Message too long") + self._budget -= 1 + return self._real.sendto(wire, addr) + + def __getattr__(self, name: str): + return getattr(self._real, name) + + +class UnsendableSessionTests(unittest.TestCase): + def test_a_later_fragment_that_cannot_be_sent_ends_that_session_only(self) -> None: + server = make_server(initial_sequence=500, max_fragment_bytes=8) + self.addCleanup(server.close) + x, y = Client(server, 10), Client(server, 20) + self.addCleanup(x.close) + self.addCleanup(y.close) + x.connect() + y.connect() + x_peer = x.peer() + server.socket = UnsendableAfterFirstFragmentSocket(server.socket, x.addr, budget=1) + ended = [] + server.peer_disconnected.append(ended.append) + + with self.assertRaises(OSError): + server.send(x_peer, "schema", bytes(24)) + self.assertEqual(ended, [x_peer]) + self.assertEqual(x_peer.disconnect_reason, b"packet could not be sent: EMSGSIZE") + self.assertEqual(len(server.peers), 1) + # The goodbye could not be sent either (same address), so the peer is only retired; y is unharmed. + server.send(y.peer(), "schema", b"ok") + self.assertEqual( + [p.payload for p in y.receive() if p.packet_type == CultNetRudpPacketType.DATA], [b"ok"] + ) + + +class ReferenceTranscriptTests(unittest.TestCase): + """Replays the datagrams the C# reference listener was captured answering, byte for byte.""" + + def test_the_listener_answers_with_the_bytes_the_reference_does(self) -> None: + server = make_server( + connection_id=0x43554C54, initial_sequence=500, max_fragment_bytes=8, accept_payload=b"acc", resend_delay_ms=250 + ) + self.addCleanup(server.close) + sockets = {label: bind() for label in ("X", "Y")} + for sock in sockets.values(): + self.addCleanup(sock.close) + target = server.socket.getsockname() + + steps: list[tuple[list[str], list[tuple[str, str]]]] = [] + for line in VECTORS.read_text().splitlines(): + if line.startswith("#") or not line.strip(): + continue + fields = line.split(" ") + if fields[0] == "out": + steps[-1][1].append((fields[1], fields[2])) + else: + steps.append((fields, [])) + self.assertGreater(len(steps), 10) + + for action, expected in steps: + if action[0] == "in": + sockets[action[1]].sendto(bytes.fromhex(action[2]), target) + while not server.try_receive_once()[0]: + time.sleep(0.001) + else: + peer = next(p for p in server.peers if p.remote_addr == sockets[action[1]].getsockname()) + server.send(peer, action[2], bytes.fromhex(action[3])) + actual = [(label, wire.hex()) for label in ("X", "Y") for wire in drain(sockets[label])] + self.assertEqual(actual, expected, f"after {' '.join(action[:2])}") + self.assertEqual(len(server.peers), 1) + self.assertEqual(server.stats.packets_dropped, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultnet-rs/CHANGELOG.md b/packages/cultnet-rs/CHANGELOG.md new file mode 100644 index 00000000..c2600756 --- /dev/null +++ b/packages/cultnet-rs/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to `cultnet-rs` are recorded here, following +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Earlier releases are recorded +only in git history. + +## Unreleased + +### Added + +- The provider half of `gamecult.runtime_presence_health.v2` for Idunn-managed Rust services, in one module that builds and runs on every platform an Idunn driver launches (unix under Idunn's systemd driver, Windows under its host driver). `IdunnRuntimeAuthority::open(target, health_contract, credentials)` reads the runtime bundle, the candidate bind and the write lease from the launch environment Idunn set (no caller supplies a path) and checks every binding Idunn expects (target and health contract, activation bound to Expected, both signing credentials, the write-lease and candidate-bind environment; the candidate bind must equal Expected's route exactly). The credentials' delivery decides the custody the bundle is held to: `IdunnRuntimeCredentials::from_systemd_descriptors` (unix; found by name per sd_listen_fds_with_names(3), `LISTEN_PID` or the `PrivatePIDs` pid-1 rule, root-owned 0400 single-link read-only files, close-on-exec, `LISTEN_*` removed from the environment) carries root custody (bundle directory, `expected.cc`, `activation.cc`, their `.lock` siblings and every parent root-owned, not group- or world-writable, no symlinks, and the same for the write lease), while `from_host_environment` (the host driver's files; the provider identity is opened before the one-shot activation credential is read and removed, and a refusal leaves the credential in place) carries profile custody on every platform (the bundle is a real directory of real files, never a link: std's `is_symlink` refuses symlinks and, on Windows, junctions). No refusal names a path; an unreadable store is reported by its failure kind alone (not decodable, unsupported format, not found, permission denied, I/O error). `IdunnRuntimePresenceSigner` takes the authority by value, owns the reported health (`RuntimePresenceState`, starting Warming; `report_health`), signs validated presence records with `sign` / `sign_at` (returning `SignedRuntimePresence`: record, raw CultNet document and canonical digest), holds Idunn's process write lease through a `ProcessWriteLeaseGuard` (unix only; `try_acquire_write_lease`; the guard holds the lease's `.lock` shared and `require_current` detects replacement; a runtime whose Expected requires a lease is refused at `open` elsewhere with `write_lease_required`), keeps signing Failed, without a lease digest, after the lease is lost, and answers Idunn's route challenge from local authority with `answer_route_observation`. The module owns no network. No wire bytes change: its output passes `authenticate_runtime_presence_claim` and `correlate_runtime_presence_claim`. New unix-only dependencies: `libc`, `fs2`. +- The launch-contract names both sides of an Idunn launch read: `IDUNN_RUNTIME_BUNDLE_ENVIRONMENT`, `IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT`, `IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT`, `IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT`, `RUNTIME_PRESENCE_IDENTITY_BINDING` and `RUNTIME_PRESENCE_IDENTITY_FD_NAME`. +- Version 0.3.0: the unreleased `register` and `CultMeshCdnChunkRef` breaking changes below follow the 0.y.z minor lane. + +### Breaking + +- `CultNetDocumentRegistry::register` returns `Result<&mut Self>` where it returned `&mut Self`. A binding that claims a document type or a schema id another binding already holds is refused with `cultcache_rs::SchemaConflictError` (naming the new binding's schema id and both document types) and nothing is bound; binding a type to the schema id it already holds still replaces that binding. Callers chain `register(...)?`. +- `CultMeshCdnChunkRef` gains a public `id` field, the decimal text of `offset` (the reference's element id, slot 4). A manifest written before ids reads `""`; encoding writes the slot. diff --git a/packages/cultnet-rs/Cargo.toml b/packages/cultnet-rs/Cargo.toml index 13d87867..77f368d4 100644 --- a/packages/cultnet-rs/Cargo.toml +++ b/packages/cultnet-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cultnet-rs" -version = "0.2.0" +version = "0.3.0" edition = "2024" publish = false license = "MIT" @@ -29,6 +29,10 @@ zeroize = "1" socket2 = "0.5" uuid = { version = "1", features = ["v4"] } +[target.'cfg(unix)'.dependencies] +fs2 = "0.4" +libc = "0.2" + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security_Cryptography"] } diff --git a/packages/cultnet-rs/README.md b/packages/cultnet-rs/README.md index 48ac2dd3..c356b97b 100644 --- a/packages/cultnet-rs/README.md +++ b/packages/cultnet-rs/README.md @@ -1,6 +1,6 @@ # CultNet RS -`cultnet-rs` is the Rust sibling of `cultnet-ts`: typed MessagePack messages, +`cultnet-rs` is the Rust sibling of `@gamecult/cultnet-ts`: typed MessagePack messages, 4-byte length-prefixed direct-pipe framing, CultLib-shaped security helpers, and CultCache replication without making callers paw through raw envelopes like we lost a bet. @@ -100,7 +100,7 @@ game-session authority remain separate responsibilities. ## Local Fast Lane -`cultnet-rs` now mirrors the raw replication seam from `cultnet-ts`: +`cultnet-rs` now mirrors the raw replication seam from `@gamecult/cultnet-ts`: - `cultnet.document_put_raw.v0` - `cultnet.snapshot_response_raw.v0` diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json index 59764338..341de5af 100644 --- a/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json @@ -28,7 +28,7 @@ "code": { "description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).", "type": ["string", "null"], - "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema"] + "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version"] }, "details": { "description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.", @@ -36,8 +36,8 @@ "additionalProperties": false, "required": ["field", "value", "asOf", "current"], "properties": { - "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\".", "type": ["string", "null"] }, - "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns.", "type": ["string", "null"] }, + "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\".", "type": ["string", "null"] }, + "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] }, "asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 }, "current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 } } diff --git a/packages/cultnet-rs/examples/cultnet_interop_peer.rs b/packages/cultnet-rs/examples/cultnet_interop_peer.rs index a2a4847e..3d6700bf 100644 --- a/packages/cultnet-rs/examples/cultnet_interop_peer.rs +++ b/packages/cultnet-rs/examples/cultnet_interop_peer.rs @@ -262,7 +262,7 @@ fn serve(config: PeerConfig) -> Result<()> { cache.put(¬e.document_id, ¬e)?; let mut document_registry = CultNetDocumentRegistry::new(); - register_capability_bindings(&mut document_registry, &schema_registration.schema_id); + register_capability_bindings(&mut document_registry, &schema_registration.schema_id)?; let cache = Arc::new(Mutex::new(cache)); let document_registry = Arc::new(document_registry); @@ -384,7 +384,7 @@ fn dial(config: DialConfig) -> Result<()> { cache.pull_all_backing_stores()?; let mut document_registry = CultNetDocumentRegistry::new(); - register_capability_bindings(&mut document_registry, &schema_registration.schema_id); + register_capability_bindings(&mut document_registry, &schema_registration.schema_id)?; let transport_name = if config.target_rudp_port.is_some() { "rudp" @@ -1227,38 +1227,39 @@ fn register_capability_entry_types(cache: &mut CultCache) -> Result<()> { fn register_capability_bindings( document_registry: &mut CultNetDocumentRegistry, note_schema_id: &str, -) { +) -> Result<()> { document_registry .register(CultNetDocumentBinding::for_entry_with_schema_id::< CultNetInteropNote, >( note_schema_id.to_string(), INTEROP_SCHEMA_VERSION.to_string(), - )) + ))? .register(CultNetDocumentBinding::for_entry_with_schema_id::< CultNetInteropMutationIntent, >( MUTATION_INTENT_SCHEMA_ID.to_string(), MUTATION_INTENT_SCHEMA_VERSION.to_string(), - )) + ))? .register(CultNetDocumentBinding::for_entry_with_schema_id::< CultNetInteropMutationReceipt, >( MUTATION_RECEIPT_SCHEMA_ID.to_string(), MUTATION_RECEIPT_SCHEMA_VERSION.to_string(), - )) + ))? .register(CultNetDocumentBinding::for_entry_with_schema_id::< CultNetInteropFireCommand, >( FIRE_COMMAND_SCHEMA_ID.to_string(), FIRE_COMMAND_SCHEMA_VERSION.to_string(), - )) + ))? .register(CultNetDocumentBinding::for_entry_with_schema_id::< CultNetInteropFireReceipt, >( FIRE_RECEIPT_SCHEMA_ID.to_string(), FIRE_RECEIPT_SCHEMA_VERSION.to_string(), - )); + ))?; + Ok(()) } fn interaction_contracts() -> Vec { diff --git a/packages/cultnet-rs/src/content.rs b/packages/cultnet-rs/src/content.rs index 843d4b86..dbb58ff2 100644 --- a/packages/cultnet-rs/src/content.rs +++ b/packages/cultnet-rs/src/content.rs @@ -27,6 +27,8 @@ pub struct CultMeshCdnChunkRef { pub offset: i64, pub size_bytes: i32, pub record_key: String, + /// Element id (key 4): the decimal text of `offset`, never random. Absent in a manifest written before ids. + pub id: String, } /// `CultMeshCdnArtifactManifest`. Encodes as the reference's positional array (keys 0-9). @@ -50,7 +52,7 @@ pub struct CultMeshCdnArtifactManifest { // The tuple mirrors: `to_vec_named` would write a map for a named-field struct, but a tuple // struct is an array under every serde encoder. #[derive(Serialize, Deserialize)] -struct ChunkRefWire(String, i64, i32, String); +struct ChunkRefWire(String, i64, i32, String, #[serde(default)] String); #[derive(Serialize, Deserialize)] struct ManifestWire( @@ -105,7 +107,7 @@ mod ordered_map { impl From for ChunkRefWire { fn from(value: CultMeshCdnChunkRef) -> Self { - Self(value.chunk_hash, value.offset, value.size_bytes, value.record_key) + Self(value.chunk_hash, value.offset, value.size_bytes, value.record_key, value.id) } } @@ -116,6 +118,7 @@ impl From for CultMeshCdnChunkRef { offset: value.1, size_bytes: value.2, record_key: value.3, + id: value.4, } } } @@ -217,6 +220,7 @@ pub fn pack_content( offset, // A piece is at most `chunk_size` bytes, which fits an i32 (checked above). size_bytes: piece.len() as i32, + id: offset.to_string(), }); offset += piece.len() as i64; chunks.push(CultMeshCdnChunk { diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index e4f67164..bf661df2 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -246,6 +246,8 @@ pub enum CultNetErrorCode { ReferenceOutsideTarget, VariantUnsupported, UnownedSchema, + WriteNotAuthorized, + UnsupportedSchemaVersion, } impl CultNetErrorCode { @@ -257,6 +259,8 @@ impl CultNetErrorCode { Self::ReferenceOutsideTarget => "reference_outside_target", Self::VariantUnsupported => "variant_unsupported", Self::UnownedSchema => "unowned_schema", + Self::WriteNotAuthorized => "write_not_authorized", + Self::UnsupportedSchemaVersion => "unsupported_schema_version", } } @@ -268,6 +272,8 @@ impl CultNetErrorCode { "reference_outside_target" => Ok(Self::ReferenceOutsideTarget), "variant_unsupported" => Ok(Self::VariantUnsupported), "unowned_schema" => Ok(Self::UnownedSchema), + "write_not_authorized" => Ok(Self::WriteNotAuthorized), + "unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion), _ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")), } } diff --git a/packages/cultnet-rs/src/idunn_runtime_provider.rs b/packages/cultnet-rs/src/idunn_runtime_provider.rs new file mode 100644 index 00000000..6e4173ed --- /dev/null +++ b/packages/cultnet-rs/src/idunn_runtime_provider.rs @@ -0,0 +1,1404 @@ +//! The provider half of `gamecult.runtime_presence_health.v2` for Rust runtimes. +//! +//! An Idunn-managed service attests its own health: it opens the root-provisioned +//! runtime bundle with the two signing credentials Idunn hands it, reports its +//! health as it changes, and signs presence records that Odin can authenticate +//! and Idunn's route challenge can read. This module owns that provider half and +//! owns no network; publishing the signed records is a separate concern that +//! consumes [`IdunnRuntimePresenceSigner::sign`]. +//! +//! The module compiles and runs on every platform an Idunn driver launches: +//! Idunn's systemd driver (unix) delivers the credentials as inherited +//! descriptors under root custody, its host driver (Windows) as files in the +//! user's profile. Only the descriptor delivery, root custody and the write-lease +//! lock are unix-only. +//! +//! ```no_run +//! use cultnet_rs::{ +//! GameCultRuntimeCapability, IdunnRuntimeAuthority, IdunnRuntimeCredentials, +//! IdunnRuntimePresenceSigner, RuntimePresenceState, +//! }; +//! +//! # fn main() -> anyhow::Result<()> { +//! // The host driver's delivery. Under Idunn's systemd driver (unix) use +//! // `IdunnRuntimeCredentials::from_systemd_descriptors()` instead. +//! let credentials = IdunnRuntimeCredentials::from_host_environment()?; +//! // The runtime bundle, the candidate bind and the write lease are read from the +//! // launch environment Idunn set. +//! let authority = IdunnRuntimeAuthority::open( +//! "my-service", +//! "my-service.runtime-health.v1", +//! credentials, +//! )?; +//! authority.require_candidate_bind("127.0.0.1:14103")?; +//! let mut signer = IdunnRuntimePresenceSigner::new( +//! authority, +//! vec![GameCultRuntimeCapability { +//! capability: "my.capability".into(), +//! schema: "my.schema.v1".into(), +//! compatibility: "v1".into(), +//! capacity: 1, +//! }], +//! ); +//! let warming = signer.sign("starting")?; // the signer starts Warming +//! signer.report_health(RuntimePresenceState::Active); +//! let active = signer.sign("ready")?; +//! # let _ = (warming, active); +//! # Ok(()) +//! # } +//! ``` + +use std::fs::File; +#[cfg(unix)] +use std::fs::OpenOptions; +use std::io::Read; +#[cfg(unix)] +use std::os::fd::{AsRawFd, FromRawFd, RawFd}; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, anyhow, bail, ensure}; +use cultcache_rs::{CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore}; +use zeroize::Zeroizing; + +#[cfg(unix)] +use crate::IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME; +#[cfg(unix)] +use crate::RUNTIME_PRESENCE_IDENTITY_FD_NAME; +use crate::{ + CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, + GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, GameCultProviderHealthIdentity, + GameCultRuntimeCapability, GameCultRuntimePresenceHealthPurpose, + GameCultRuntimePresenceHealthRecord, IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + IDUNN_EXPECTED_INCARNATION_SCHEMA, IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, IdunnExpectedIncarnationRecord, + IdunnRuntimeActivationRecord, IdunnRuntimeActivationSigner, RUNTIME_PRESENCE_IDENTITY_BINDING, + ServiceIdentitySigner, open_service_identity_credential_reader, +}; +#[cfg(unix)] +use crate::{IDUNN_PROCESS_WRITE_LEASE_SCHEMA, IdunnProcessWriteLeaseRecord}; + +#[cfg(unix)] +const LISTEN_PID_ENVIRONMENT: &str = "LISTEN_PID"; +#[cfg(unix)] +const LISTEN_FDS_ENVIRONMENT: &str = "LISTEN_FDS"; +#[cfg(unix)] +const LISTEN_FDNAMES_ENVIRONMENT: &str = "LISTEN_FDNAMES"; +/// `SD_LISTEN_FDS_START` in sd_listen_fds(3). +#[cfg(unix)] +const LISTEN_FDS_START: RawFd = 3; +const MAX_RECENT_WARMING_PROOFS: usize = 64; +const MAX_DETAIL_BYTES: usize = 512; +const DOCUMENT_ROLE: &str = "runtime-presence-health-publisher"; +const DOCUMENT_TAGS: [&str; 2] = ["cultnet.transport.rudp.v0", "runtime-presence"]; + +// ------------------------------------------------------------------ host port + +/// The process facts the provider reads: environment, pid and inherited +/// descriptors. Production reads the real process; tests supply a fake, so the +/// rules run without mutating the test process's own environment. +trait LaunchHost { + fn var(&self, name: &str) -> Option; + fn remove_var(&mut self, name: &str); + fn process_id(&self) -> u32; + /// Takes sole ownership of an inherited descriptor, or refuses if it is not open. + #[cfg(unix)] + fn adopt_descriptor(&mut self, descriptor: RawFd) -> Result; +} + +struct ProcessHost; + +impl LaunchHost for ProcessHost { + fn var(&self, name: &str) -> Option { + std::env::var(name).ok() + } + + fn remove_var(&mut self, name: &str) { + // SAFETY: the credential helpers document that they run during start-up + // before the service spawns threads, which is the contract + // `std::env::remove_var` needs. + unsafe { std::env::remove_var(name) } + } + + fn process_id(&self) -> u32 { + std::process::id() + } + + #[cfg(unix)] + fn adopt_descriptor(&mut self, descriptor: RawFd) -> Result { + // SAFETY: F_GETFD takes an integer descriptor and dereferences nothing. + if unsafe { libc::fcntl(descriptor, libc::F_GETFD) } == -1 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("inherited descriptor {descriptor} is not open")); + } + // SAFETY: the caller proved the descriptor is open, names it exactly once + // and has not adopted it before, so this is its only owner. + Ok(unsafe { File::from_raw_fd(descriptor) }) + } +} + +fn optional_environment(host: &impl LaunchHost, name: &str) -> Result> { + match host.var(name) { + None => Ok(None), + Some(value) if !value.is_empty() && value.trim() == value => Ok(Some(value)), + Some(_) => bail!("{name} is empty or has surrounding whitespace"), + } +} + +fn required_environment(host: &impl LaunchHost, name: &str) -> Result { + optional_environment(host, name)? + .ok_or_else(|| anyhow!("{name} is required for an Idunn-managed runtime")) +} + +// -------------------------------------------------------------------- custody + +/// What a path is, read without following links. std counts symlinks and, on +/// Windows, junctions as links; Profile custody accepts only the real thing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FileKind { + Regular, + Directory, + Symlink, + Other, +} + +impl FileKind { + fn of(metadata: &std::fs::Metadata) -> Self { + let file_type = metadata.file_type(); + if file_type.is_symlink() { + Self::Symlink + } else if file_type.is_dir() { + Self::Directory + } else if file_type.is_file() { + Self::Regular + } else { + Self::Other + } + } + + fn noun(self) -> &'static str { + match self { + Self::Directory => "a directory", + _ => "a regular file", + } + } +} + +/// What a custody-checked path must be. +#[derive(Clone, Copy, Debug)] +enum Rule { + /// A record or lock Idunn provisions read-only. + ReadOnlyFile, + /// The runtime bundle directory. + ReadOnlyDirectory, + /// The write-lease record and its lock. + ControlledFile, +} + +impl Rule { + fn kind(self) -> FileKind { + match self { + Self::ReadOnlyFile | Self::ControlledFile => FileKind::Regular, + Self::ReadOnlyDirectory => FileKind::Directory, + } + } + + #[cfg(unix)] + fn root_description(self) -> &'static str { + match self { + Self::ReadOnlyFile => "a root-owned read-only regular file", + Self::ReadOnlyDirectory => "a root-owned read-only directory", + Self::ControlledFile => "a root-controlled regular file", + } + } +} + +/// Profile custody trusts the user's profile and checks only that the path is the +/// real thing: no owner or ACL, because Idunn's host driver sets none. +fn profile_accepts(kind: FileKind, rule: Rule) -> bool { + kind == rule.kind() +} + +/// The unix facts a root-custody rule reads. Rules are pure predicates over +/// these, so the whole policy is testable without owning root. +#[cfg(unix)] +#[derive(Clone, Copy, Debug)] +struct Facts { + kind: FileKind, + uid: u32, + gid: u32, + mode: u32, + nlink: u64, + len: u64, +} + +#[cfg(unix)] +impl Facts { + fn of(metadata: &std::fs::Metadata) -> Self { + Self { + kind: FileKind::of(metadata), + uid: metadata.uid(), + gid: metadata.gid(), + mode: metadata.mode() & 0o7777, + nlink: metadata.nlink(), + len: metadata.len(), + } + } + + fn of_path(path: &Path, label: &str) -> Result { + let metadata = + std::fs::symlink_metadata(path).with_context(|| format!("inspecting {label}"))?; + Ok(Self::of(&metadata)) + } + + fn satisfies(&self, rule: Rule) -> bool { + match rule { + Rule::ReadOnlyFile => self.is_root_read_only_file(), + Rule::ReadOnlyDirectory => self.is_root_read_only_directory(), + Rule::ControlledFile => self.is_root_controlled_file(), + } + } + + /// Root-owned regular file nobody can write, not even root's group. + fn is_root_read_only_file(&self) -> bool { + self.kind == FileKind::Regular && self.uid == 0 && self.mode & 0o222 == 0 + } + + fn is_root_read_only_directory(&self) -> bool { + self.kind == FileKind::Directory && self.uid == 0 && self.mode & 0o222 == 0 + } + + /// Root-owned regular file that group and world cannot write. + fn is_root_controlled_file(&self) -> bool { + self.kind == FileKind::Regular && self.uid == 0 && self.mode & 0o022 == 0 + } + + fn is_root_controlled_directory(&self) -> bool { + self.kind == FileKind::Directory && self.uid == 0 && self.mode & 0o022 == 0 + } + + /// An inherited signer: root-owned, mode exactly 0400, one link, not empty. + fn is_signer_descriptor(&self) -> bool { + self.kind == FileKind::Regular + && self.uid == 0 + && self.gid == 0 + && self.mode == 0o400 + && self.nlink == 1 + && self.len > 0 + } +} + +/// How much of the filesystem the provider trusts, derived from how the +/// credentials were delivered and never chosen by a caller. Idunn's systemd +/// driver provisions root-owned material (`Root`); its host driver writes into the +/// user's profile (`Profile`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Custody { + #[cfg(unix)] + Root, + Profile, +} + +impl Custody { + fn require_file(self, path: &Path, label: &str, rule: Rule) -> Result<()> { + ensure!(path.is_absolute(), "{label} path is not absolute"); + let metadata = + std::fs::symlink_metadata(path).with_context(|| format!("inspecting {label}"))?; + match self { + Self::Profile => ensure!( + profile_accepts(FileKind::of(&metadata), rule), + "{label} is not {} (a link is refused)", + rule.kind().noun() + ), + #[cfg(unix)] + Self::Root => ensure!( + Facts::of(&metadata).satisfies(rule), + "{label} is not {}", + rule.root_description() + ), + } + Ok(()) + } + + /// The sibling `.lock` of a record. Only root custody inspects it. + #[cfg(unix)] + fn require_lock(self, path: &Path, label: &str, rule: Rule) -> Result<()> { + match self { + Self::Profile => Ok(()), + Self::Root => self.require_file(path, label, rule), + } + } + + /// Every directory from `path` up to the filesystem root is root-controlled. + /// Only root custody walks the chain. + #[cfg(unix)] + fn require_directory_chain(self, path: &Path, label: &str) -> Result<()> { + if self == Self::Profile { + return Ok(()); + } + ensure!(path.is_absolute(), "{label} path is not absolute"); + for directory in path.ancestors() { + let facts = Facts::of_path(directory, label)?; + ensure!( + facts.is_root_controlled_directory(), + "{label} is not entirely root-controlled" + ); + } + Ok(()) + } + + fn require_bundle(self, bundle: &Path) -> Result<()> { + self.require_file(bundle, "Idunn runtime bundle", Rule::ReadOnlyDirectory)?; + #[cfg(unix)] + { + let parent = bundle.parent().context("runtime bundle has no parent")?; + self.require_directory_chain(parent, "Idunn runtime bundle parent")?; + } + Ok(()) + } +} + +#[cfg(unix)] +fn sibling_lock_path(path: &Path) -> Result { + let mut name = path + .file_name() + .context("CultCache authority path has no file name")? + .to_os_string(); + name.push(".lock"); + Ok(path.with_file_name(name)) +} + +// ---------------------------------------------------------------- credentials + +/// The two signing credentials Idunn issues a launch: the one-launch activation +/// seed and the stable provider identity. Idunn's systemd driver delivers them +/// as OpenFile descriptors and its host driver as files named in the +/// environment; `from_systemd_descriptors` (unix only) and +/// [`from_host_environment`](Self::from_host_environment) read each form. +/// +/// The delivery decides how much of the filesystem the authority trusts: systemd +/// descriptors carry root custody, the host files carry profile custody. A +/// caller never chooses it. +/// +/// The raw constructor is not public, so a caller cannot choose custody: +/// +/// ```compile_fail,E0624 +/// let _ = cultnet_rs::IdunnRuntimeCredentials::new(std::io::empty(), std::io::empty()); +/// ``` +pub struct IdunnRuntimeCredentials { + activation: Box, + provider: Box, + custody: Custody, +} + +impl IdunnRuntimeCredentials { + /// Wraps the host driver's activation seed and provider identity credential + /// (profile custody). Crate-private: custody follows delivery, so a caller + /// never picks it and uses a `from_*` constructor. + pub(crate) fn new(activation: impl Read + 'static, provider: impl Read + 'static) -> Self { + Self::delivered(activation, provider, Custody::Profile) + } + + fn delivered( + activation: impl Read + 'static, + provider: impl Read + 'static, + custody: Custody, + ) -> Self { + Self { + activation: Box::new(activation), + provider: Box::new(provider), + custody, + } + } + + /// Takes the credentials from systemd's inherited descriptors, following + /// sd_listen_fds_with_names(3). `LISTEN_PID` must be this process (or, under + /// `PrivatePIDs`, this process must be pid 1 while `LISTEN_PID` names a host + /// pid above 1); the descriptors are found by name and extra descriptors are + /// tolerated; each must be a root-owned 0400 single-link read-only regular + /// file and is marked close-on-exec. `LISTEN_PID`, `LISTEN_FDS` and + /// `LISTEN_FDNAMES` are removed from the environment so no child inherits + /// them. + /// + /// Call this during start-up, before the service spawns threads: it edits the + /// process environment and takes ownership of descriptors 3 and above. + #[cfg(unix)] + pub fn from_systemd_descriptors() -> Result { + Self::from_systemd_descriptors_in(&mut ProcessHost) + } + + #[cfg(unix)] + fn from_systemd_descriptors_in(host: &mut impl LaunchHost) -> Result { + let (activation, provider) = take_systemd_descriptors(host, Custody::Root)?; + Ok(Self::delivered(activation, provider, Custody::Root)) + } + + /// Takes the credentials from the files Idunn's host driver names in + /// [`IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT`] and + /// [`RUNTIME_PRESENCE_IDENTITY_BINDING`]. The provider identity file is opened + /// first; a missing, unreadable or non-regular one refuses and leaves the + /// activation credential in place. The activation credential is one-shot: it is read + /// into memory, its handle closed and the file removed, so the seed lives only + /// in this process afterwards. A refusal names the variable and never the path. + pub fn from_host_environment() -> Result { + Self::from_host_environment_in(&ProcessHost) + } + + fn from_host_environment_in(host: &impl LaunchHost) -> Result { + let activation_path = PathBuf::from(required_environment( + host, + IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + )?); + let provider_path = PathBuf::from(required_environment( + host, + RUNTIME_PRESENCE_IDENTITY_BINDING, + )?); + ensure!( + activation_path.is_absolute() && provider_path.is_absolute(), + "host credential paths are not absolute" + ); + let provider = File::open(&provider_path).with_context(|| { + format!("{RUNTIME_PRESENCE_IDENTITY_BINDING}: opening provider identity credential") + })?; + ensure!( + provider + .metadata() + .with_context(|| { + format!("{RUNTIME_PRESENCE_IDENTITY_BINDING}: inspecting provider identity") + })? + .is_file(), + "{RUNTIME_PRESENCE_IDENTITY_BINDING}: provider identity credential is not a regular file" + ); + let mut seed = Zeroizing::new(Vec::new()); + { + let mut file = File::open(&activation_path).with_context(|| { + format!("{IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT}: opening activation credential") + })?; + file.read_to_end(&mut seed).with_context(|| { + format!("{IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT}: reading activation credential") + })?; + } + std::fs::remove_file(&activation_path).with_context(|| { + format!("{IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT}: retiring activation credential") + })?; + Ok(Self::new(SeedReader { seed, position: 0 }, provider)) + } +} + +/// Reads an in-memory seed and wipes it when dropped. +struct SeedReader { + seed: Zeroizing>, + position: usize, +} + +impl Read for SeedReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let remaining = &self.seed[self.position..]; + let count = remaining.len().min(buffer.len()); + buffer[..count].copy_from_slice(&remaining[..count]); + self.position += count; + Ok(count) + } +} + +/// systemd's `LISTEN_PID` is a host pid. `PrivatePIDs=yes` makes the main process +/// pid 1 in a private namespace, where it cannot see that host pid; a descendant +/// has a pid above 1 and must not reuse the inherited set. +#[cfg(unix)] +fn listen_pid_matches(listen_pid: &str, process_id: u32) -> bool { + match listen_pid.parse::() { + Ok(declared) if declared.to_string() == listen_pid => { + declared == process_id || (process_id == 1 && declared > 1) + } + _ => false, + } +} + +#[cfg(unix)] +fn take_systemd_descriptors(host: &mut impl LaunchHost, custody: Custody) -> Result<(File, File)> { + let listen_pid = required_environment(&*host, LISTEN_PID_ENVIRONMENT); + let listen_fds = required_environment(&*host, LISTEN_FDS_ENVIRONMENT); + let listen_fd_names = required_environment(&*host, LISTEN_FDNAMES_ENVIRONMENT); + // Whatever the verdict, no child of this process inherits the claim. + for name in [ + LISTEN_PID_ENVIRONMENT, + LISTEN_FDS_ENVIRONMENT, + LISTEN_FDNAMES_ENVIRONMENT, + ] { + host.remove_var(name); + } + let (listen_pid, listen_fds, listen_fd_names) = (listen_pid?, listen_fds?, listen_fd_names?); + ensure!( + listen_pid_matches(&listen_pid, host.process_id()), + "systemd signer descriptors belong to a different process" + ); + let count = listen_fds + .parse::() + .ok() + .filter(|count| count.to_string() == listen_fds) + .context("parsing systemd LISTEN_FDS")?; + let names = listen_fd_names.split(':').collect::>(); + ensure!( + names.len() == count, + "systemd LISTEN_FDNAMES does not name every inherited descriptor" + ); + let activation = find_descriptor(&names, IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME)?; + let provider = find_descriptor(&names, RUNTIME_PRESENCE_IDENTITY_FD_NAME)?; + let activation = host.adopt_descriptor(activation)?; + let activation = protect_descriptor(activation, "runtime activation signer", custody)?; + let provider = host.adopt_descriptor(provider)?; + let provider = protect_descriptor(provider, "stable provider signer", custody)?; + Ok((activation, provider)) +} + +/// The descriptor number of the one descriptor called `name`. +#[cfg(unix)] +fn find_descriptor(names: &[&str], name: &str) -> Result { + let mut matches = names + .iter() + .enumerate() + .filter(|(_, candidate)| **candidate == name) + .map(|(index, _)| LISTEN_FDS_START + index as RawFd); + let descriptor = matches + .next() + .ok_or_else(|| anyhow!("systemd did not pass the {name} descriptor"))?; + ensure!( + matches.next().is_none(), + "systemd passed the {name} descriptor more than once" + ); + Ok(descriptor) +} + +#[cfg(unix)] +fn protect_descriptor(file: File, label: &str, custody: Custody) -> Result { + // OpenFile descriptors arrive without close-on-exec; a child must never + // inherit a signer, even on a path that refuses it below. + // SAFETY: fcntl reads and sets flags of the descriptor `file` owns. + let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFD) }; + if flags == -1 + // SAFETY: as above. + || unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, flags | libc::FD_CLOEXEC) } == -1 + { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("protecting {label} descriptor from child inheritance")); + } + // SAFETY: F_GETFL reads the flags of the descriptor `file` owns. + let status = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) }; + if status == -1 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("reading {label} access mode")); + } + ensure!( + status & libc::O_ACCMODE == libc::O_RDONLY, + "{label} descriptor is not read-only" + ); + if custody == Custody::Root { + let facts = Facts::of( + &file + .metadata() + .with_context(|| format!("inspecting {label}"))?, + ); + ensure!( + facts.is_signer_descriptor(), + "{label} descriptor is not a root-owned 0400 singly-linked regular file" + ); + } + Ok(file) +} + +// ------------------------------------------------------------------ authority + +/// What the authority and every lease guard share: the verified records and the +/// custody policy their files are read under. +struct Records { + expected: IdunnExpectedIncarnationRecord, + expected_sha256: String, + activation: IdunnRuntimeActivationRecord, + activation_sha256: String, + /// Set exactly when Expected requires a process write lease (unix only: no + /// other platform can hold the lease lock, so `open` refuses the requirement). + #[cfg(unix)] + lease_path: Option, + #[cfg(unix)] + custody: Custody, +} + +/// One launch's verified Idunn authority: the Expected incarnation, the +/// activation witness bound to it, and the two signing keys Idunn issued. +/// +/// Opening checks every binding the provider owes Idunn. The Expected target and +/// health contract equal the caller's; the activation binds Expected's canonical +/// digest and runtime id; the activation credential is the activation's signer; +/// the provider identity is the signer Expected selected; the write-lease +/// environment is present exactly when Expected requires a lease; and the +/// candidate-bind environment names Expected's route. The launch environment is +/// read here and nowhere else. +pub struct IdunnRuntimeAuthority { + records: Arc, + activation_signer: IdunnRuntimeActivationSigner, + provider_signer: ServiceIdentitySigner, + candidate_bind: Option, +} + +impl std::fmt::Debug for IdunnRuntimeAuthority { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("IdunnRuntimeAuthority") + .field("target", &self.records.expected.target) + .field("runtime_id", &self.records.expected.runtime_id) + .finish_non_exhaustive() + } +} + +impl IdunnRuntimeAuthority { + /// Opens the runtime bundle Idunn launched this process with, for the target + /// `target` and the health contract `health_contract`, with the credentials + /// Idunn issued. The bundle directory comes from + /// [`IDUNN_RUNTIME_BUNDLE_ENVIRONMENT`], the candidate bind from + /// [`IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT`] and the write lease from + /// [`IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT`]; no caller supplies a path. + /// + /// The credentials' delivery decides the custody the bundle is held to. Under + /// systemd descriptors (root custody, unix) the bundle directory, its + /// `expected.cc` and `activation.cc`, their `.lock` siblings and every parent + /// directory must be root-owned and not writable by others, and none may be a + /// symlink; the write lease, if Expected requires one, is held to the same + /// custody. Under any other delivery (profile custody, every platform) the + /// bundle directory and the two records must be a real directory and real + /// regular files, never a link. A runtime that requires a + /// write lease can only be opened on unix, where the lease lock exists. + pub fn open( + target: &str, + health_contract: &str, + credentials: IdunnRuntimeCredentials, + ) -> Result { + Self::open_in(&ProcessHost, target, health_contract, credentials) + } + + fn open_in( + host: &impl LaunchHost, + target: &str, + health_contract: &str, + credentials: IdunnRuntimeCredentials, + ) -> Result { + let custody = credentials.custody; + let bundle = PathBuf::from(required_environment( + host, + IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + )?); + ensure!( + bundle.is_absolute(), + "{IDUNN_RUNTIME_BUNDLE_ENVIRONMENT} is not an absolute path" + ); + custody.require_bundle(&bundle)?; + let (key, payload) = read_authority_record( + &bundle.join("expected.cc"), + IdunnExpectedIncarnationRecord::TYPE, + IDUNN_EXPECTED_INCARNATION_SCHEMA, + custody, + )?; + let expected = IdunnExpectedIncarnationRecord::decode_canonical(&payload)?; + ensure!( + key == expected.target, + "Expected record key is not its target" + ); + ensure!( + expected.target == target && expected.health_contract == health_contract, + "Idunn Expected target or health contract does not match this runtime" + ); + let (key, payload) = read_authority_record( + &bundle.join("activation.cc"), + IdunnRuntimeActivationRecord::TYPE, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, + custody, + )?; + let activation = IdunnRuntimeActivationRecord::decode_canonical(&payload)?; + ensure!( + key == expected.target, + "activation record key is not the Expected target" + ); + let expected_sha256 = expected.canonical_sha256()?; + ensure!( + activation.expected_projection_sha256 == expected_sha256 + && activation.runtime_id == expected.runtime_id, + "runtime activation does not bind the bundled Expected projection" + ); + + // The lease lock exists only on unix; a requirement no process here can + // hold is refused rather than signed around. + #[cfg(not(unix))] + ensure!( + !expected.write_lease_required, + "write_lease_required: Expected requires a process write lease, which this platform cannot hold" + ); + let lease_path = optional_environment(host, IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT)?; + ensure!( + lease_path.is_some() == expected.write_lease_required, + "Idunn process write-lease environment does not match Expected" + ); + #[cfg(unix)] + let lease_path = lease_path.map(PathBuf::from); + #[cfg(unix)] + if let Some(path) = &lease_path { + ensure!( + path.is_absolute(), + "Idunn process write-lease path is not absolute" + ); + } + let candidate_bind = optional_environment(host, IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT)?; + match (&expected.route, &candidate_bind) { + (Some(route), Some(bind)) => ensure!( + route.candidate_endpoint == format!("{}://{bind}", route.transport), + "Idunn candidate bind differs from the Expected route" + ), + (None, None) => {} + _ => bail!("Idunn candidate bind environment does not match Expected's route"), + } + + let IdunnRuntimeCredentials { + activation: activation_credential, + provider: provider_credential, + .. + } = credentials; + let activation_signer = + IdunnRuntimeActivationSigner::from_credential_reader(activation_credential)?; + ensure!( + activation_signer.identity_id() == activation.activation_signer_identity_id + && activation_signer.public_key() == activation.activation_signer_public_key, + "runtime activation credential does not belong to the Idunn activation record" + ); + // The shared reader owns profile validation, platform binding, seed + // unprotection and domain-separated signing; this module never parses or + // exports seed bytes. + let provider_signer = open_service_identity_credential_reader::< + GameCultProviderHealthIdentity, + >(provider_credential)?; + ensure!( + provider_signer.entry().identity_id == expected.expected_signer_identity_id, + "provider signer is not the identity selected by Expected" + ); + + Ok(Self { + records: Arc::new(Records { + activation_sha256: activation.canonical_sha256()?, + expected_sha256, + expected, + activation, + #[cfg(unix)] + lease_path, + #[cfg(unix)] + custody, + }), + activation_signer, + provider_signer, + candidate_bind, + }) + } + + /// Idunn's admitted description of this incarnation. + pub fn expected(&self) -> &IdunnExpectedIncarnationRecord { + &self.records.expected + } + + /// The activation witness Idunn issued for this launch. + pub fn activation(&self) -> &IdunnRuntimeActivationRecord { + &self.records.activation + } + + /// The endpoint every presence states as bound: Expected's candidate route, + /// confirmed at open against Idunn's candidate-bind environment. `None` when + /// the incarnation has no route. + pub fn bound_endpoint(&self) -> Option<&str> { + self.records + .expected + .route + .as_ref() + .map(|route| route.candidate_endpoint.as_str()) + } + + /// Checks that `bind` (a `host:port`) is the address Idunn will challenge: + /// the candidate bind Idunn put in [`IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT`], + /// which open already tied to Expected's route. Call it with the address the + /// service actually binds, before it listens. + pub fn require_candidate_bind(&self, bind: &str) -> Result<()> { + let route = self + .records + .expected + .route + .as_ref() + .context("Expected has no admitted route")?; + ensure!( + self.candidate_bind.as_deref() == Some(bind) + && route.candidate_endpoint == format!("{}://{bind}", route.transport), + "candidate bind differs from Idunn's candidate bind and the Expected route" + ); + Ok(()) + } +} + +fn read_authority_record( + path: &Path, + record_type: &str, + schema: &str, + custody: Custody, +) -> Result<(String, Vec)> { + custody.require_file(path, "runtime authority record", Rule::ReadOnlyFile)?; + #[cfg(unix)] + custody.require_lock( + &sibling_lock_path(path)?, + "runtime authority lock", + Rule::ReadOnlyFile, + )?; + read_snapshot(path, "runtime authority record", |entries| { + let [envelope] = entries.as_slice() else { + bail!("runtime authority store must contain exactly one record"); + }; + ensure!( + envelope.r#type == record_type && envelope.schema_id.as_deref() == Some(schema), + "runtime authority store has the wrong typed envelope" + ); + Ok((envelope.key.clone(), envelope.payload.clone())) + }) +} + +/// Reads `path` as a CultCache store under its shared lock and hands the +/// envelopes to `read`. CultCache's own errors carry the store's path, which +/// under the host driver lies in the user's profile, so a failure of the store +/// itself is reported by `label` and a path-free kind alone +/// ([`store_failure_kind`]); `read`'s refusals pass through. +fn read_snapshot( + path: &Path, + label: &str, + read: impl FnOnce(Vec) -> Result, +) -> Result { + let mut outcome = None; + let store = + SingleFileMessagePackBackingStore::new(path).with_read_only_shared_snapshot(|entries| { + outcome = Some(read(entries)); + Ok(()) + }); + match (store, outcome) { + (Ok(()), Some(outcome)) => outcome, + (Err(error), _) => bail!( + "{label} could not be read as a CultCache store: {}", + store_failure_kind(&error) + ), + (Ok(()), None) => bail!("{label} could not be read as a CultCache store"), + } +} + +/// What kept a store from being read, as a label that names no path: CultCache's +/// verdict on the bytes, else the kind of the I/O failure in the chain. +fn store_failure_kind(error: &anyhow::Error) -> &'static str { + use cultcache_rs::{CultCacheStoreUnreadable, CultCacheStoreUnreadableKind}; + if let Some(unreadable) = error.downcast_ref::() { + return match unreadable.kind { + CultCacheStoreUnreadableKind::Undecodable => "not decodable", + CultCacheStoreUnreadableKind::UnsupportedFormat => { + "in a format this runtime does not read" + } + _ => "unreadable", + }; + } + match error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .map(std::io::Error::kind) + { + Some(std::io::ErrorKind::NotFound) => "not found", + Some(std::io::ErrorKind::PermissionDenied) => "permission denied", + Some(_) => "I/O error", + None => "unreadable", + } +} + +// ---------------------------------------------------------------- write lease + +/// Proof that this process holds Idunn's process write lease. It holds the +/// lease's sibling `.lock` shared for as long as any clone lives, which fences +/// Idunn's lease replacement and every other writer that takes the lock +/// exclusively. Gate state writes on [`require_current`](Self::require_current). +/// +/// The lease lock exists only on unix. The type exists everywhere so consumer code +/// needs no `cfg`, but on other platforms no guard can be constructed: `open` +/// refuses a runtime that requires a lease. +#[derive(Clone)] +pub struct ProcessWriteLeaseGuard { + held: Arc, +} + +#[cfg(unix)] +struct HeldLease { + _lock: File, + records: Arc, + warming_proofs: Vec, + lease_sha256: String, +} + +/// No platform without the lease lock can hold a lease. +#[cfg(not(unix))] +enum HeldLease {} + +impl HeldLease { + #[cfg(unix)] + fn lease_sha256(&self) -> &str { + &self.lease_sha256 + } + + #[cfg(not(unix))] + fn lease_sha256(&self) -> &str { + match *self {} + } + + #[cfg(unix)] + fn require_current(&self) -> Result<()> { + let observed = load_lease(&self.records, &self.warming_proofs)?; + ensure!( + observed.as_deref() == Some(self.lease_sha256()), + "process-write lease is no longer the admitted lifetime grant" + ); + Ok(()) + } + + #[cfg(not(unix))] + fn require_current(&self) -> Result<()> { + match *self {} + } +} + +impl std::fmt::Debug for ProcessWriteLeaseGuard { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProcessWriteLeaseGuard") + .field("lease_sha256", &self.lease_sha256()) + .finish_non_exhaustive() + } +} + +impl ProcessWriteLeaseGuard { + /// The canonical digest of the lease record this guard was admitted under. + pub fn lease_sha256(&self) -> &str { + self.held.lease_sha256() + } + + /// Fails unless the lease record on disk is still the admitted lifetime grant. + pub fn require_current(&self) -> Result<()> { + self.held.require_current() + } +} + +/// The digest of the lease on disk if it exists and binds this incarnation and +/// one of `warming_proofs`; `None` while the record is absent or empty. +#[cfg(unix)] +fn load_lease(records: &Records, warming_proofs: &[String]) -> Result> { + let Some(path) = present_lease_path(records)? else { + return Ok(None); + }; + read_snapshot(path, "process-write-lease record", |entries| { + let [envelope] = entries.as_slice() else { + if entries.is_empty() { + return Ok(None); + } + bail!("process-write-lease store is ambiguous"); + }; + ensure!( + envelope.r#type == IdunnProcessWriteLeaseRecord::TYPE + && envelope.schema_id.as_deref() == Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA), + "process-write-lease store has the wrong typed envelope" + ); + let lease = IdunnProcessWriteLeaseRecord::decode_canonical(&envelope.payload)?; + ensure!( + envelope.key == lease.target, + "process-write-lease key is not its target" + ); + exact_lease_sha256(&lease, records, warming_proofs).map(Some) + }) +} + +/// The lease record's path once the record exists and its files pass custody; +/// `None` while Idunn has not written it. +#[cfg(unix)] +fn present_lease_path(records: &Records) -> Result> { + let path = records + .lease_path + .as_deref() + .context("Expected does not require a process write lease")?; + match std::fs::symlink_metadata(path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + } + require_lease_custody(records.custody, path)?; + Ok(Some(path)) +} + +#[cfg(unix)] +fn require_lease_custody(custody: Custody, path: &Path) -> Result<()> { + custody.require_file(path, "process-write-lease record", Rule::ControlledFile)?; + custody.require_lock( + &sibling_lock_path(path)?, + "process-write-lease lock", + Rule::ControlledFile, + )?; + custody.require_directory_chain( + path.parent().context("process-write lease has no parent")?, + "process-write-lease parent", + ) +} + +#[cfg(unix)] +fn exact_lease_sha256( + lease: &IdunnProcessWriteLeaseRecord, + records: &Records, + warming_proofs: &[String], +) -> Result { + lease.validate()?; + let expected = &records.expected; + ensure!( + lease.target == expected.target + && lease.expected_projection_sha256 == records.expected_sha256 + && lease.plan_id == expected.plan_id + && lease.incarnation_id == expected.incarnation_id + && lease.sealed_release_id == expected.sealed_release_id + && lease.activation_witness_sha256 == records.activation_sha256 + && Some(&lease.state_schema_generation) == expected.state_schema_generation.as_ref() + && Some(&lease.state_contract_sha256) == expected.state_contract_sha256.as_ref() + && lease.runtime_id == expected.runtime_id + && lease.runtime_instance_id == records.activation.runtime_instance_id + && warming_proofs + .iter() + .any(|proof| proof == &lease.warming_presence_sha256), + "process-write lease does not bind a provider-owned Warming proof for this incarnation" + ); + lease.canonical_sha256() +} + +/// Takes the lease's shared lock and admits the record under it. `None` while +/// the record is absent. +#[cfg(unix)] +fn acquire_lease( + records: &Arc, + warming_proofs: &[String], +) -> Result> { + let Some(path) = present_lease_path(records)? else { + return Ok(None); + }; + let lock_path = sibling_lock_path(path)?; + let lock = OpenOptions::new() + .read(true) + .open(&lock_path) + .context("opening process-write-lease lock")?; + fs2::FileExt::lock_shared(&lock).context("locking process-write-lease lock")?; + // The first shared lock stays held while the snapshot is decoded; the + // backing-store read takes its own nested shared lock so the canonical + // CultCache decoder is the one reading the record. + let Some(lease_sha256) = load_lease(records, warming_proofs)? else { + return Ok(None); + }; + Ok(Some(ProcessWriteLeaseGuard { + held: Arc::new(HeldLease { + _lock: lock, + records: Arc::clone(records), + warming_proofs: warming_proofs.to_vec(), + lease_sha256, + }), + })) +} + +/// `open` refuses a lease requirement on this platform, so nothing reaches here. +#[cfg(not(unix))] +fn acquire_lease( + _records: &Arc, + _warming_proofs: &[String], +) -> Result> { + bail!("write_lease_required: this platform cannot hold a process write lease") +} + +// --------------------------------------------------------------------- signer + +/// The health an app reports about itself. Every presence the signer signs +/// states the last one reported. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimePresenceState { + /// Starting, or waiting for Idunn's write lease. The signer begins here. + Warming, + /// Serving. + Active, + /// Serving with reduced ability. + Degraded, + /// Not able to serve. + Failed, +} + +impl RuntimePresenceState { + /// The state string the presence record carries. + pub fn as_str(self) -> &'static str { + match self { + Self::Warming => "warming", + Self::Active => "active", + Self::Degraded => "degraded", + Self::Failed => "failed", + } + } +} + +/// One signed presence: the validated record, the raw CultNet document that +/// carries it, and the record's canonical digest (what a write lease names when +/// the presence is Warming). +#[derive(Clone, Debug)] +pub struct SignedRuntimePresence { + pub record: GameCultRuntimePresenceHealthRecord, + pub document: CultNetRawDocumentRecord, + pub canonical_sha256: String, +} + +/// Signs this launch's runtime presence and answers Idunn's route challenge from +/// local authority, with no network. +/// +/// The signer is the one owner of reported health: it starts Warming, the app +/// moves it with [`report_health`](Self::report_health), and every presence it +/// signs, however it is carried, states that health. It takes the authority by +/// value, so one signer per authority is a fact of the types: a second signer +/// would restart the publisher sequence under the same runtime instance. +pub struct IdunnRuntimePresenceSigner { + authority: IdunnRuntimeAuthority, + capabilities: Vec, + health: RuntimePresenceState, + sequence: u64, + warming_proofs: Vec, + lease: Option, +} + +impl IdunnRuntimePresenceSigner { + /// A Warming signer over `authority`, stating `capabilities` in every + /// presence. Capabilities are validated when a presence is signed. + pub fn new( + authority: IdunnRuntimeAuthority, + capabilities: Vec, + ) -> Self { + Self { + authority, + capabilities, + health: RuntimePresenceState::Warming, + sequence: 0, + warming_proofs: Vec::new(), + lease: None, + } + } + + /// The authority this signer owns. + pub fn authority(&self) -> &IdunnRuntimeAuthority { + &self.authority + } + + /// Records the app's health. Until the first call the signer reports Warming. + pub fn report_health(&mut self, state: RuntimePresenceState) { + self.health = state; + } + + /// The health the next presence will state. + pub fn health(&self) -> RuntimePresenceState { + self.health + } + + /// Signs one presence now, in the reported health. Every call takes the next + /// publisher sequence. A Warming presence never carries a lease and becomes a + /// valid write-lease target; Active on a lease-required Expected is refused + /// unless the signer holds a current [`ProcessWriteLeaseGuard`]; a held guard + /// that is no longer current refuses Active and Degraded, while Failed still + /// signs, without a lease digest, so a runtime that lost its lease can say so. + pub fn sign(&mut self, detail: &str) -> Result { + self.sign_at(detail, unix_millis()?) + } + + /// [`sign`](Self::sign) with an explicit observation time. + pub fn sign_at( + &mut self, + detail: &str, + observed_at_unix_millis: u64, + ) -> Result { + let records = Arc::clone(&self.authority.records); + let state = self.health; + let write_lease_sha256 = match (state, &self.lease) { + (RuntimePresenceState::Warming, _) | (RuntimePresenceState::Failed, None) => None, + // A runtime that lost its lease must still be able to say it + // failed; the correlation admits a not-ready presence without a + // lease digest. + (RuntimePresenceState::Failed, Some(guard)) => guard + .require_current() + .ok() + .map(|()| guard.lease_sha256().to_owned()), + (_, Some(guard)) => { + guard.require_current()?; + Some(guard.lease_sha256().to_owned()) + } + (_, None) if records.expected.write_lease_required => { + bail!( + "{} stateful presence lacks its process-write lease", + state.as_str() + ) + } + (_, None) => None, + }; + self.sequence = self + .sequence + .checked_add(1) + .ok_or_else(|| anyhow!("runtime presence publisher sequence overflow"))?; + let expected = &records.expected; + let mut record = GameCultRuntimePresenceHealthRecord { + schema_version: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), + target: expected.target.clone(), + expected_projection_sha256: records.expected_sha256.clone(), + plan_id: expected.plan_id.clone(), + incarnation_id: expected.incarnation_id.clone(), + sealed_release_id: expected.sealed_release_id.clone(), + activation_witness_sha256: records.activation_sha256.clone(), + state_schema_generation: expected.state_schema_generation.clone(), + state_contract_sha256: expected.state_contract_sha256.clone(), + runtime_id: expected.runtime_id.clone(), + runtime_instance_id: records.activation.runtime_instance_id.clone(), + bound_endpoint: self.authority.bound_endpoint().map(str::to_owned), + capabilities: self.capabilities.clone(), + health_contract: expected.health_contract.clone(), + state: state.as_str().into(), + detail: truncate_utf8(detail, MAX_DETAIL_BYTES).into(), + write_lease_sha256, + signer_identity_id: self.authority.provider_signer.entry().identity_id.clone(), + publisher_sequence: self.sequence, + observed_at_unix_millis, + signature_algorithm: "ed25519".into(), + signature: Vec::new(), + activation_signer_identity_id: records.activation.activation_signer_identity_id.clone(), + activation_signature: Vec::new(), + }; + let proof_payload = record.canonical_proof_payload()?; + let provider_proof = self + .authority + .provider_signer + .sign::(&proof_payload); + ensure!( + provider_proof.identity_id == record.signer_identity_id, + "provider signer identity changed after runtime admission" + ); + record.signature = provider_proof.signature; + record.activation_signature = self + .authority + .activation_signer + .sign_presence_proof(&record)?; + record.validate()?; + let payload = rmp_serde::to_vec(&record)?; + ensure!( + rmp_serde::from_slice::(&payload)? == record, + "runtime presence did not round-trip canonically" + ); + let canonical_sha256 = record.canonical_sha256()?; + let document = CultNetRawDocumentRecord { + schema_id: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), + record_key: expected.target.clone(), + stored_at: chrono::DateTime::from_timestamp_millis(i64::try_from( + observed_at_unix_millis, + )?) + .context("runtime presence observation time is invalid")? + .to_rfc3339(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload, + source_runtime_id: Some(expected.runtime_id.clone()), + source_agent_id: Some(record.signer_identity_id.clone()), + source_role: Some(DOCUMENT_ROLE.into()), + tags: Some(DOCUMENT_TAGS.iter().map(|tag| (*tag).into()).collect()), + }; + if state == RuntimePresenceState::Warming { + self.warming_proofs.push(canonical_sha256.clone()); + if self.warming_proofs.len() > MAX_RECENT_WARMING_PROOFS { + self.warming_proofs.remove(0); + } + } + Ok(SignedRuntimePresence { + record, + document, + canonical_sha256, + }) + } + + /// Looks for Idunn's process write lease. `Ok(None)` while Idunn has not + /// written it; an error when a record exists but does not bind this + /// incarnation and a Warming presence this signer signed within its last 64. + /// The guard it returns is kept: later non-Warming presences carry its lease + /// digest. The caller waits, sleeping between calls and signing Warming + /// heartbeats, as its own loop decides. + pub fn try_acquire_write_lease(&mut self) -> Result> { + ensure!( + self.authority.records.expected.write_lease_required, + "Expected does not require a process write lease" + ); + if let Some(guard) = &self.lease { + guard.require_current()?; + return Ok(Some(guard.clone())); + } + let guard = acquire_lease(&self.authority.records, &self.warming_proofs)?; + self.lease = guard.clone(); + Ok(guard) + } + + /// Answers Idunn's route challenge: a `SnapshotResponseRaw` carrying a fresh + /// presence in the reported health. Only a plain snapshot request for this + /// runtime's presence schema and target is answered; another schema, another + /// record key, a shard-addressed request, a request of any other kind or an + /// empty or untrimmed message id is refused. + pub fn answer_route_observation(&mut self, request: &CultNetMessage) -> Result { + let CultNetMessage::SnapshotRequest { + message_id, + schema_ids, + record_keys, + } = request + else { + bail!("Idunn route observation is not a plain snapshot request"); + }; + ensure!( + !message_id.is_empty() && message_id.trim() == message_id, + "Idunn route observation requires a message id" + ); + let target = &self.authority.records.expected.target; + ensure!( + schema_ids.as_ref().is_none_or(|schemas| schemas + .iter() + .any(|schema| schema == GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA)) + && record_keys + .as_ref() + .is_none_or(|keys| keys.iter().any(|key| key == target)), + "Idunn route observation does not request this runtime presence document" + ); + let signed = self.sign(&format!("route-observation:{message_id}"))?; + Ok(CultNetMessage::SnapshotResponseRaw { + message_id: message_id.clone(), + documents: vec![signed.document], + }) + } +} + +fn truncate_utf8(text: &str, max_bytes: usize) -> &str { + if text.len() <= max_bytes { + return text; + } + let mut end = max_bytes; + while !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] +} + +fn unix_millis() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before the Unix epoch")? + .as_millis() + .try_into() + .context("Unix time exceeds u64") +} + +#[cfg(test)] +mod tests; diff --git a/packages/cultnet-rs/src/idunn_runtime_provider/tests.rs b/packages/cultnet-rs/src/idunn_runtime_provider/tests.rs new file mode 100644 index 00000000..9f2d53ec --- /dev/null +++ b/packages/cultnet-rs/src/idunn_runtime_provider/tests.rs @@ -0,0 +1,2120 @@ +use std::collections::BTreeMap; +use std::io::Cursor; + +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope}; + +use super::*; +use crate::{ + IdunnExpectedCapability, IdunnExpectedRoute, IdunnRuntimeActivationLaunch, + IdunnServiceIdentity, RUNTIME_PRESENCE_IDENTITY_FD_NAME, RuntimePresenceAuthenticationContext, + VerifiedRuntimeAuthority, authenticate_runtime_presence_claim, + correlate_runtime_presence_claim, enroll_service_identity_at, verify_runtime_authority, +}; + +const TARGET: &str = "ghostlight"; +const HEALTH_CONTRACT: &str = "ghostlight.runtime-health.v1"; +const BIND: &str = "127.0.0.1:14103"; +const ENDPOINT: &str = "tcp://127.0.0.1:14103"; +#[cfg(unix)] +const ACTIVATION_NAME: &str = IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME; +#[cfg(unix)] +const PROVIDER_NAME: &str = RUNTIME_PRESENCE_IDENTITY_FD_NAME; + +fn digest(byte: char) -> String { + format!("sha256-{}", byte.to_string().repeat(64)) +} + +fn refusal(result: Result) -> String { + match result { + Ok(_) => panic!("expected a refusal"), + Err(error) => format!("{error:#}"), + } +} + +fn capabilities() -> Vec { + ["conversation", "state"] + .into_iter() + .map(|name| GameCultRuntimeCapability { + capability: name.into(), + schema: format!("{name}.v1"), + compatibility: "v1".into(), + capacity: 1, + }) + .collect() +} + +fn write_record(path: &Path, key: &str, ty: &str, schema: &str, payload: Vec) -> Result<()> { + SingleFileMessagePackBackingStore::new(path).push(&CultCacheEnvelope { + key: key.into(), + r#type: ty.into(), + payload, + stored_at: "2026-10-29T00:00:00Z".into(), + schema_id: Some(schema.into()), + }) +} + +// ------------------------------------------------------------------ fake host + +struct FakeHost { + vars: BTreeMap, + pid: u32, + #[cfg(unix)] + descriptors: BTreeMap, +} + +impl FakeHost { + fn new() -> Self { + Self { + vars: BTreeMap::new(), + pid: 4242, + #[cfg(unix)] + descriptors: BTreeMap::new(), + } + } + + fn with(mut self, name: &str, value: &str) -> Self { + self.vars.insert(name.into(), value.into()); + self + } +} + +impl LaunchHost for FakeHost { + fn var(&self, name: &str) -> Option { + self.vars.get(name).cloned() + } + + fn remove_var(&mut self, name: &str) { + self.vars.remove(name); + } + + fn process_id(&self) -> u32 { + self.pid + } + + #[cfg(unix)] + fn adopt_descriptor(&mut self, descriptor: RawFd) -> Result { + self.descriptors + .remove(&descriptor) + .with_context(|| format!("descriptor {descriptor} is not open")) + } +} + +// ------------------------------------------------------------------------ rig + +/// One launch's worth of Idunn material written under a temp directory. +struct Rig { + dir: tempfile::TempDir, + expected: IdunnExpectedIncarnationRecord, + activation: IdunnRuntimeActivationRecord, + activation_seed: Vec, + provider_credential: Vec, + idunn: ServiceIdentitySigner, + provider_public_key: Vec, +} + +impl Rig { + fn new(write_lease_required: bool) -> Result { + let dir = tempfile::tempdir()?; + let provider_path = dir.path().join("provider.cc"); + let provider = + enroll_service_identity_at::(&provider_path)?; + let idunn = + enroll_service_identity_at::(&dir.path().join("idunn.cc"))?; + let provider_anchor = provider.trust_anchor()?; + let expected = IdunnExpectedIncarnationRecord { + schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(), + target: TARGET.into(), + plan_id: digest('2'), + incarnation_id: "ghostlight/2026-09-02/1".into(), + sealed_release_id: digest('3'), + source_repository: "github.com/GameCult/Ghostlight".into(), + source_revision: "4".repeat(40), + recipe_sha256: digest('5'), + runtime_id: "ghostlight-yggdrasil-1".into(), + expected_signer_identity_id: provider_anchor.identity_id.clone(), + health_contract: HEALTH_CONTRACT.into(), + artifact_sha256: digest('6'), + state_schema_generation: Some("world-v2".into()), + state_contract_sha256: Some(digest('7')), + write_lease_required, + route: Some(IdunnExpectedRoute { + route_id: "ghostlight-public".into(), + transport: "tcp".into(), + stable_endpoint: "tcp://127.0.0.1:14100".into(), + candidate_endpoint: ENDPOINT.into(), + }), + capabilities: ["conversation", "state"] + .into_iter() + .map(|name| IdunnExpectedCapability { + capability: name.into(), + schema: format!("{name}.v1"), + compatibility: "v1".into(), + minimum_capacity: 1, + }) + .collect(), + dependencies: Vec::new(), + }; + let (activation, activation_seed) = Self::issue(&expected, &idunn)?; + let rig = Self { + provider_credential: std::fs::read(&provider_path)?, + provider_public_key: provider_anchor.public_key, + activation, + activation_seed, + expected, + idunn, + dir, + }; + std::fs::create_dir(rig.bundle())?; + rig.write_bundle(&rig.expected, &rig.activation)?; + Ok(rig) + } + + fn issue( + expected: &IdunnExpectedIncarnationRecord, + idunn: &ServiceIdentitySigner, + ) -> Result<(IdunnRuntimeActivationRecord, Vec)> { + let launch = IdunnRuntimeActivationLaunch::issue(expected, digest('8'), 99, idunn)?; + let mut seed = Vec::new(); + let activation = launch.write_credential(&mut seed)?; + Ok((activation, seed)) + } + + fn write_bundle( + &self, + expected: &IdunnExpectedIncarnationRecord, + activation: &IdunnRuntimeActivationRecord, + ) -> Result<()> { + write_record( + &self.bundle().join("expected.cc"), + &expected.target, + IdunnExpectedIncarnationRecord::TYPE, + IDUNN_EXPECTED_INCARNATION_SCHEMA, + expected.canonical_bytes()?, + )?; + write_record( + &self.bundle().join("activation.cc"), + &expected.target, + IdunnRuntimeActivationRecord::TYPE, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, + activation.canonical_bytes()?, + ) + } + + fn bundle(&self) -> PathBuf { + self.dir.path().join("bundle") + } + + fn lease_path(&self) -> PathBuf { + self.dir.path().join("lease.cc") + } + + #[cfg(unix)] + fn lock_path(&self) -> PathBuf { + sibling_lock_path(&self.lease_path()).unwrap() + } + + fn credentials(&self) -> IdunnRuntimeCredentials { + IdunnRuntimeCredentials::new( + Cursor::new(self.activation_seed.clone()), + Cursor::new(self.provider_credential.clone()), + ) + } + + /// A launch environment that names only the bundle. + fn bare_host(&self) -> FakeHost { + FakeHost::new().with( + IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + self.bundle().to_str().unwrap(), + ) + } + + /// The whole launch environment Idunn sets for this rig. + fn host(&self) -> FakeHost { + let host = self + .bare_host() + .with(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, BIND); + if self.expected.write_lease_required { + host.with( + IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT, + self.lease_path().to_str().unwrap(), + ) + } else { + host + } + } + + fn open_with( + &self, + host: &FakeHost, + target: &str, + health_contract: &str, + credentials: IdunnRuntimeCredentials, + ) -> Result { + IdunnRuntimeAuthority::open_in(host, target, health_contract, credentials) + } + + fn open(&self, host: &FakeHost) -> Result { + self.open_with(host, TARGET, HEALTH_CONTRACT, self.credentials()) + } + + fn authority(&self) -> Result { + self.open(&self.host()) + } + + fn signer(&self) -> Result { + Ok(IdunnRuntimePresenceSigner::new( + self.authority()?, + capabilities(), + )) + } + + fn verified(&self) -> Result { + verify_runtime_authority( + &self.expected, + &self.activation, + &self.idunn.trust_anchor()?, + &self.provider_public_key, + ) + } + + /// Idunn's lease record at `path`, naming `warming_sha256`. + #[cfg(unix)] + fn write_lease_at(&self, path: &Path, warming_sha256: &str, epoch: u64) -> Result<()> { + let lease = IdunnProcessWriteLeaseRecord { + schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), + target: TARGET.into(), + expected_projection_sha256: self.expected.canonical_sha256()?, + plan_id: self.expected.plan_id.clone(), + incarnation_id: self.expected.incarnation_id.clone(), + sealed_release_id: self.expected.sealed_release_id.clone(), + activation_witness_sha256: self.activation.canonical_sha256()?, + state_schema_generation: "world-v2".into(), + state_contract_sha256: digest('7'), + runtime_id: self.expected.runtime_id.clone(), + runtime_instance_id: self.activation.runtime_instance_id.clone(), + warming_presence_sha256: warming_sha256.into(), + lease_epoch: epoch, + issued_at_unix_millis: 101, + }; + write_record( + path, + TARGET, + IdunnProcessWriteLeaseRecord::TYPE, + IDUNN_PROCESS_WRITE_LEASE_SCHEMA, + lease.canonical_bytes()?, + ) + } + + #[cfg(unix)] + fn write_lease(&self, warming_sha256: &str, epoch: u64) -> Result<()> { + self.write_lease_at(&self.lease_path(), warming_sha256, epoch) + } +} + +/// Runs `presence` through the shared verifier exactly as Odin would. +fn assert_odin_accepts(rig: &Rig, presence: &SignedRuntimePresence, now: u64) -> Result<()> { + let authority = rig.verified()?; + let claim = authenticate_runtime_presence_claim( + &presence.document.payload, + &authority, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: now, + maximum_age_millis: 100, + maximum_future_skew_millis: 5, + }, + )?; + assert_eq!(claim.record(), &presence.record); + let verified = + correlate_runtime_presence_claim(claim, &authority)?.into_undisputed_present()?; + assert_eq!(verified.signed_presence_sha256(), presence.canonical_sha256); + Ok(()) +} + +// ---------------------------------------------------------------- signer tests + +#[test] +fn signed_presence_authenticates_and_correlates() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("starting", 1_000)?; + signer.report_health(RuntimePresenceState::Active); + let active = signer.sign_at("ready", 1_001)?; + + for (presence, state, detail) in [ + (&warming, "warming", "starting"), + (&active, "active", "ready"), + ] { + assert_odin_accepts(&rig, presence, 1_010)?; + assert_eq!(presence.record.state, state); + assert_eq!(presence.record.detail, detail); + assert_eq!(presence.record.bound_endpoint.as_deref(), Some(ENDPOINT)); + assert_eq!( + presence.document.payload, + rmp_serde::to_vec(&presence.record)? + ); + assert_eq!( + presence.canonical_sha256, + presence.record.canonical_sha256()? + ); + assert_eq!( + presence.document.schema_id, + GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA + ); + assert_eq!(presence.document.record_key, TARGET); + assert_eq!( + presence.document.source_runtime_id.as_deref(), + Some("ghostlight-yggdrasil-1") + ); + assert_eq!( + presence.document.source_agent_id.as_deref(), + Some(presence.record.signer_identity_id.as_str()) + ); + assert_eq!( + presence.document.source_role.as_deref(), + Some(DOCUMENT_ROLE) + ); + assert_eq!( + presence.document.tags.as_deref(), + Some( + &[ + "cultnet.transport.rudp.v0".to_owned(), + "runtime-presence".to_owned() + ][..] + ) + ); + } + assert_eq!(warming.document.stored_at, "1970-01-01T00:00:01+00:00"); + assert_eq!(signer.authority().expected().target, TARGET); + assert_eq!(signer.authority().activation(), &rig.activation); + assert_eq!(signer.authority().bound_endpoint(), Some(ENDPOINT)); + Ok(()) +} + +#[test] +fn sequence_strictly_increases_and_health_is_signer_owned() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + assert_eq!(signer.health(), RuntimePresenceState::Warming); + let first = signer.sign_at("a", 1_000)?; + assert_eq!( + (first.record.state.as_str(), first.record.publisher_sequence), + ("warming", 1) + ); + let mut sequence = 1; + for (health, text) in [ + (RuntimePresenceState::Active, "active"), + (RuntimePresenceState::Degraded, "degraded"), + (RuntimePresenceState::Failed, "failed"), + (RuntimePresenceState::Warming, "warming"), + ] { + signer.report_health(health); + assert_eq!(signer.health(), health); + let signed = signer.sign_at("b", 1_000)?; + sequence += 1; + assert_eq!(signed.record.state, text); + assert_eq!(signed.record.publisher_sequence, sequence); + assert_odin_accepts(&rig, &signed, 1_010)?; + } + Ok(()) +} + +#[test] +fn detail_is_cut_to_512_bytes_on_a_character_boundary() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + let exact = "x".repeat(512); + assert_eq!(signer.sign_at(&exact, 1_000)?.record.detail, exact); + let over = "x".repeat(513); + assert_eq!(signer.sign_at(&over, 1_000)?.record.detail, exact); + let wide = "é".repeat(400); + assert_eq!(signer.sign_at(&wide, 1_000)?.record.detail, "é".repeat(256)); + let odd = format!("x{}", "é".repeat(400)); + assert_eq!( + signer.sign_at(&odd, 1_000)?.record.detail, + format!("x{}", "é".repeat(255)) + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn warming_never_carries_lease_and_active_requires_held_lease() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("awaiting-process-write-lease", 1_000)?; + assert_eq!(warming.record.write_lease_sha256, None); + + signer.report_health(RuntimePresenceState::Active); + let refused = refusal(signer.sign_at("ready", 1_001)); + assert!( + refused.contains("lacks its process-write lease"), + "{refused}" + ); + // Degraded needs the lease as Active does, whatever the history. + signer.report_health(RuntimePresenceState::Degraded); + let refused = refusal(signer.sign_at("serving reduced", 1_001)); + assert!( + refused.contains("degraded stateful presence lacks its process-write lease"), + "{refused}" + ); + // Failed is the honest report and needs none. + signer.report_health(RuntimePresenceState::Failed); + assert_eq!( + signer + .sign_at("no lease yet", 1_001)? + .record + .write_lease_sha256, + None + ); + + rig.write_lease(&warming.canonical_sha256, 1)?; + let guard = signer + .try_acquire_write_lease()? + .expect("the lease binds a warming proof"); + signer.report_health(RuntimePresenceState::Active); + let active = signer.sign_at("ready", 1_002)?; + assert_eq!( + active.record.write_lease_sha256.as_deref(), + Some(guard.lease_sha256()) + ); + assert_odin_accepts(&rig, &active, 1_010)?; + + signer.report_health(RuntimePresenceState::Degraded); + assert_eq!( + signer + .sign_at("slow", 1_003)? + .record + .write_lease_sha256 + .as_deref(), + Some(guard.lease_sha256()) + ); + signer.report_health(RuntimePresenceState::Warming); + assert_eq!( + signer.sign_at("again", 1_004)?.record.write_lease_sha256, + None + ); + Ok(()) +} + +#[test] +fn a_runtime_without_a_lease_requirement_signs_active_and_refuses_a_lease() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + signer.report_health(RuntimePresenceState::Active); + assert_eq!( + signer.sign_at("ready", 1_000)?.record.write_lease_sha256, + None + ); + let refused = refusal(signer.try_acquire_write_lease()); + assert!(refused.contains("does not require"), "{refused}"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn lease_must_bind_a_recent_warming_proof_of_this_signer() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + assert!(signer.try_acquire_write_lease()?.is_none(), "no record yet"); + + let mut warming = Vec::new(); + for index in 0..65 { + warming.push( + signer + .sign_at(&format!("heartbeat {index}"), 1_000)? + .canonical_sha256, + ); + } + signer.report_health(RuntimePresenceState::Failed); + let not_warming = signer.sign_at("failed", 1_000)?.canonical_sha256; + + // A proof some other signer signed, a presence that was never Warming and a + // Warming proof pushed out of the last 64 are all refused. + for (epoch, sha) in [(1, digest('d')), (2, not_warming), (3, warming[0].clone())] { + rig.write_lease(&sha, epoch)?; + let refused = refusal(signer.try_acquire_write_lease()); + assert!( + refused.contains("provider-owned Warming proof"), + "{refused}" + ); + } + + // The oldest proof still inside the window is accepted. + rig.write_lease(&warming[1], 4)?; + assert!(signer.try_acquire_write_lease()?.is_some()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_lease_for_another_runtime_instance_is_refused() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("w", 1_000)?; + rig.write_lease(&warming.canonical_sha256, 1)?; + let stored = SingleFileMessagePackBackingStore::new(rig.lease_path()) + .pull_all()? + .remove(0); + let mut lease = IdunnProcessWriteLeaseRecord::decode_canonical(&stored.payload)?; + lease.runtime_instance_id = digest('1'); + write_record( + &rig.lease_path(), + TARGET, + IdunnProcessWriteLeaseRecord::TYPE, + IDUNN_PROCESS_WRITE_LEASE_SCHEMA, + lease.canonical_bytes()?, + )?; + let refused = refusal(signer.try_acquire_write_lease()); + assert!( + refused.contains("provider-owned Warming proof"), + "{refused}" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn lease_guard_holds_shared_lock_and_detects_replacement() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("w", 1_000)?; + rig.write_lease(&warming.canonical_sha256, 1)?; + let guard = signer.try_acquire_write_lease()?.unwrap(); + assert_eq!( + signer.try_acquire_write_lease()?.unwrap().lease_sha256(), + guard.lease_sha256(), + "acquiring again returns the held lease" + ); + guard.require_current()?; + + let probe = OpenOptions::new().read(true).open(rig.lock_path())?; + assert!( + fs2::FileExt::try_lock_exclusive(&probe).is_err(), + "a guard holds the lock shared" + ); + fs2::FileExt::try_lock_shared(&probe)?; + fs2::FileExt::unlock(&probe)?; + + // The lock outlives every clone but the last. + let clone = guard.clone(); + drop(guard); + assert!(fs2::FileExt::try_lock_exclusive(&probe).is_err()); + + // A writer that renames a new record into place is detected. + let replacement = rig.dir.path().join("replacement.cc"); + rig.write_lease_at(&replacement, &warming.canonical_sha256, 2)?; + std::fs::rename(&replacement, rig.lease_path())?; + let stale = refusal(clone.require_current()); + assert!(stale.contains("no longer the admitted"), "{stale}"); + signer.report_health(RuntimePresenceState::Active); + assert!( + signer.sign_at("ready", 1_001).is_err(), + "a stale lease signs nothing but Warming" + ); + + drop(clone); + drop(signer); + fs2::FileExt::try_lock_exclusive(&probe)?; + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_removed_lease_record_is_not_current() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("w", 1_000)?; + rig.write_lease(&warming.canonical_sha256, 1)?; + let guard = signer.try_acquire_write_lease()?.unwrap(); + std::fs::remove_file(rig.lease_path())?; + let stale = refusal(guard.require_current()); + assert!(stale.contains("no longer the admitted"), "{stale}"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_lost_lease_still_signs_failed() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("warming", 1_000)?; + rig.write_lease(&warming.canonical_sha256, 1)?; + let guard = signer.try_acquire_write_lease()?.expect("the lease binds"); + signer.report_health(RuntimePresenceState::Active); + let held = signer.sign_at("held", 1_001)?; + assert_eq!( + held.record.write_lease_sha256.as_deref(), + Some(guard.lease_sha256()) + ); + // While the lease is current, Failed carries its digest like any other state. + signer.report_health(RuntimePresenceState::Failed); + assert_eq!( + signer + .sign_at("failing", 1_001)? + .record + .write_lease_sha256 + .as_deref(), + Some(guard.lease_sha256()) + ); + + // Idunn withdraws the lease: Active and Degraded can no longer be claimed. + std::fs::remove_file(rig.lease_path())?; + for state in [RuntimePresenceState::Active, RuntimePresenceState::Degraded] { + signer.report_health(state); + let refused = refusal(signer.sign_at("lost", 1_002)); + assert!( + refused.contains("no longer the admitted"), + "{state:?}: {refused}" + ); + } + // Failed is the honest report, and it carries no lease digest. + signer.report_health(RuntimePresenceState::Failed); + let failed = signer.sign_at("lease lost", 1_003)?; + assert_eq!(failed.record.state, "failed"); + assert_eq!(failed.record.write_lease_sha256, None); + let authority = rig.verified()?; + let claim = authenticate_runtime_presence_claim( + &failed.document.payload, + &authority, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: 1_010, + maximum_age_millis: 100, + maximum_future_skew_millis: 5, + }, + )?; + correlate_runtime_presence_claim(claim, &authority)?; + // Warming still signs, as before. + signer.report_health(RuntimePresenceState::Warming); + signer.sign_at("again", 1_004)?; + Ok(()) +} + +// ------------------------------------------------------------- authority tests + +#[test] +fn authority_open_refuses_mismatches() -> Result<()> { + let rig = Rig::new(false)?; + rig.authority()?; + + let refused = refusal(rig.open_with(&rig.host(), "other", HEALTH_CONTRACT, rig.credentials())); + assert!(refused.contains("target or health contract"), "{refused}"); + let refused = refusal(rig.open_with(&rig.host(), TARGET, "other.v1", rig.credentials())); + assert!(refused.contains("target or health contract"), "{refused}"); + + let mut wrong_seed = rig.activation_seed.clone(); + wrong_seed[0] ^= 1; + let credentials = IdunnRuntimeCredentials::new( + Cursor::new(wrong_seed), + Cursor::new(rig.provider_credential.clone()), + ); + let refused = refusal(rig.open_with(&rig.host(), TARGET, HEALTH_CONTRACT, credentials)); + assert!( + refused.contains("activation credential does not belong"), + "{refused}" + ); + + let other = tempfile::tempdir()?; + enroll_service_identity_at::(&other.path().join("p.cc"))?; + let credentials = IdunnRuntimeCredentials::new( + Cursor::new(rig.activation_seed.clone()), + Cursor::new(std::fs::read(other.path().join("p.cc"))?), + ); + let refused = refusal(rig.open_with(&rig.host(), TARGET, HEALTH_CONTRACT, credentials)); + assert!( + refused.contains("not the identity selected by Expected"), + "{refused}" + ); + + // A lease environment on an incarnation that needs none is refused. + let lease_env = rig + .host() + .with(IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT, "/run/lease.cc"); + let refused = refusal(rig.open(&lease_env)); + assert!( + refused.contains("write-lease environment does not match"), + "{refused}" + ); + let empty = rig.host().with(IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT, ""); + assert!(refusal(rig.open(&empty)).contains("empty or has surrounding whitespace")); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_lease_requirement_needs_the_lease_environment() -> Result<()> { + let rig = Rig::new(true)?; + rig.authority()?; + let no_lease_env = rig + .bare_host() + .with(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, BIND); + let refused = refusal(rig.open(&no_lease_env)); + assert!( + refused.contains("write-lease environment does not match"), + "{refused}" + ); + Ok(()) +} + +#[cfg(not(unix))] +#[test] +fn non_unix_open_refuses_a_write_lease_requirement() -> Result<()> { + let rig = Rig::new(true)?; + let refused = refusal(rig.authority()); + assert!(refused.contains("write_lease_required"), "{refused}"); + assert!( + !refused.contains(rig.dir.path().to_str().unwrap()), + "{refused}" + ); + Ok(()) +} + +#[test] +fn open_reads_the_whole_launch_environment() -> Result<()> { + let rig = Rig::new(false)?; + // The bundle comes only from the launch environment: with it missing, open has + // nowhere to look, whatever else is set. + let no_bundle = FakeHost::new().with(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, BIND); + let refused = refusal(rig.open(&no_bundle)); + assert!( + refused.contains(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT), + "{refused}" + ); + let relative = rig + .host() + .with(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, "relative/bundle"); + let refused = refusal(rig.open(&relative)); + assert!(refused.contains("not an absolute path"), "{refused}"); + // The bundle, the candidate bind and (below, unix) the write lease are all + // read through the one host port. + let authority = rig.open(&rig.host())?; + authority.require_candidate_bind(BIND)?; + Ok(()) +} + +#[test] +fn the_launch_contract_names_are_the_public_spellings() { + assert_eq!( + IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + "GAMECULT_IDUNN_RUNTIME_BUNDLE" + ); + assert_eq!( + IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, + "GAMECULT_IDUNN_CANDIDATE_BIND" + ); + assert_eq!( + IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT, + "GAMECULT_IDUNN_PROCESS_WRITE_LEASE" + ); + assert_eq!( + IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + "GAMECULT_IDUNN_ACTIVATION_CREDENTIAL" + ); + assert_eq!( + RUNTIME_PRESENCE_IDENTITY_BINDING, + "GAMECULT_RUNTIME_PRESENCE_IDENTITY" + ); + assert_eq!( + RUNTIME_PRESENCE_IDENTITY_FD_NAME, + "gamecult-runtime-presence-identity" + ); +} + +#[test] +fn authority_open_refuses_an_activation_for_another_expected() -> Result<()> { + let rig = Rig::new(false)?; + let mut other_expected = rig.expected.clone(); + other_expected.plan_id = digest('9'); + let (foreign, _) = Rig::issue(&other_expected, &rig.idunn)?; + write_record( + &rig.bundle().join("activation.cc"), + TARGET, + IdunnRuntimeActivationRecord::TYPE, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, + foreign.canonical_bytes()?, + )?; + let refused = refusal(rig.authority()); + assert!( + refused.contains("does not bind the bundled Expected"), + "{refused}" + ); + Ok(()) +} + +#[test] +fn authority_open_refuses_a_substituted_record_key() -> Result<()> { + let rig = Rig::new(false)?; + let substitute = rig.dir.path().join("substitute.cc"); + write_record( + &substitute, + "someone-else", + IdunnExpectedIncarnationRecord::TYPE, + IDUNN_EXPECTED_INCARNATION_SCHEMA, + rig.expected.canonical_bytes()?, + )?; + std::fs::rename(&substitute, rig.bundle().join("expected.cc"))?; + let refused = refusal(rig.authority()); + assert!(refused.contains("key is not its target"), "{refused}"); + Ok(()) +} + +#[test] +fn candidate_bind_must_equal_expected_route() -> Result<()> { + let rig = Rig::new(false)?; + let authority = rig.authority()?; + authority.require_candidate_bind(BIND)?; + assert!(authority.require_candidate_bind("127.0.0.1:14104").is_err()); + assert!(authority.require_candidate_bind(ENDPOINT).is_err()); + assert!(authority.require_candidate_bind("").is_err()); + assert_eq!(authority.bound_endpoint(), Some(ENDPOINT)); + + // Idunn's environment must itself name Expected's route. + let bind = |value: &str| { + rig.bare_host() + .with(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, value) + }; + assert!(refusal(rig.open(&bind("127.0.0.1:14104"))).contains("candidate bind differs")); + let missing = rig.bare_host(); + assert!(refusal(rig.open(&missing)).contains("candidate bind environment")); + assert!(refusal(rig.open(&bind(" 127.0.0.1:14103"))).contains("surrounding whitespace")); + Ok(()) +} + +#[test] +fn candidate_bind_suffix_is_refused_at_open() -> Result<()> { + let rig = Rig::new(false)?; + // Idunn passes the bare host:port; only the whole of Expected's endpoint + // after `tcp://` is that bind. A suffix or prefix of it is not. + for wrong in [ + "0.0.1:14103", + "1:14103", + "127.0.0.1:1410", + "tcp://127.0.0.1:14103", + ] { + let host = rig + .bare_host() + .with(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, wrong); + let refused = refusal(rig.open(&host)); + assert!( + refused.contains("candidate bind differs"), + "{wrong}: {refused}" + ); + } + let authority = rig.authority()?; + assert!(authority.require_candidate_bind("0.0.1:14103").is_err()); + Ok(()) +} + +#[test] +fn a_route_less_incarnation_has_no_bind_to_check() -> Result<()> { + let mut rig = Rig::new(false)?; + rig.expected.route = None; + let (activation, seed) = Rig::issue(&rig.expected, &rig.idunn)?; + rig.activation = activation; + rig.activation_seed = seed; + rig.write_bundle(&rig.expected, &rig.activation)?; + let authority = rig.open(&rig.bare_host())?; + assert_eq!(authority.bound_endpoint(), None); + assert!(authority.require_candidate_bind(BIND).is_err()); + assert!(refusal(rig.open(&rig.host())).contains("candidate bind environment")); + let mut signer = IdunnRuntimePresenceSigner::new(authority, capabilities()); + assert_eq!(signer.sign_at("w", 1_000)?.record.bound_endpoint, None); + Ok(()) +} + +// --------------------------------------------------------------- route answer + +fn strings(items: &[&str]) -> Vec { + items.iter().map(|item| (*item).to_owned()).collect() +} + +fn request(id: &str, schemas: Option<&[&str]>, keys: Option<&[&str]>) -> CultNetMessage { + CultNetMessage::SnapshotRequest { + message_id: id.into(), + schema_ids: schemas.map(strings), + record_keys: keys.map(strings), + } +} + +fn presence_in(answer: CultNetMessage) -> GameCultRuntimePresenceHealthRecord { + let CultNetMessage::SnapshotResponseRaw { documents, .. } = answer else { + panic!("a snapshot response"); + }; + let [document] = documents.as_slice() else { + panic!("one presence document"); + }; + rmp_serde::from_slice(&document.payload).unwrap() +} + +#[test] +fn route_observation_refuses_foreign_requests() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + signer.report_health(RuntimePresenceState::Degraded); + let schema = GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA; + + let answer = + signer.answer_route_observation(&request("m-1", Some(&[schema]), Some(&[TARGET])))?; + let CultNetMessage::SnapshotResponseRaw { message_id, .. } = &answer else { + panic!("a snapshot response"); + }; + assert_eq!(message_id, "m-1"); + let record = presence_in(answer); + assert_eq!( + record.state, "degraded", + "the answer states the reported health" + ); + assert_eq!(record.detail, "route-observation:m-1"); + assert_eq!(record.publisher_sequence, 1); + // An open request and a request listing more than this runtime are fine. + signer.answer_route_observation(&request("m-2", None, None))?; + signer.answer_route_observation(&request("m-3", Some(&["x", schema]), Some(&["y", TARGET])))?; + + for refused in [ + request("m", Some(&["other.schema.v1"]), None), + request("m", None, Some(&["another-target"])), + request("m", Some(&[schema]), Some(&["another-target"])), + request("m", Some(&["other.schema.v1"]), Some(&[TARGET])), + request("", None, None), + request(" m", None, None), + request("m ", None, None), + CultNetMessage::SnapshotResponseRaw { + message_id: "m".into(), + documents: Vec::new(), + }, + ] { + assert!( + signer.answer_route_observation(&refused).is_err(), + "{refused:?}" + ); + } + // Refusals sign nothing: the next answer takes the next sequence. + let record = presence_in(signer.answer_route_observation(&request("m-4", None, None))?); + assert_eq!(record.publisher_sequence, 4); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn route_observation_answers_in_lease_discipline() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + signer.report_health(RuntimePresenceState::Active); + assert!( + signer + .answer_route_observation(&request("m", None, None)) + .is_err() + ); + Ok(()) +} + +// -------------------------------------------------------- descriptor contract + +#[cfg(unix)] +fn clear_cloexec(file: &File) { + // SAFETY: fcntl on a descriptor `file` owns. + assert_ne!( + unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, 0) }, + -1 + ); +} + +#[cfg(unix)] +fn set_cloexec(file: &File) { + // SAFETY: fcntl on a descriptor `file` owns. + assert_ne!( + unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) }, + -1 + ); +} + +#[cfg(unix)] +fn is_cloexec(file: &File) -> bool { + // SAFETY: fcntl on a descriptor `file` owns. + let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFD) }; + flags != -1 && flags & libc::FD_CLOEXEC != 0 +} + +#[cfg(unix)] +fn inherited(dir: &Path, name: &str, content: &[u8]) -> Result { + std::fs::write(dir.join(name), content)?; + let file = File::open(dir.join(name))?; + clear_cloexec(&file); + assert!(!is_cloexec(&file)); + Ok(file) +} + +#[cfg(unix)] +fn systemd_host(dir: &Path, listen_pid: &str, fds: &str, names: &str) -> Result { + let mut host = FakeHost::new() + .with(LISTEN_PID_ENVIRONMENT, listen_pid) + .with(LISTEN_FDS_ENVIRONMENT, fds) + .with(LISTEN_FDNAMES_ENVIRONMENT, names); + for (index, name) in names.split(':').enumerate() { + let descriptor = LISTEN_FDS_START + index as RawFd; + host.descriptors.insert( + descriptor, + inherited(dir, &format!("fd-{index}"), name.as_bytes())?, + ); + } + Ok(host) +} + +#[cfg(unix)] +fn contents(mut file: File) -> Result { + let mut text = String::new(); + file.read_to_string(&mut text)?; + Ok(text) +} + +#[cfg(unix)] +#[test] +fn systemd_descriptor_contract() -> Result<()> { + let dir = tempfile::tempdir()?; + let names = format!("socket:{ACTIVATION_NAME}:extra:{PROVIDER_NAME}:more"); + + // Found by name; extra descriptors are tolerated and left alone. + let mut host = systemd_host(dir.path(), "4242", "5", &names)?; + // A descriptor that already carries close-on-exec keeps it. + set_cloexec(&host.descriptors[&(LISTEN_FDS_START + 3)]); + let (activation, provider) = take_systemd_descriptors(&mut host, Custody::Profile)?; + assert!( + is_cloexec(&activation) && is_cloexec(&provider), + "signers never reach a child" + ); + assert_eq!(contents(activation)?, ACTIVATION_NAME); + assert_eq!(contents(provider)?, PROVIDER_NAME); + assert!(host.vars.is_empty(), "LISTEN_* leave the environment"); + assert_eq!(host.descriptors.len(), 3, "only the two signers were taken"); + + // Order does not matter. + let swapped = format!("{PROVIDER_NAME}:{ACTIVATION_NAME}"); + let mut host = systemd_host(dir.path(), "4242", "2", &swapped)?; + let (activation, provider) = take_systemd_descriptors(&mut host, Custody::Profile)?; + assert_eq!(contents(activation)?, ACTIVATION_NAME); + assert_eq!(contents(provider)?, PROVIDER_NAME); + + // A claim for another process is refused and still cleared from the environment. + let both = format!("{ACTIVATION_NAME}:{PROVIDER_NAME}"); + let mut host = systemd_host(dir.path(), "999", "2", &both)?; + assert!( + refusal(take_systemd_descriptors(&mut host, Custody::Profile)) + .contains("different process") + ); + assert!(host.vars.is_empty()); + + // PrivatePIDs: pid 1 accepts the host's LISTEN_PID, a descendant does not. + let mut host = systemd_host(dir.path(), "777", "2", &both)?; + host.pid = 1; + take_systemd_descriptors(&mut host, Custody::Profile)?; + let mut host = systemd_host(dir.path(), "777", "2", &both)?; + host.pid = 2; + assert!(take_systemd_descriptors(&mut host, Custody::Profile).is_err()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn systemd_listen_pid_rule() { + assert!(listen_pid_matches("4242", 4242)); + assert!(!listen_pid_matches("4243", 4242)); + // PrivatePIDs: the main process is pid 1 and cannot see the host pid. + assert!(listen_pid_matches("777", 1)); + assert!(listen_pid_matches("1", 1)); + assert!( + !listen_pid_matches("777", 2), + "a descendant must not reuse the set" + ); + assert!(!listen_pid_matches("0", 1)); + // The text must be the canonical number. + assert!(!listen_pid_matches("04242", 4242)); + assert!(!listen_pid_matches("+4242", 4242)); + assert!(!listen_pid_matches("", 1)); + assert!(!listen_pid_matches("abc", 1)); +} + +#[cfg(unix)] +#[test] +fn systemd_descriptor_contract_refuses_malformed_claims() -> Result<()> { + let dir = tempfile::tempdir()?; + let both = format!("{ACTIVATION_NAME}:{PROVIDER_NAME}"); + let only_activation = ACTIVATION_NAME.to_owned(); + let doubled = format!("{ACTIVATION_NAME}:{PROVIDER_NAME}:{PROVIDER_NAME}"); + for (fds, names, expect) in [ + ( + "2", + only_activation.as_str(), + "LISTEN_FDNAMES does not name", + ), + ( + "1", + only_activation.as_str(), + "did not pass the gamecult-runtime-presence-identity", + ), + ("3", doubled.as_str(), "more than once"), + ("2", "a:b", "did not pass the gamecult-idunn"), + ("02", both.as_str(), "LISTEN_FDS"), + ("x", both.as_str(), "LISTEN_FDS"), + ("3", both.as_str(), "does not name every"), + ] { + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + host.vars.insert(LISTEN_FDS_ENVIRONMENT.into(), fds.into()); + host.vars + .insert(LISTEN_FDNAMES_ENVIRONMENT.into(), names.into()); + let refused = refusal(take_systemd_descriptors(&mut host, Custody::Profile)); + assert!(refused.contains(expect), "{names}: {refused}"); + assert!(host.vars.is_empty()); + } + for missing in [ + LISTEN_PID_ENVIRONMENT, + LISTEN_FDS_ENVIRONMENT, + LISTEN_FDNAMES_ENVIRONMENT, + ] { + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + host.vars.remove(missing); + assert!(refusal(take_systemd_descriptors(&mut host, Custody::Profile)).contains(missing)); + assert!(host.vars.is_empty(), "the rest of the claim is removed too"); + } + // A descriptor the process does not have open is refused. + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + host.descriptors.clear(); + assert!(refusal(take_systemd_descriptors(&mut host, Custody::Profile)).contains("not open")); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn systemd_descriptors_must_be_read_only() -> Result<()> { + let dir = tempfile::tempdir()?; + let both = format!("{ACTIVATION_NAME}:{PROVIDER_NAME}"); + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + let writable = OpenOptions::new() + .read(true) + .write(true) + .open(dir.path().join("fd-1"))?; + host.descriptors.insert(LISTEN_FDS_START + 1, writable); + let refused = refusal(take_systemd_descriptors(&mut host, Custody::Profile)); + assert!(refused.contains("not read-only"), "{refused}"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn write_only_pipe_descriptor_is_not_read_only() -> Result<()> { + let mut fds = [0 as libc::c_int; 2]; + // SAFETY: pipe writes two descriptors into the array. + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + // SAFETY: both descriptors were just created and are owned exactly once. + let (read_end, write_end) = unsafe { (File::from_raw_fd(fds[0]), File::from_raw_fd(fds[1])) }; + // SAFETY: F_GETFL reads the flags of a descriptor `write_end` owns. + let status = unsafe { libc::fcntl(write_end.as_raw_fd(), libc::F_GETFL) }; + assert_eq!( + status & libc::O_ACCMODE, + libc::O_WRONLY, + "a valid status of 1" + ); + // F_GETFL succeeds here, so the refusal is the access mode and not a failed read. + let refused = refusal(protect_descriptor( + write_end, + "probe signer", + Custody::Profile, + )); + assert!( + refused.contains("probe signer descriptor is not read-only"), + "{refused}" + ); + assert!( + protect_descriptor(read_end, "probe signer", Custody::Profile).is_ok(), + "the read end of the same pipe is read-only" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn systemd_descriptors_pass_the_root_custody_rule_or_are_refused() -> Result<()> { + let dir = tempfile::tempdir()?; + let both = format!("{ACTIVATION_NAME}:{PROVIDER_NAME}"); + // The temp files are mode 0644, never root's 0400. + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + let refused = refusal(take_systemd_descriptors(&mut host, Custody::Root)); + assert!(refused.contains("root-owned 0400"), "{refused}"); + // The public delivery is root custody, with no way to ask for less. + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + let refused = refusal(IdunnRuntimeCredentials::from_systemd_descriptors_in( + &mut host, + )); + assert!(refused.contains("root-owned 0400"), "{refused}"); + Ok(()) +} + +#[test] +fn custody_follows_delivery() -> Result<()> { + let rig = Rig::new(false)?; + assert_eq!(rig.credentials().custody, Custody::Profile, "new"); + + let dir = tempfile::tempdir()?; + let seed = dir.path().join("activation-key"); + let identity = dir.path().join("identity.cc"); + std::fs::write(&seed, &rig.activation_seed)?; + std::fs::write(&identity, &rig.provider_credential)?; + let host = FakeHost::new() + .with( + IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + seed.to_str().unwrap(), + ) + .with( + RUNTIME_PRESENCE_IDENTITY_BINDING, + identity.to_str().unwrap(), + ); + assert_eq!( + IdunnRuntimeCredentials::from_host_environment_in(&host)?.custody, + Custody::Profile, + "host files" + ); + + // Profile custody takes the real thing and nothing else, on every platform. + for (kind, rule, accepted) in [ + (FileKind::Directory, Rule::ReadOnlyDirectory, true), + (FileKind::Regular, Rule::ReadOnlyFile, true), + (FileKind::Regular, Rule::ControlledFile, true), + (FileKind::Symlink, Rule::ReadOnlyDirectory, false), + (FileKind::Symlink, Rule::ReadOnlyFile, false), + (FileKind::Symlink, Rule::ControlledFile, false), + (FileKind::Other, Rule::ReadOnlyFile, false), + (FileKind::Regular, Rule::ReadOnlyDirectory, false), + (FileKind::Directory, Rule::ReadOnlyFile, false), + ] { + assert_eq!(profile_accepts(kind, rule), accepted, "{kind:?} {rule:?}"); + } + Ok(()) +} + +#[test] +fn profile_custody_reads_the_real_filesystem() -> Result<()> { + let dir = tempfile::tempdir()?; + let file = dir.path().join("record.cc"); + std::fs::write(&file, b"x")?; + let custody = Custody::Profile; + custody.require_file(&file, "record", Rule::ReadOnlyFile)?; + custody.require_file(dir.path(), "dir", Rule::ReadOnlyDirectory)?; + // The wrong kind, an absent path and a relative path are refused. + let refused = refusal(custody.require_file(dir.path(), "dir", Rule::ReadOnlyFile)); + assert!(refused.contains("dir is not a regular file"), "{refused}"); + assert!( + custody + .require_file(&file, "record", Rule::ReadOnlyDirectory) + .is_err() + ); + assert!( + custody + .require_file(&dir.path().join("absent"), "absent", Rule::ReadOnlyFile) + .is_err() + ); + assert!( + custody + .require_file(Path::new("record.cc"), "record", Rule::ReadOnlyFile) + .is_err() + ); + assert_eq!( + FileKind::of(&std::fs::symlink_metadata(&file)?), + FileKind::Regular + ); + assert_eq!( + FileKind::of(&std::fs::symlink_metadata(dir.path())?), + FileKind::Directory + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn profile_custody_refuses_a_symlink() -> Result<()> { + let dir = tempfile::tempdir()?; + let file = dir.path().join("record.cc"); + std::fs::write(&file, b"x")?; + let link = dir.path().join("link.cc"); + std::os::unix::fs::symlink(&file, &link)?; + let refused = refusal(Custody::Profile.require_file(&link, "record", Rule::ReadOnlyFile)); + assert!(refused.contains("a link is refused"), "{refused}"); + assert_eq!( + FileKind::of(&std::fs::symlink_metadata(&link)?), + FileKind::Symlink + ); + let dir_link = dir.path().join("dir-link"); + std::os::unix::fs::symlink(dir.path(), &dir_link)?; + assert!( + Custody::Profile + .require_file(&dir_link, "dir", Rule::ReadOnlyDirectory) + .is_err() + ); + // A bundle that is a symlink is refused by open, not followed. + let rig = Rig::new(false)?; + let bundle_link = rig.dir.path().join("bundle-link"); + std::os::unix::fs::symlink(rig.bundle(), &bundle_link)?; + let host = rig.host().with( + IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + bundle_link.to_str().unwrap(), + ); + let refused = refusal(rig.open(&host)); + assert!( + refused.contains("Idunn runtime bundle is not a directory"), + "{refused}" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn credentials_helper_wires_the_systemd_signers() -> Result<()> { + let rig = Rig::new(false)?; + let dir = tempfile::tempdir()?; + let both = format!("{ACTIVATION_NAME}:{PROVIDER_NAME}"); + let mut host = systemd_host(dir.path(), "4242", "2", &both)?; + host.descriptors.insert( + LISTEN_FDS_START, + inherited(dir.path(), "seed", &rig.activation_seed)?, + ); + host.descriptors.insert( + LISTEN_FDS_START + 1, + inherited(dir.path(), "identity", &rig.provider_credential)?, + ); + // The temp files are not root's, so the test takes the descriptors under profile + // custody and wraps them as any two readers. + let (activation, provider) = take_systemd_descriptors(&mut host, Custody::Profile)?; + let credentials = IdunnRuntimeCredentials::new(activation, provider); + let authority = rig.open_with(&rig.host(), TARGET, HEALTH_CONTRACT, credentials)?; + assert_eq!(authority.expected().target, TARGET); + Ok(()) +} + +// ------------------------------------------------------------ host credentials + +#[test] +fn host_activation_credential_is_one_shot() -> Result<()> { + let rig = Rig::new(false)?; + let dir = tempfile::tempdir()?; + let seed_path = dir.path().join("activation-key"); + let identity_path = dir.path().join("identity.cc"); + std::fs::write(&seed_path, &rig.activation_seed)?; + std::fs::write(&identity_path, &rig.provider_credential)?; + let host = FakeHost::new() + .with( + IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + seed_path.to_str().unwrap(), + ) + .with( + RUNTIME_PRESENCE_IDENTITY_BINDING, + identity_path.to_str().unwrap(), + ); + + let credentials = IdunnRuntimeCredentials::from_host_environment_in(&host)?; + assert!( + !seed_path.exists(), + "the seed lives only in this process now" + ); + assert!( + identity_path.exists(), + "the provider identity is stable and stays" + ); + let authority = rig.open_with(&rig.host(), TARGET, HEALTH_CONTRACT, credentials)?; + assert_eq!(authority.activation(), &rig.activation); + // The file is gone, so a second launch from the same delivery cannot read it. + assert!(IdunnRuntimeCredentials::from_host_environment_in(&host).is_err()); + Ok(()) +} + +#[test] +fn host_credentials_refuse_missing_or_relative_paths() -> Result<()> { + let dir = tempfile::tempdir()?; + let seed = dir.path().join("seed"); + std::fs::write(&seed, [1u8; 32])?; + let identity = dir.path().join("identity.cc"); + std::fs::write(&identity, b"x")?; + let host = FakeHost::new().with( + IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + seed.to_str().unwrap(), + ); + assert!( + refusal(IdunnRuntimeCredentials::from_host_environment_in(&host)) + .contains(RUNTIME_PRESENCE_IDENTITY_BINDING) + ); + let host = FakeHost::new().with( + RUNTIME_PRESENCE_IDENTITY_BINDING, + identity.to_str().unwrap(), + ); + assert!( + refusal(IdunnRuntimeCredentials::from_host_environment_in(&host)) + .contains(IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT) + ); + let host = FakeHost::new() + .with(IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, "relative-seed") + .with( + RUNTIME_PRESENCE_IDENTITY_BINDING, + identity.to_str().unwrap(), + ); + assert!( + refusal(IdunnRuntimeCredentials::from_host_environment_in(&host)).contains("not absolute") + ); + assert!(seed.exists(), "nothing was consumed by a refused delivery"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn root_rules_accept_what_idunn_provisions_and_nothing_else() { + use FileKind::{Directory, Regular}; + // What Idunn's systemd driver provisions: 0555 bundle, 0444 records and locks, + // 0640 lease files. + assert!(facts(Directory, 0, 0o555).satisfies(Rule::ReadOnlyDirectory)); + assert!(facts(Regular, 0, 0o444).satisfies(Rule::ReadOnlyFile)); + assert!(facts(Regular, 0, 0o640).satisfies(Rule::ControlledFile)); + // Each rule keeps its own kind and owner. + assert!(!facts(Directory, 0, 0o555).satisfies(Rule::ReadOnlyFile)); + assert!(!facts(Regular, 0, 0o444).satisfies(Rule::ReadOnlyDirectory)); + assert!(!facts(Regular, 0, 0o640).satisfies(Rule::ReadOnlyFile)); + assert!(!facts(Regular, 1000, 0o640).satisfies(Rule::ControlledFile)); +} + +#[cfg(unix)] +#[test] +fn only_root_custody_inspects_a_lock() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir()?; + let lock = dir.path().join("record.cc.lock"); + std::fs::write(&lock, b"")?; + std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o666))?; + // The profile trusts its own lock; root custody refuses a world-writable one. + Custody::Profile.require_lock(&lock, "lock", Rule::ControlledFile)?; + let refused = refusal(Custody::Root.require_lock(&lock, "lock", Rule::ControlledFile)); + assert!( + refused.contains("lock is not a root-controlled regular file"), + "{refused}" + ); + Ok(()) +} + +/// A directory junction is a reparse point: profile custody refuses it as a bundle. +#[cfg(windows)] +#[test] +fn profile_custody_refuses_a_junction() -> Result<()> { + let rig = Rig::new(false)?; + let junction = rig.dir.path().join("bundle-junction"); + let made = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&junction) + .arg(rig.bundle()) + .output()?; + assert!(made.status.success(), "{made:?}"); + assert_eq!( + FileKind::of(&std::fs::symlink_metadata(&junction)?), + FileKind::Symlink + ); + let host = rig + .host() + .with(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, junction.to_str().unwrap()); + let refused = refusal(rig.open(&host)); + assert!( + refused.contains("Idunn runtime bundle is not a directory"), + "{refused}" + ); + Ok(()) +} + +fn host_delivery(seed: &Path, identity: &Path) -> FakeHost { + FakeHost::new() + .with( + IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT, + seed.to_str().unwrap(), + ) + .with( + RUNTIME_PRESENCE_IDENTITY_BINDING, + identity.to_str().unwrap(), + ) +} + +#[test] +fn host_delivery_signs_on_every_platform() -> Result<()> { + let rig = Rig::new(false)?; + let dir = tempfile::tempdir()?; + let seed = dir.path().join("activation-key"); + let identity = dir.path().join("identity.cc"); + std::fs::write(&seed, &rig.activation_seed)?; + std::fs::write(&identity, &rig.provider_credential)?; + let credentials = + IdunnRuntimeCredentials::from_host_environment_in(&host_delivery(&seed, &identity))?; + let authority = rig.open_with(&rig.host(), TARGET, HEALTH_CONTRACT, credentials)?; + let mut signer = IdunnRuntimePresenceSigner::new(authority, capabilities()); + let warming = signer.sign_at("starting", 1_000)?; + signer.report_health(RuntimePresenceState::Active); + let active = signer.sign_at("ready", 1_001)?; + assert_odin_accepts(&rig, &warming, 1_010)?; + assert_odin_accepts(&rig, &active, 1_010)?; + Ok(()) +} + +#[test] +fn host_credentials_keep_the_seed_when_the_provider_is_missing() -> Result<()> { + let rig = Rig::new(false)?; + let dir = tempfile::tempdir()?; + let seed = dir.path().join("activation-key"); + std::fs::write(&seed, &rig.activation_seed)?; + let absent = dir.path().join("absent-identity.cc"); + let refused = refusal(IdunnRuntimeCredentials::from_host_environment_in( + &host_delivery(&seed, &absent), + )); + assert!( + refused.contains(RUNTIME_PRESENCE_IDENTITY_BINDING), + "{refused}" + ); + assert!(seed.exists(), "a refused delivery leaves the one-shot seed"); + // The next launch attempt, with the identity in place, still has its seed. + let identity = dir.path().join("identity.cc"); + std::fs::write(&identity, &rig.provider_credential)?; + IdunnRuntimeCredentials::from_host_environment_in(&host_delivery(&seed, &identity))?; + assert!(!seed.exists()); + Ok(()) +} + +#[test] +fn host_credential_errors_never_echo_a_path() -> Result<()> { + let rig = Rig::new(false)?; + let root = tempfile::tempdir()?; + let canary = root.path().join("canary-6e1f0c9a"); + std::fs::create_dir(&canary)?; + let seed = canary.join("activation-key"); + let identity = canary.join("identity.cc"); + std::fs::write(&identity, &rig.provider_credential)?; + let directory = canary.join("a-directory"); + std::fs::create_dir(&directory)?; + let mut refusals = vec![ + // The activation credential is absent. + refusal(IdunnRuntimeCredentials::from_host_environment_in( + &host_delivery(&seed, &identity), + )), + // The provider identity is absent. + refusal(IdunnRuntimeCredentials::from_host_environment_in( + &host_delivery(&directory.join("seed-in-a-dir"), &canary.join("absent.cc")), + )), + // The activation credential is a directory: it cannot be read or retired. + refusal(IdunnRuntimeCredentials::from_host_environment_in( + &host_delivery(&directory, &identity), + )), + ]; + // Bundle and record refusals: a missing bundle and a bundle of the wrong kind. + let missing_bundle = rig.bare_host().with( + IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + canary.join("no-bundle").to_str().unwrap(), + ); + refusals.push(refusal(rig.open(&missing_bundle))); + let file_bundle = rig + .bare_host() + .with(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, identity.to_str().unwrap()); + refusals.push(refusal(rig.open(&file_bundle))); + let record_missing = tempfile::tempdir()?; + let host = rig.bare_host().with( + IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, + record_missing.path().to_str().unwrap(), + ); + refusals.push(refusal(rig.open(&host))); + for refused in &refusals { + assert!(!refused.contains("canary-6e1f0c9a"), "{refused}"); + assert!( + !refused.contains(root.path().to_str().unwrap()), + "{refused}" + ); + assert!( + !refused.contains(record_missing.path().to_str().unwrap()), + "{refused}" + ); + } + assert!( + refusals[0].contains(IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT), + "{}", + refusals[0] + ); + assert!( + refusals[1].contains(RUNTIME_PRESENCE_IDENTITY_BINDING), + "{}", + refusals[1] + ); + Ok(()) +} + +// --------------------------------------------------------------------- custody + +#[cfg(unix)] +fn facts(kind: FileKind, uid: u32, mode: u32) -> Facts { + Facts { + kind, + uid, + gid: 0, + mode, + nlink: 1, + len: 32, + } +} + +#[cfg(unix)] +#[test] +fn custody_predicates() { + use FileKind::{Directory, Other, Regular, Symlink}; + // Root-owned read-only files (bundle records and locks). + assert!(facts(Regular, 0, 0o444).is_root_read_only_file()); + assert!(facts(Regular, 0, 0o400).is_root_read_only_file()); + assert!( + !facts(Regular, 0, 0o644).is_root_read_only_file(), + "owner-writable" + ); + assert!( + !facts(Regular, 0, 0o464).is_root_read_only_file(), + "group-writable" + ); + assert!( + !facts(Regular, 0, 0o446).is_root_read_only_file(), + "world-writable" + ); + assert!( + !facts(Regular, 1000, 0o444).is_root_read_only_file(), + "not root" + ); + assert!( + !facts(Symlink, 0, 0o444).is_root_read_only_file(), + "symlink" + ); + assert!(!facts(Directory, 0, 0o444).is_root_read_only_file()); + assert!(!facts(Other, 0, 0o444).is_root_read_only_file()); + // The bundle directory. + assert!(facts(Directory, 0, 0o555).is_root_read_only_directory()); + assert!(!facts(Directory, 0, 0o755).is_root_read_only_directory()); + assert!(!facts(Directory, 1000, 0o555).is_root_read_only_directory()); + assert!(!facts(Symlink, 0, 0o555).is_root_read_only_directory()); + assert!(!facts(Regular, 0, 0o555).is_root_read_only_directory()); + // Root-controlled files and directories (the lease and its parents). + assert!(facts(Regular, 0, 0o644).is_root_controlled_file()); + assert!( + !facts(Regular, 0, 0o664).is_root_controlled_file(), + "group-writable" + ); + assert!( + !facts(Regular, 0, 0o646).is_root_controlled_file(), + "world-writable" + ); + assert!(!facts(Regular, 1000, 0o644).is_root_controlled_file()); + assert!(!facts(Symlink, 0, 0o644).is_root_controlled_file()); + assert!(facts(Directory, 0, 0o755).is_root_controlled_directory()); + assert!(!facts(Directory, 0, 0o775).is_root_controlled_directory()); + assert!(!facts(Directory, 0, 0o757).is_root_controlled_directory()); + assert!( + !facts(Directory, 0, 0o1777).is_root_controlled_directory(), + "sticky tmp" + ); + assert!(!facts(Directory, 1000, 0o755).is_root_controlled_directory()); + assert!(!facts(Symlink, 0, 0o755).is_root_controlled_directory()); + // Inherited signers. + assert!(facts(Regular, 0, 0o400).is_signer_descriptor()); + assert!(!facts(Regular, 0, 0o440).is_signer_descriptor()); + assert!(!facts(Regular, 0, 0o600).is_signer_descriptor()); + assert!(!facts(Regular, 0, 0o4400).is_signer_descriptor(), "setuid"); + assert!(!facts(Regular, 1000, 0o400).is_signer_descriptor()); + assert!( + !Facts { + gid: 1000, + ..facts(Regular, 0, 0o400) + } + .is_signer_descriptor() + ); + assert!( + !Facts { + nlink: 2, + ..facts(Regular, 0, 0o400) + } + .is_signer_descriptor() + ); + assert!( + !Facts { + len: 0, + ..facts(Regular, 0, 0o400) + } + .is_signer_descriptor() + ); + assert!(!facts(Directory, 0, 0o400).is_signer_descriptor()); +} + +#[cfg(unix)] +#[test] +fn root_custody_reads_the_real_filesystem_and_refuses() -> Result<()> { + use std::os::unix::fs::{PermissionsExt, symlink}; + let dir = tempfile::tempdir()?; + let file = dir.path().join("record.cc"); + std::fs::write(&file, b"x")?; + let require = + |custody: Custody, path: &Path| custody.require_file(path, "record", Rule::ControlledFile); + + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o666))?; + assert!( + refusal(require(Custody::Root, &file)) + .contains("record is not a root-controlled regular file") + ); + let link = dir.path().join("link.cc"); + symlink(&file, &link)?; + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644))?; + assert!( + require(Custody::Root, &link).is_err(), + "a symlink is never custody" + ); + assert!(require(Custody::Root, Path::new("record.cc")).is_err()); + assert!(require(Custody::Root, &dir.path().join("absent")).is_err()); + assert!( + require(Custody::Profile, &link).is_err(), + "profile custody refuses a link too" + ); + assert!(require(Custody::Profile, &file).is_ok()); + + assert!( + Custody::Root + .require_directory_chain(dir.path(), "dir") + .is_err() + ); + assert!( + Custody::Root + .require_directory_chain(Path::new("relative"), "dir") + .is_err() + ); + assert!( + Custody::Profile + .require_directory_chain(dir.path(), "dir") + .is_ok() + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn the_default_open_path_enforces_root_custody() -> Result<()> { + // The rig's bundle is an ordinary writable temp directory. + let rig = Rig::new(false)?; + let mut credentials = rig.credentials(); + credentials.custody = Custody::Root; + let refused = refusal(rig.open_with(&rig.host(), TARGET, HEALTH_CONTRACT, credentials)); + assert!( + refused.contains("Idunn runtime bundle is not a root-owned read-only directory"), + "{refused}" + ); + Ok(()) +} + +#[test] +fn debug_output_names_the_target_and_never_signing_material() -> Result<()> { + let rig = Rig::new(false)?; + let shown = format!("{:?}", rig.authority()?); + assert!(shown.contains("IdunnRuntimeAuthority") && shown.contains(TARGET)); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_lease_guard_debug_names_its_digest() -> Result<()> { + let lease_rig = Rig::new(true)?; + let mut signer = lease_rig.signer()?; + let warming = signer.sign_at("w", 1_000)?; + lease_rig.write_lease(&warming.canonical_sha256, 1)?; + let guard = signer.try_acquire_write_lease()?.unwrap(); + assert!(format!("{guard:?}").contains(guard.lease_sha256())); + Ok(()) +} + +// ----------------------------------------------------- the real process, in small + +/// A descriptor number far above anything a parallel test is likely to open. +#[cfg(unix)] +fn high_duplicate(file: &File) -> RawFd { + // SAFETY: F_DUPFD duplicates a descriptor `file` owns into a fresh number. + let descriptor = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_DUPFD, 700) }; + assert!(descriptor >= 700); + descriptor +} + +#[test] +fn the_process_host_reads_the_real_process() -> Result<()> { + let mut host = ProcessHost; + assert_eq!(host.process_id(), std::process::id()); + + let name = "CULTNET_RS_PROVIDER_TEST_PROCESS_HOST"; + assert_eq!(host.var(name), None); + // SAFETY: a name no other test or thread reads or writes. + unsafe { std::env::set_var(name, "a value") }; + assert_eq!(host.var(name).as_deref(), Some("a value")); + host.remove_var(name); + assert_eq!(host.var(name), None); + assert!( + std::env::var(name).is_err(), + "removed from the real environment" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn the_process_host_adopts_real_descriptors() -> Result<()> { + let mut host = ProcessHost; + let dir = tempfile::tempdir()?; + std::fs::write(dir.path().join("adopted"), b"adopted bytes")?; + let source = File::open(dir.path().join("adopted"))?; + let descriptor = high_duplicate(&source); + let adopted = host.adopt_descriptor(descriptor)?; + assert_eq!(adopted.as_raw_fd(), descriptor); + assert_eq!(contents(adopted.try_clone()?)?, "adopted bytes"); + adopted.sync_all()?; + + let closed = high_duplicate(&source); + // SAFETY: closes the duplicate made on the previous line. + assert_eq!(unsafe { libc::close(closed) }, 0); + assert!(refusal(host.adopt_descriptor(closed)).contains("is not open")); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn facts_read_the_real_metadata() -> Result<()> { + use std::os::unix::fs::{PermissionsExt, symlink}; + let dir = tempfile::tempdir()?; + let file = dir.path().join("file"); + std::fs::write(&file, b"hello")?; + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o640))?; + let observed = Facts::of(&std::fs::symlink_metadata(&file)?); + assert_eq!(observed.kind, FileKind::Regular); + assert_eq!( + observed.mode, 0o640, + "permission bits only, not the file type" + ); + assert_eq!((observed.len, observed.nlink), (5, 1)); + // SAFETY: geteuid takes no arguments and cannot fail. + assert_eq!(observed.uid, unsafe { libc::geteuid() }); + + assert_eq!(Facts::of_path(dir.path(), "dir")?.kind, FileKind::Directory); + let link = dir.path().join("link"); + symlink(&file, &link)?; + assert_eq!(Facts::of_path(&link, "link")?.kind, FileKind::Symlink); + let socket = dir.path().join("socket"); + let _listener = std::os::unix::net::UnixListener::bind(&socket)?; + assert_eq!(Facts::of_path(&socket, "socket")?.kind, FileKind::Other); + assert!(Facts::of_path(&dir.path().join("absent"), "absent").is_err()); + Ok(()) +} + +// --------------------------------------------------------- lease failure shapes + +#[cfg(unix)] +#[test] +fn a_lease_path_that_cannot_be_inspected_is_an_error_not_an_absent_lease() -> Result<()> { + let rig = Rig::new(true)?; + // The parent is a regular file, so the lease path is ENOTDIR, not "not found". + let impossible = rig.dir.path().join("provider.cc").join("lease.cc"); + let host = rig + .bare_host() + .with(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, BIND) + .with( + IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT, + impossible.to_str().unwrap(), + ); + let mut signer = IdunnRuntimePresenceSigner::new(rig.open(&host)?, capabilities()); + assert!(signer.try_acquire_write_lease().is_err()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn the_write_lease_is_held_to_root_custody() -> Result<()> { + let rig = Rig::new(true)?; + let mut authority = rig.authority()?; + Arc::get_mut(&mut authority.records).unwrap().custody = Custody::Root; + let mut signer = IdunnRuntimePresenceSigner::new(authority, capabilities()); + let warming = signer.sign_at("w", 1_000)?; + assert!( + signer.try_acquire_write_lease()?.is_none(), + "an absent record is not yet a custody question" + ); + rig.write_lease(&warming.canonical_sha256, 1)?; + let refused = refusal(signer.try_acquire_write_lease()); + assert!(refused.contains("process-write-lease"), "{refused}"); + Ok(()) +} + +#[test] +fn sign_stamps_the_current_time() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + let before = unix_millis()?; + let signed = signer.sign("now")?; + let after = unix_millis()?; + assert!( + (before..=after).contains(&signed.record.observed_at_unix_millis), + "{before} <= {} <= {after}", + signed.record.observed_at_unix_millis + ); + assert!(before > 1_700_000_000_000, "unix_millis is the wall clock"); + Ok(()) +} + +// ------------------------------------------------------ s2 repairs, attempt 3 + +/// A lease-required runtime may claim Degraded only under a current lease, with +/// no lease ever held and after one was lost alike. +#[cfg(unix)] +#[test] +fn degraded_needs_a_current_lease_whatever_the_history() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("w", 1_000)?; + signer.report_health(RuntimePresenceState::Degraded); + assert!(signer.sign_at("never held", 1_001).is_err()); + rig.write_lease(&warming.canonical_sha256, 1)?; + signer.try_acquire_write_lease()?.expect("binds"); + assert!(signer.sign_at("held", 1_002).is_ok()); + std::fs::remove_file(rig.lease_path())?; + assert!(signer.sign_at("lost", 1_003).is_err()); + Ok(()) +} + +/// A runtime that never needed a lease reports Degraded and Active without one. +#[test] +fn degraded_and_active_sign_without_a_lease_when_none_is_required() -> Result<()> { + let rig = Rig::new(false)?; + let mut signer = rig.signer()?; + for state in [RuntimePresenceState::Degraded, RuntimePresenceState::Active] { + signer.report_health(state); + assert_eq!(signer.sign_at("up", 1_000)?.record.write_lease_sha256, None); + } + Ok(()) +} + +fn assert_no_path(refused: &str, rig: &Rig) { + assert!( + !refused.contains(rig.dir.path().to_str().unwrap()), + "{refused}" + ); + assert!( + !refused.contains(rig.dir.path().file_name().unwrap().to_str().unwrap()), + "{refused}" + ); +} + +#[test] +fn a_missing_record_lock_names_no_path() -> Result<()> { + let rig = Rig::new(false)?; + let mut lock = rig.bundle().join("expected.cc").into_os_string(); + lock.push(".lock"); + std::fs::remove_file(&lock)?; + let refused = refusal(rig.open(&rig.host())); + assert!( + refused.contains("could not be read as a CultCache store: not found"), + "{refused}" + ); + assert_no_path(&refused, &rig); + Ok(()) +} + +#[test] +fn an_undecodable_record_names_no_path() -> Result<()> { + let rig = Rig::new(false)?; + std::fs::write( + rig.bundle().join("activation.cc"), + [0xc1_u8, 0xc1, b'g', b'a'], + )?; + let refused = refusal(rig.open(&rig.host())); + assert!( + refused.contains("could not be read as a CultCache store: not decodable"), + "{refused}" + ); + assert_no_path(&refused, &rig); + Ok(()) +} + +#[test] +fn a_record_in_a_newer_store_format_says_so_and_names_no_path() -> Result<()> { + let rig = Rig::new(false)?; + // A store header this runtime does not read; the format is judged from the + // header alone, so the cut-short store is still a newer one. + let mut newer = vec![0x93_u8, 0xb2]; + newer.extend_from_slice(b"cultcache.store.v9"); + std::fs::write(rig.bundle().join("activation.cc"), newer)?; + let refused = refusal(rig.open(&rig.host())); + assert!( + refused.contains( + "could not be read as a CultCache store: in a format this runtime does not read" + ), + "{refused}" + ); + assert_no_path(&refused, &rig); + Ok(()) +} + +#[test] +fn an_io_failure_is_labelled_by_its_kind_alone() { + use std::io::{Error, ErrorKind}; + let path = "/profile/secret-dir/store.cc"; + for (kind, label) in [ + (ErrorKind::PermissionDenied, "permission denied"), + (ErrorKind::NotFound, "not found"), + (ErrorKind::TimedOut, "I/O error"), + ] { + let error = anyhow::Error::new(Error::new(kind, path)) + .context(format!("failed to open pre-created {path}.lock")); + assert_eq!(store_failure_kind(&error), label); + } + assert_eq!(store_failure_kind(&anyhow!("opaque")), "unreadable"); +} + +/// The provider's own refusals about a record still say what is wrong. +#[test] +fn a_store_of_two_records_is_still_refused_by_its_own_message() -> Result<()> { + let rig = Rig::new(false)?; + write_record( + &rig.bundle().join("expected.cc"), + "other", + IdunnExpectedIncarnationRecord::TYPE, + IDUNN_EXPECTED_INCARNATION_SCHEMA, + rig.expected.canonical_bytes()?, + )?; + let refused = refusal(rig.open(&rig.host())); + assert!(refused.contains("exactly one record"), "{refused}"); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn an_undecodable_lease_names_no_path() -> Result<()> { + let rig = Rig::new(true)?; + let mut signer = rig.signer()?; + let warming = signer.sign_at("w", 1_000)?; + rig.write_lease(&warming.canonical_sha256, 1)?; + signer.try_acquire_write_lease()?.expect("binds"); + std::fs::write(rig.lease_path(), [0xc1_u8, 0xc1, b'g', b'a'])?; + signer.report_health(RuntimePresenceState::Active); + let refused = refusal(signer.sign_at("a", 1_001)); + assert!(refused.contains("could not be read"), "{refused}"); + assert_no_path(&refused, &rig); + Ok(()) +} + +/// A provider identity that is not a regular file refuses before the one-shot +/// seed is read, so the next launch attempt still has it. +#[test] +fn host_credentials_keep_the_seed_when_the_provider_is_a_directory() -> Result<()> { + let rig = Rig::new(false)?; + let dir = tempfile::tempdir()?; + let seed = dir.path().join("activation-key"); + std::fs::write(&seed, &rig.activation_seed)?; + let provider = dir.path().join("provider-is-a-dir"); + std::fs::create_dir(&provider)?; + let refused = refusal(IdunnRuntimeCredentials::from_host_environment_in( + &host_delivery(&seed, &provider), + )); + assert!( + refused.contains(RUNTIME_PRESENCE_IDENTITY_BINDING), + "{refused}" + ); + assert!(!refused.contains(dir.path().to_str().unwrap()), "{refused}"); + assert!(seed.exists(), "a refused delivery leaves the one-shot seed"); + Ok(()) +} diff --git a/packages/cultnet-rs/src/lib.rs b/packages/cultnet-rs/src/lib.rs index 621150ba..b35bd701 100644 --- a/packages/cultnet-rs/src/lib.rs +++ b/packages/cultnet-rs/src/lib.rs @@ -7,6 +7,7 @@ mod media_stream_contracts; mod media_stream_wire; mod provider_health_contracts; mod runtime_authority_contracts; +mod idunn_runtime_provider; mod service_identity; mod snapshot_query; mod cultmesh; @@ -30,6 +31,7 @@ pub use media_stream_contracts::*; pub use media_stream_wire::*; pub use provider_health_contracts::*; pub use runtime_authority_contracts::*; +pub use idunn_runtime_provider::*; pub use service_identity::*; pub use snapshot_query::*; pub use cultmesh::*; diff --git a/packages/cultnet-rs/src/replication.rs b/packages/cultnet-rs/src/replication.rs index d29714fe..743a28fd 100644 --- a/packages/cultnet-rs/src/replication.rs +++ b/packages/cultnet-rs/src/replication.rs @@ -3,6 +3,7 @@ use anyhow::anyhow; use cultcache_rs::CultCache; use cultcache_rs::CultCacheEnvelope; use cultcache_rs::DatabaseEntry; +use cultcache_rs::SchemaConflictError; use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::Value; @@ -127,12 +128,31 @@ impl CultNetDocumentRegistry { Self::default() } - pub fn register(&mut self, binding: CultNetDocumentBinding) -> &mut Self { + /// Binds `binding`. One binding owns a document type and a schema id: a binding claiming a type or a schema id + /// another binding already holds is refused with [`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 replaces that binding. + pub fn register(&mut self, binding: CultNetDocumentBinding) -> Result<&mut Self> { + let holder = self + .bindings_by_type + .get(&binding.document_type) + .filter(|held| held.schema_id != binding.schema_id) + .or_else(|| { + self.bindings_by_schema_id + .get(&binding.schema_id) + .filter(|held| held.document_type != binding.document_type) + }); + if let Some(holder) = holder { + return Err(anyhow::Error::new(SchemaConflictError { + schema_id: binding.schema_id.clone(), + schema_names: vec![holder.document_type.clone(), binding.document_type.clone()], + record_key: String::new(), + })); + } self.bindings_by_schema_id .insert(binding.schema_id.clone(), binding.clone()); self.bindings_by_type .insert(binding.document_type.clone(), binding); - self + Ok(self) } pub fn binding(&self, document_type: &str) -> Option<&CultNetDocumentBinding> { diff --git a/packages/cultnet-rs/src/runtime_authority_contracts.rs b/packages/cultnet-rs/src/runtime_authority_contracts.rs index 0549a5c1..08cdbcf1 100644 --- a/packages/cultnet-rs/src/runtime_authority_contracts.rs +++ b/packages/cultnet-rs/src/runtime_authority_contracts.rs @@ -21,6 +21,22 @@ pub const ODIN_RUNTIME_TOPOLOGY_CORRELATION_SCHEMA: &str = "odin.runtime_topolog pub const ODIN_RUNTIME_TOPOLOGY_CORRELATION_SIGNING_PURPOSE: &str = "odin.runtime_topology_correlation.v2"; +/// Names the runtime bundle directory Idunn launched this process with. Set by +/// both Idunn drivers; read by `IdunnRuntimeAuthority::open`. +pub const IDUNN_RUNTIME_BUNDLE_ENVIRONMENT: &str = "GAMECULT_IDUNN_RUNTIME_BUNDLE"; +/// Names the `host:port` Idunn expects the candidate to bind. Set by Idunn's +/// systemd driver for routed targets. +pub const IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT: &str = "GAMECULT_IDUNN_CANDIDATE_BIND"; +/// Names the process write-lease record of a stateful target. Set by Idunn's +/// systemd driver exactly when Expected requires a lease. +pub const IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT: &str = "GAMECULT_IDUNN_PROCESS_WRITE_LEASE"; +/// Names the one-shot activation credential file. Set by Idunn's host driver. +pub const IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT: &str = "GAMECULT_IDUNN_ACTIVATION_CREDENTIAL"; +/// Names the stable provider identity credential file. Set by Idunn's host driver. +pub const RUNTIME_PRESENCE_IDENTITY_BINDING: &str = "GAMECULT_RUNTIME_PRESENCE_IDENTITY"; +/// The name Idunn's systemd driver gives the stable provider identity descriptor. +pub const RUNTIME_PRESENCE_IDENTITY_FD_NAME: &str = "gamecult-runtime-presence-identity"; + const MAX_RUNTIME_CAPABILITIES: usize = 256; const MAX_RUNTIME_DEPENDENCIES: usize = 256; const MAX_TOPOLOGY_DISAGREEMENTS: usize = 256; diff --git a/packages/cultnet-rs/tests/content.rs b/packages/cultnet-rs/tests/content.rs index 178ebe3c..732d7d09 100644 --- a/packages/cultnet-rs/tests/content.rs +++ b/packages/cultnet-rs/tests/content.rs @@ -547,6 +547,7 @@ fn chunk_ref(hash: &str, offset: i64, size: i32) -> cultnet_rs::CultMeshCdnChunk offset, size_bytes: size, record_key: String::new(), + id: offset.to_string(), } } diff --git a/packages/cultnet-rs/tests/cultnet.rs b/packages/cultnet-rs/tests/cultnet.rs index df348cee..64c57aaf 100644 --- a/packages/cultnet-rs/tests/cultnet.rs +++ b/packages/cultnet-rs/tests/cultnet.rs @@ -1773,7 +1773,7 @@ fn document_registry_advertises_mutation_contracts_with_bindings() -> Result<()> "ghostlight.agent_state.v0".to_string(), ) .with_mutation_contract(contract.clone()), - ); + )?; assert_eq!(registry.mutation_contracts(), vec![contract]); Ok(()) @@ -1842,7 +1842,7 @@ fn document_registry_replicates_typed_cultcache_state() -> Result<()> { let mut registry = CultNetDocumentRegistry::new(); registry.register(CultNetDocumentBinding::for_entry::< GhostlightAgentStateFixture, - >("ghostlight.agent_state.v0".to_string())); + >("ghostlight.agent_state.v0".to_string()))?; let mut origin = CultCache::new(); origin.register_entry_type::()?; @@ -1899,7 +1899,7 @@ fn raw_snapshot_replication_preserves_messagepack_payload_bytes() -> Result<()> let mut registry = CultNetDocumentRegistry::new(); registry.register(CultNetDocumentBinding::for_entry::< GhostlightAgentStateFixture, - >("ghostlight.agent_state.v0".to_string())); + >("ghostlight.agent_state.v0".to_string()))?; let mut origin = CultCache::new(); origin.register_entry_type::()?; @@ -1945,7 +1945,7 @@ fn serve_read_only_raw_snapshot_omitted_v0_schema_list_is_no_filter_present_empt let mut registry = CultNetDocumentRegistry::new(); registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< GhostlightAgentStateFixture, - >("ghostlight.agent-state".to_string(), None)); + >("ghostlight.agent-state".to_string(), None))?; let mut policy = CultNetReadOnlySnapshotPolicy::new(); policy.allow("ghostlight.agent-state", "row-1")?; @@ -2039,7 +2039,7 @@ fn serve_read_only_raw_snapshot_keeps_the_requested_record_key_order() -> Result let mut registry = CultNetDocumentRegistry::new(); registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< GhostlightAgentStateFixture, - >("ghostlight.agent-state".to_string(), None)); + >("ghostlight.agent-state".to_string(), None))?; let mut policy = CultNetReadOnlySnapshotPolicy::new(); policy.allow("ghostlight.agent-state", "row-1")?; @@ -2099,10 +2099,64 @@ fn serve_read_only_raw_snapshot_keeps_the_requested_record_key_order() -> Result } #[test] -fn raw_snapshot_replication_hydrates_same_schema_rust_aliases() -> Result<()> { +fn document_registry_binds_one_type_per_schema_id_and_refuses_a_second_typed() -> Result<()> { + let canonical = |schema_id: &str| { + CultNetDocumentBinding::for_entry_with_schema_id::(schema_id.to_string(), None) + }; + let ui = |schema_id: &str| { + CultNetDocumentBinding::for_entry_with_schema_id::(schema_id.to_string(), None) + }; + let canonical_type = GhostlightAgentStateFixture::TYPE.to_string(); + let ui_type = GhostlightAgentStateUiFixture::TYPE.to_string(); + + // Another type for a held schema id, and a held type for another schema id, in both registration orders. + let cases = [ + (canonical("id.agent"), ui("id.agent"), "id.agent", [&canonical_type, &ui_type]), + (ui("id.agent"), canonical("id.agent"), "id.agent", [&ui_type, &canonical_type]), + (canonical("id.agent"), canonical("id.other"), "id.other", [&canonical_type, &canonical_type]), + (canonical("id.other"), canonical("id.agent"), "id.agent", [&canonical_type, &canonical_type]), + ]; + for (first, second, schema_id, names) in cases { + let mut registry = CultNetDocumentRegistry::new(); + registry.register(first.clone())?; + let error = registry.register(second.clone()).err().expect("a second claimant is refused"); + let conflict = error + .downcast_ref::() + .unwrap_or_else(|| panic!("{error:#}")); + assert_eq!(conflict.schema_id, schema_id); + assert_eq!(conflict.schema_names, names.map(|name| name.clone()).to_vec()); + assert_eq!(conflict.record_key, ""); + assert!(error.to_string().contains(schema_id), "{error:#}"); + // The refused binding left nothing behind: the first binding still answers for its type and its schema id. + assert_eq!(registry.binding(&first.document_type).map(|held| held.schema_id.as_str()), Some(first.schema_id.as_str())); + assert_eq!( + registry.binding_by_schema_id(&first.schema_id).map(|held| held.document_type.as_str()), + Some(first.document_type.as_str()) + ); + if second.schema_id != first.schema_id { + assert!(registry.binding_by_schema_id(&second.schema_id).is_none()); + } + } + + // A type bound again to the schema id it holds replaces its binding. + let mut registry = CultNetDocumentRegistry::new(); + registry.register(canonical("id.agent"))?; + registry.register(CultNetDocumentBinding::for_entry_with_schema_id::( + "id.agent".to_string(), + Some("ghostlight.agent_state.v2".to_string()), + ))?; + assert_eq!( + registry.binding(&canonical_type).and_then(|held| held.payload_schema_version.as_deref()), + Some("ghostlight.agent_state.v2") + ); + Ok(()) +} + +#[test] +fn raw_snapshot_replication_hydrates_the_receivers_own_type_for_the_schema() -> Result<()> { let temp = tempfile::tempdir()?; - let origin_store = temp.path().join("origin-alias.msgpack"); - let target_store = temp.path().join("target-alias.msgpack"); + let origin_store = temp.path().join("origin.msgpack"); + let target_store = temp.path().join("target.msgpack"); let canonical = GhostlightAgentStateFixture { schema_version: "ghostlight.agent_state.v0".to_string(), agent_id: "epiphany.persona".to_string(), @@ -2115,7 +2169,7 @@ fn raw_snapshot_replication_hydrates_same_schema_rust_aliases() -> Result<()> { >( "ghostlight.agent_state.v0".to_string(), "ghostlight.agent_state.v0".to_string(), - )); + ))?; let mut origin = CultCache::new(); origin.register_entry_type::()?; origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store))?; @@ -2123,24 +2177,24 @@ fn raw_snapshot_replication_hydrates_same_schema_rust_aliases() -> Result<()> { origin.put("epiphany.persona", &canonical)?; let raw_snapshot = origin_registry.create_raw_snapshot_response( &origin, - "raw-snapshot-alias", + "raw-snapshot-receiver-type", Some(&["ghostlight.agent_state.v0".to_string()]), Some(&["epiphany.persona".to_string()]), )?; - let mut alias_registry = CultNetDocumentRegistry::new(); - alias_registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< + let mut target_registry = CultNetDocumentRegistry::new(); + target_registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< GhostlightAgentStateUiFixture, >( "ghostlight.agent_state.v0".to_string(), "ghostlight.agent_state.v0".to_string(), - )); + ))?; let mut target = CultCache::new(); target.register_entry_type::()?; target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store))?; target.pull_all_backing_stores()?; - let applied = alias_registry + let applied = target_registry .sync_raw_document_from_snapshot_response::( &mut target, &raw_snapshot, @@ -2163,16 +2217,16 @@ fn raw_snapshot_replication_hydrates_same_schema_rust_aliases() -> Result<()> { } #[test] -fn reactive_document_coalesces_direct_same_schema_alias_member_writes() -> Result<()> { +fn reactive_document_coalesces_direct_member_writes() -> Result<()> { let temp = tempfile::tempdir()?; - let target_store = temp.path().join("target-reactive-alias.msgpack"); + let target_store = temp.path().join("target-reactive.msgpack"); let mut registry = CultNetDocumentRegistry::new(); registry.register(CultNetDocumentBinding::for_entry_with_schema_id::< GhostlightReactiveNoteUiFixture, >( "ghostlight.reactive_note.v0".to_string(), "ghostlight.reactive_note.v0".to_string(), - )); + ))?; let cache = Arc::new(Mutex::new(CultCache::new())); { let mut cache = cache.lock().expect("cache mutex"); @@ -2237,7 +2291,7 @@ fn reactive_document_tracks_canonical_reconciliation_delta() -> Result<()> { >( "ghostlight.reactive_note.v0".to_string(), "ghostlight.reactive_note.v0".to_string(), - )); + ))?; let cache = Arc::new(Mutex::new(CultCache::new())); { let mut cache = cache.lock().expect("cache mutex"); diff --git a/packages/cultnet-rs/tests/error_message.rs b/packages/cultnet-rs/tests/error_message.rs index 5c633087..06e25935 100644 --- a/packages/cultnet-rs/tests/error_message.rs +++ b/packages/cultnet-rs/tests/error_message.rs @@ -158,6 +158,36 @@ fn cases() -> Vec { }), }, }, + Case { + name: "write_not_authorized", + // C#: ForRemoteWriteRefused(new CultNetRemoteWriteRequest(null, Put, "some.schema.v1", new CultRecordKey("some:key"), null, null), "not granted") + csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0", + message: || CultNetMessage::Error { + error: "write_not_authorized: not granted".to_string(), + code: Some(CultNetErrorCode::WriteNotAuthorized), + details: Some(CultNetErrorDetails { + field: Some("recordKey".to_string()), + value: Some("some:key".to_string()), + as_of: None, + current: None, + }), + }, + }, + Case { + name: "unsupported_schema_version", + // C#: ForUnsupportedSchemaVersion("cultnet.hello.v1") + csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0", + message: || CultNetMessage::Error { + error: "unsupported_schema_version: cultnet.hello.v1".to_string(), + code: Some(CultNetErrorCode::UnsupportedSchemaVersion), + details: Some(CultNetErrorDetails { + field: Some("schemaVersion".to_string()), + value: Some("cultnet.hello.v1".to_string()), + as_of: None, + current: None, + }), + }, + }, ] } @@ -196,3 +226,22 @@ fn round_trip_is_stable() { assert_eq!(decoded, message, "{}: round trip mismatch", case.name); } } + +/// `contracts/cultnet/interop/error-vectors.json` is the file the TypeScript and Python runtimes +/// decode; it must stay byte-identical to the inline C# captures above, or those runtimes would be +/// pinned to bytes this crate no longer pins. +#[test] +fn error_vectors_file_matches_the_inline_csharp_captures() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../contracts/cultnet/interop/error-vectors.json"); + let file: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).expect("read error-vectors.json")) + .expect("parse error-vectors.json"); + let vectors = file["vectors"].as_array().expect("vectors array"); + let all = cases(); + assert_eq!(vectors.len(), all.len(), "vector count differs from the inline cases"); + for (vector, case) in vectors.iter().zip(all.iter()) { + assert_eq!(vector["name"].as_str(), Some(case.name)); + assert_eq!(vector["messagePackHex"].as_str(), Some(case.csharp_hex), "{}", case.name); + } +} diff --git a/packages/cultnet-rs/tests/first_call.rs b/packages/cultnet-rs/tests/first_call.rs new file mode 100644 index 00000000..e2ef1e6f --- /dev/null +++ b/packages/cultnet-rs/tests/first_call.rs @@ -0,0 +1,77 @@ +//! The Rust CultNet first call from docs/start/README.md. The docs show the region between the +//! `snippet` markers verbatim, and `docs_show_the_tested_snippet` fails when they differ. + +use cultcache_rs::{CultCache, DatabaseEntry}; + +// snippet:begin +use std::net::{TcpListener, TcpStream}; +use std::thread; + +use cultnet_rs::{ + CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract, + TcpFramedTransportConnection, create_tcp_framed_transport_profile, + decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, +}; + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "note")] +struct Note { + #[cultcache(key = 0)] + title: String, + #[cultcache(key = 1)] + body: String, +} + +fn first_call() -> anyhow::Result { + let mut documents = CultNetDocumentRegistry::new(); + documents.register(CultNetDocumentBinding::for_entry_with_schema_id::( + "gamecult.first_call_note.v1".to_string(), + "gamecult.first_call_note.v1".to_string(), + ))?; + let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default()); + + // Receiver: a loopback listener that applies the document put it receives to its own cache. + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + let receiver = thread::spawn({ + let (documents, profile) = (documents.clone(), profile("receiver")); + move || -> anyhow::Result { + let (stream, _) = listener.accept()?; + let frame = TcpFramedTransportConnection::new(stream, profile).receive()?; + let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?; + let mut inbox = CultCache::new(); + inbox.register_entry_type::()?; + documents.apply_raw_document_put_message::(&mut inbox, &message) + } + }); + + // Sender: build a typed put message, encode it for the wire, send one frame. + let put = documents.create_raw_document_put_message( + "put-1", + "note:hello", + &Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() }, + CultNetDocumentPutOptions::default(), + )?; + let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?; + TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender")) + .send("schema", &bytes)?; + + receiver.join().expect("receiver thread") +} +// snippet:end + +#[test] +fn a_typed_document_crosses_loopback_into_a_second_cache() { + let note = first_call().unwrap(); + assert_eq!(note, Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() }); +} + +#[test] +fn docs_show_the_tested_snippet() { + let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/first_call.rs")).unwrap(); + let begin = source.find("// snippet:begin\n").unwrap() + "// snippet:begin\n".len(); + let end = source.find("// snippet:end\n").unwrap(); + let docs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/start/README.md")).unwrap(); + let block = format!("```rust\n{}```", &source[begin..end]); + assert!(docs.contains(&block), "docs/start/README.md no longer shows tests/first_call.rs verbatim"); +} diff --git a/packages/cultnet-ts/CHANGELOG.md b/packages/cultnet-ts/CHANGELOG.md index 052d075a..64ee5a3b 100644 --- a/packages/cultnet-ts/CHANGELOG.md +++ b/packages/cultnet-ts/CHANGELOG.md @@ -1,8 +1,27 @@ # Changelog -All notable changes to `cultnet-ts` are recorded here, following +All notable changes to `@gamecult/cultnet-ts` are recorded here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## Unreleased + +### Added + +- `cultNetWriteNotAuthorizedError(recordKey, reason?)`: the typed `write_not_authorized` refusal (code and `details.recordKey`), byte-identical to the C# `ForRemoteWriteRefused` vector in `error-vectors.json`. `CultNetDocumentRegistry.canonicalSchemaId(schemaId)` resolves the id a put or delete writes under, for a server's write authority. +- `cultNetTextError(error)`: the text-only `cultnet.error.v0` refusal, with `routingHint`, `code` and `details` null. The legacy-union error decode uses it, so there is one place that spells the null members. +- `CultNetRudpSocketTransportServer`: one UDP socket serving many peers, each remote address and port with its own reliable session (`peerConnected`, `frame`, `peerDisconnected` events; `send(peer, ...)` answers on the peer a frame came from). A second client no longer replaces the first; Connects beyond `maxPeers` (default 256) are dropped, and `close()` resolves once every peer's goodbye has left the socket. The shape follows the Rust `CultNetRudpServerHub` and C# `CultNetRudpSocketTransportServer`, and the wire format is unchanged. The optional `peerTimeoutMs` (idle peers ended and told so; default off) exists only in TypeScript. `CultNetRudpSocketTransportConnection` in `server` mode still serves exactly one peer. + +- The `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1` and `cultnet.snapshot_response_raw.v1` carriers and the selection payload types (`CultNetSelection`, `CultNetRawDocumentHeader`, `CultNetEdge`). `parseCultNetMessage` accepts them (absent members arrive as absent keys; a page carries exactly one of `headers`/`documents`; an empty or blank `schemas`/`keys` entry throws `CultNetSelectionInvalidError` naming its field), and `encodeCultNetMessageForWire` writes the reference's nil-keyed, declaration-ordered map. Judged against bytes the C# reference wrote (`contracts/cultnet/interop/carrier-vectors.cs-written.json`) and the selection vectors C# and Rust wrote. The selection engine (evaluation, cursors, the declaration-dependent door checks) is not ported. + +### Breaking + +- The package is renamed `cultnet-ts` to `@gamecult/cultnet-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultnet-ts` and its subpaths; the API is unchanged. +- `CultNetErrorMessage` carries `routingHint`, `code` and `details` (null when absent), as the reference writes them and `cultnet.error.schema.json` requires; the interface previously dropped them. New `CultNetErrorCode`, `CultNetErrorDetails` and `CultNetShardRoutingHint` types. + +- `CultMeshCdnChunkRef` carries `id` (slot 4), the reference's element id. `encodeManifest` writes five-slot chunk references and `packContent` sets the decimal offset as the id, as the reference does; a manifest written before ids reads `""` and is rewritten with the slot. Content vectors regenerated from the reference. + +- Needs the cultcache-ts release that ships with this change (its manifest range moves with that release): the Idunn runtime authority reader calls `readSingleFileStore`, which 0.14.x does not export. An absent authority file is now a plain `Error` without a code. + ## [0.3.0] ### Added diff --git a/packages/cultnet-ts/README.md b/packages/cultnet-ts/README.md index ca0db88d..1fb37a3f 100644 --- a/packages/cultnet-ts/README.md +++ b/packages/cultnet-ts/README.md @@ -1,6 +1,6 @@ # CultNet TS -`cultnet-ts` is the TypeScript port of the useful shape of GameCult's +`@gamecult/cultnet-ts` is the TypeScript port of the useful shape of GameCult's networking layer: typed MessagePack messages, direct-pipe framing, CultLib-style security/session helpers, and state replication that hands typed values into a `CultCache` instead of making consumer code paw raw database envelopes. @@ -21,7 +21,7 @@ The library currently includes: - `CultCacheTS` replication helpers for document put/delete/snapshot flows - schema discovery catalog messages and a registry for asking what contracts a peer can actually exchange safely -- RUDP socket transport, reconnect policy helpers, and +- RUDP socket transport (`CultNetRudpSocketTransportServer` serves many peers from one socket), reconnect policy helpers, and `CultNetRudpReconnectLoop` for controller-owned retry scheduling around a caller-owned socket factory - generated TypeScript contracts and runtime verifiers from canonical JSON @@ -42,7 +42,7 @@ We want: So the split is: - `cultcache-ts`: typed local persistence -- `cultnet-ts`: typed transport, auth, and state replication +- `@gamecult/cultnet-ts`: typed transport, auth, and state replication Same family. Different organs. Nobody needs a second spine growing out of the transport layer. @@ -65,6 +65,7 @@ Current message families: - `cultnet.snapshot_request.v0` - `cultnet.snapshot_response.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.sample.change_name.v0` - `cultnet.sample.chat.v0` @@ -79,7 +80,7 @@ CultNet without inventing a second ontology first. ## Generated Swarm Contracts -`cultnet-ts` can consume canonical JSON Schema files and generate: +`@gamecult/cultnet-ts` can consume canonical JSON Schema files and generate: - TypeScript contract classes/interfaces - runtime schema verifiers @@ -110,7 +111,7 @@ which exports: ## Wire Contracts -`cultnet-ts` does not sniff random payloads and pretend that counts as rigor. +`@gamecult/cultnet-ts` does not sniff random payloads and pretend that counts as rigor. You choose the contract explicitly. Current wire-contract options: @@ -128,7 +129,7 @@ away from the C# lineage. ## Content -`cultnet-ts/content` owns the TypeScript shapes of the CultMesh CDN +`@gamecult/cultnet-ts/content` owns the TypeScript shapes of the CultMesh CDN (`src/content.ts`, the twin of `CultMeshCdn.cs`): the artifact manifest and chunk reference, their MessagePack form, hash normalization, both record-key grammars, SHA-256 hashing through WebCrypto, and `packContent`. It is browser-safe: it @@ -143,7 +144,9 @@ imports no `node:` module, so a browser bundle can take it. - The decoder reads what MessagePack-CSharp reads and refuses what it refuses: nil in a string, tag or metadata slot is `null` and is written back as nil, a missing trailing slot takes the reference's default, extra slots and trailing - bytes are skipped, a float-encoded integer is refused, and invalid UTF-8 + bytes are skipped (a chunk reference's fifth slot is its element id, a string + or nil, `""` when absent; `packContent` writes the decimal offset as the id, as the + reference does), a float-encoded integer is refused, and invalid UTF-8 decodes to U+FFFD. A nil `chunks` is refused, as the reference's `ValidateManifest` refuses it. `content-acceptance-vectors.json` is the reference's own verdict on each of those shapes. @@ -157,7 +160,7 @@ imports no `node:` module, so a browser bundle can take it. ## Local Fast Lane For direct-pipe neighbors that already share the same CultCache formatter -contract, `cultnet-ts` now has a raw replication lane: +contract, `@gamecult/cultnet-ts` now has a raw replication lane: - `cultnet.document_put_raw.v0` - `cultnet.snapshot_response_raw.v0` @@ -182,7 +185,7 @@ cuts out the dumbest part of the trip. Apps sharing a CultNet space should be able to ask each other what they can exchange without resorting to folklore. -`cultnet-ts` now includes: +`@gamecult/cultnet-ts` now includes: - `cultnet.schema_catalog_request.v0` - `cultnet.schema_catalog_response.v0` @@ -229,7 +232,7 @@ import { createTcpFramedCultNetPeer, defineCultNetDocumentBinding, ghostlightAgentStateGeneratedContract, -} from "cultnet-ts"; +} from "@gamecult/cultnet-ts"; const ghostlightStateDefinition = defineDocumentType({ type: "ghostlight.agent-state", @@ -267,7 +270,7 @@ That means a TypeScript runtime can follow the same cryptographic and session story as the C# version instead of inventing one more house religion around "trust me, this pipe is probably fine." -For the legacy `gamecult.networking.v0` contract, `cultnet-ts` normalizes the +For the legacy `gamecult.networking.v0` contract, `@gamecult/cultnet-ts` normalizes the encrypted binary fields as base64url strings in the ergonomic TS surface and maps them back to the exact C# union payload shape at the wire boundary. diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index 1fc6abed..60c9e13f 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -1,5 +1,5 @@ { - "name": "cultnet-ts", + "name": "@gamecult/cultnet-ts", "version": "0.3.0", "description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.", "main": "dist/index.js", @@ -73,7 +73,7 @@ "dependencies": { "@msgpack/msgpack": "^3.1.3", "ajv": "^8.17.1", - "@gamecult/cultcache-ts": "^0.14.1" + "@gamecult/cultcache-ts": "^0.15.0" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultnet-ts/src/content.ts b/packages/cultnet-ts/src/content.ts index 6ff1e107..6f9fd873 100644 --- a/packages/cultnet-ts/src/content.ts +++ b/packages/cultnet-ts/src/content.ts @@ -26,6 +26,8 @@ export interface CultMeshCdnChunkRef { readonly offset: number; readonly sizeBytes: number; readonly recordKey: string | null; + // Element id, slot 4: the reference derives it from the offset when packing; "" when a manifest written before ids omits it. + readonly id: string | null; } // Metadata is ordered entries, never an object: a JS object reorders integer-like keys, and the entry order is wire bytes. @@ -142,13 +144,13 @@ const mapHeader = (count: number): Uint8Array => /** A string slot is written as C# writes it: a lone surrogate has no UTF-8 encoding and becomes U+FFFD at every length. */ const text = (field: T): T => (typeof field === "string" ? wellFormed(field) : field) as T; -/** The positional ten-array of the C# `[MessagePackObject]`: chunk references are four-arrays, metadata a map in entry order. */ +/** The positional ten-array of the C# `[MessagePackObject]`: chunk references are five-arrays, metadata a map in entry order. */ export function encodeManifest(manifest: CultMeshCdnArtifactManifest): Uint8Array { const { artifactId, kind, version, contentHash, sizeBytes, mimeType, createdAtUtc, chunks, tags, metadata } = manifest; return concat([ Uint8Array.of(0x9a), ...[artifactId, kind, version, contentHash, sizeBytes, mimeType, createdAtUtc].map(field => encode(text(field))), - encode(chunks.map(c => [text(c.chunkHash), c.offset, c.sizeBytes, text(c.recordKey)])), + encode(chunks.map(c => [text(c.chunkHash), c.offset, c.sizeBytes, text(c.recordKey), text(c.id)])), encode(tags === null ? null : tags.map(text)), ...(metadata === null ? [encode(null)] : [mapHeader(metadata.length), ...metadata.flatMap(([key, value]) => [encode(text(key)), encode(text(value))])]), ]); @@ -279,6 +281,7 @@ function readChunkRef(reader: Reader): CultMeshCdnChunkRef { // The reference's `int`. sizeBytes: slot(() => { const size = reader.integer("chunks.sizeBytes"); return size >= -(2 ** 31) && size < 2 ** 31 ? size : wrongType("chunks.sizeBytes"); }, 0), recordKey: slot(() => reader.string("chunks.recordKey"), ""), + id: slot(() => reader.string("chunks.id"), ""), }; for (; index < count; index += 1) reader.skip(); return chunk; @@ -308,7 +311,7 @@ export async function packContent( sizeBytes: bytes.length, mimeType: isNullOrWhiteSpaceCSharp(options.mimeType) ? "application/octet-stream" : options.mimeType!, createdAtUtc: options.createdAtUtc ?? `${new Date().toISOString().slice(0, 23)}0000+00:00`, - chunks: chunks.map(({ chunkHash, payload }, index) => ({ chunkHash, offset: offsets[index]!, sizeBytes: payload.length, recordKey: chunkRecordKey(chunkHash) })), + chunks: chunks.map(({ chunkHash, payload }, index) => ({ chunkHash, offset: offsets[index]!, sizeBytes: payload.length, recordKey: chunkRecordKey(chunkHash), id: String(offsets[index]) })), tags: options.tags ?? [], metadata: options.metadata ?? [], }; diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index 31188790..bc6a7b72 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -1,3 +1,4 @@ +import { isNullOrWhiteSpaceCSharp } from "./cultmesh-authority"; import type { DocumentPayload } from "@gamecult/cultcache-ts/document"; import Ajv2020, { type ValidateFunction } from "ajv/dist/2020.js"; @@ -33,8 +34,25 @@ import verseCatalogRequestSchema from "../contracts/cultnet/cultmesh.verse-catal import verseCatalogResponseSchema from "../contracts/cultnet/cultmesh.verse-catalog-response.schema.json"; import sessionOpenSchema from "../contracts/cultnet/cultmesh.session-open.schema.json"; import sessionAcceptedSchema from "../contracts/cultnet/cultmesh.session-accepted.schema.json"; +import selectionSchema from "../contracts/cultnet/cultnet.selection.schema.json"; +import rawDocumentHeaderSchema from "../contracts/cultnet/cultnet.raw-document-header.schema.json"; +import snapshotRequestV1Schema from "../contracts/cultnet/cultnet.snapshot-request.v1.schema.json"; +import snapshotResponseRawV1Schema from "../contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json"; +import databaseSubscribeV1Schema from "../contracts/cultnet/cultnet.database-subscribe.v1.schema.json"; import ghostlightAgentStateSchema from "../contracts/cultnet/ghostlight.agent-state.schema.json"; +import { + encodeSelectionCarrierForWire, + normalizeSelectionCarrierNulls, + type CultNetSelectionCarrier, +} from "./selection-carriers"; +import { + validateSelectionShape, + type CultNetEdge, + type CultNetRawDocumentHeader, + type CultNetSelection, +} from "./selection"; + export type CultNetWireContract = "cultnet.schema.v0" | "gamecult.networking.v0"; export type CultNetSchemaVersion = @@ -53,6 +71,9 @@ export type CultNetSchemaVersion = | "cultnet.snapshot_response.v0" | "cultnet.snapshot_response_raw.v0" | "cultnet.database_subscribe.v0" + | "cultnet.snapshot_request.v1" + | "cultnet.snapshot_response_raw.v1" + | "cultnet.database_subscribe.v1" | "cultnet.database_unsubscribe.v0" | "cultnet.database_change_raw.v0" | "cultnet.schema_catalog_request.v0" @@ -165,9 +186,58 @@ export interface CultNetLoginSuccessMessage { session: string; } +/** The closed refusal-code set of cultnet.error.v0 (contracts/cultnet/cultnet.error.schema.json). */ +export const CULTNET_ERROR_CODES = [ + "selection_invalid", + "cursor_stale", + "cursor_invalid", + "reference_outside_target", + "variant_unsupported", + "unowned_schema", + "write_not_authorized", + "unsupported_schema_version", +] as const; + +export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number]; + +/** A refusal that carries text only: `routingHint`, `code` and `details` are written as null, as the reference writes an absent member. */ +export function cultNetTextError(error: string): CultNetErrorMessage { + return { schemaVersion: "cultnet.error.v0", error, routingHint: null, code: null, details: null }; +} + +/** + * The refusal for a peer write its server's authority did not grant (C# `CultNetErrorMessage.ForRemoteWriteRefused`): + * code `write_not_authorized`, `details` naming the record in `recordKey`. A blank `reason` leaves the text bare. + */ +export function cultNetWriteNotAuthorizedError(recordKey: string, reason?: string | null): CultNetErrorMessage { + return { + schemaVersion: "cultnet.error.v0", + error: typeof reason === "string" && !isNullOrWhiteSpaceCSharp(reason) ? `write_not_authorized: ${reason}` : "write_not_authorized", + routingHint: null, + code: "write_not_authorized", + details: { field: "recordKey", value: recordKey, asOf: null, current: null }, + }; +} + +export interface CultNetErrorRoutingHint { + shard: Record | null; + reason: string; +} + +export interface CultNetErrorDetails { + field: string | null; + value: string | null; + asOf: number | null; + current: number | null; +} + +/** Every key is written on the wire, null included (the C# map formatter never omits a key). */ export interface CultNetErrorMessage { schemaVersion: "cultnet.error.v0"; error: string; + routingHint: CultNetErrorRoutingHint | null; + code: CultNetErrorCode | null; + details: CultNetErrorDetails | null; } export interface CultNetSampleChangeNameMessage { @@ -247,6 +317,46 @@ export interface CultNetDatabaseSubscribeMessage { supportedBodyTransports?: string[]; } +/** A document snapshot through a typed selection (docs/cultnet-selection-cut.md). */ +export interface CultNetSnapshotRequestV1Message { + schemaVersion: "cultnet.snapshot_request.v1"; + messageId: string; + selection: CultNetSelection; + shardId?: string; + shardEpoch?: number; +} + +/** A live subscription through a typed selection. */ +export interface CultNetDatabaseSubscribeV1Message { + schemaVersion: "cultnet.database_subscribe.v1"; + messageId: string; + subscriptionId: string; + selection: CultNetSelection; + /** Default true. */ + includeSnapshot?: boolean; + consumerRuntimeId?: string; + bodyIds?: string[]; + supportedBodyTransports?: string[]; +} + +/** One selection page: exactly one of `headers` / `documents` is present, by projection. */ +export interface CultNetSnapshotResponseRawV1Message { + schemaVersion: "cultnet.snapshot_response_raw.v1"; + messageId: string; + /** Rows on this page. */ + matched: number; + /** The snapshot this page is exact as of. */ + asOf: number; + /** Cursor for the next page, absent on the last page. */ + next?: string; + headers?: CultNetRawDocumentHeader[]; + documents?: CultNetRawDocumentRecord[]; + edges?: CultNetEdge[]; + shardId?: string; + shardEpoch?: number; + shardLogSequence?: number; +} + export interface CultNetDatabaseUnsubscribeMessage { schemaVersion: "cultnet.database_unsubscribe.v0"; messageId: string; @@ -659,6 +769,9 @@ export type CultNetMessage = | CultNetSnapshotResponseMessage | CultNetSnapshotResponseRawMessage | CultNetDatabaseSubscribeMessage + | CultNetSnapshotRequestV1Message + | CultNetSnapshotResponseRawV1Message + | CultNetDatabaseSubscribeV1Message | CultNetDatabaseUnsubscribeMessage | CultNetDatabaseChangeRawMessage | CultNetSchemaCatalogRequestMessage @@ -693,6 +806,9 @@ const CULTNET_MESSAGE_SCHEMAS = [ snapshotResponseSchema, snapshotResponseRawSchema, databaseSubscribeSchema, + snapshotRequestV1Schema, + snapshotResponseRawV1Schema, + databaseSubscribeV1Schema, databaseUnsubscribeSchema, databaseChangeRawSchema, schemaCatalogRequestSchema, @@ -711,6 +827,8 @@ const cultNetValidators = new Map(); for (const schema of [ documentRecordSchema, rawDocumentRecordSchema, + rawDocumentHeaderSchema, + selectionSchema, documentMutationContractSchema, transportProfileSchema, schemaDescriptorSchema, @@ -757,6 +875,20 @@ export function parseCultNetMessage(input: unknown, wireContract: CultNetWireCon return input as CultNetSnapshotResponseRawMessage; } + if (schemaVersion === "cultnet.snapshot_response_raw.v1") { + validateSnapshotResponseRawV1Message(input); + return input as CultNetSnapshotResponseRawV1Message; + } + + if (schemaVersion === "cultnet.snapshot_request.v1" || schemaVersion === "cultnet.database_subscribe.v1") { + // The door first, so an empty or blank schemas/keys entry is a typed refusal naming its field + // rather than a generic schema-validation failure. + const selection = (input as { selection?: unknown }).selection; + if (selection && typeof selection === "object") { + validateSelectionShape(selection as CultNetSelection); + } + } + if (schemaVersion === "cultnet.database_change_raw.v0") { validateDatabaseChangeRawMessage(input); return input as CultNetDatabaseChangeRawMessage; @@ -778,7 +910,14 @@ export function encodeCultNetMessageForWire( return encodeGameCultNetworkingMessage(message); } - return message; + switch (message.schemaVersion) { + case "cultnet.snapshot_request.v1": + case "cultnet.database_subscribe.v1": + case "cultnet.snapshot_response_raw.v1": + return encodeSelectionCarrierForWire(message as CultNetSelectionCarrier); + default: + return message; + } } export function validateGhostlightAgentState(input: unknown): GhostlightAgentStateDocument { @@ -839,10 +978,8 @@ function parseGameCultNetworkingMessage(input: unknown): CultNetMessage { session: requireLegacyBytes(payload[1], "LoginSuccessMessage.Session"), }; case 4: - return { - schemaVersion: "cultnet.error.v0", - error: requireLegacyString(payload[0], "ErrorMessage.Error"), - }; + // The legacy union ErrorMessage carries only Error; the R-N fields exist on cultnet.schema.v0 alone. + return cultNetTextError(requireLegacyString(payload[0], "ErrorMessage.Error")); case 5: return { schemaVersion: "cultnet.sample.change_name.v0", @@ -898,6 +1035,7 @@ function encodeGameCultNetworkingMessage(message: CultNetMessage): [number, unkn decodeBase64Url(message.session, "LoginSuccessMessage.Session"), ]]; case "cultnet.error.v0": + // The gamecult.networking.v0 union's ErrorMessage carries only the text. return [4, [message.error]]; case "cultnet.sample.change_name.v0": return [5, [message.name]]; @@ -1102,6 +1240,89 @@ function validateSnapshotResponseRawMessage( }); } +function validateSnapshotResponseRawV1Message( + input: unknown, +): asserts input is CultNetSnapshotResponseRawV1Message { + const candidate = validateBinaryMessageRoot(input, "cultnet.snapshot_response_raw.v1"); + const fail = (path: string, problem: string): never => { + throw new Error(`Validation failed for cultnet.snapshot_response_raw.v1: ${path}: ${problem}`); + }; + for (const counter of ["matched", "asOf"] as const) { + const value = candidate[counter]; + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + fail(`/${counter}`, "must be a non-negative integer"); + } + } + if (typeof candidate.next !== "undefined" && typeof candidate.next !== "string") { + fail("/next", "must be a string"); + } + const hasHeaders = typeof candidate.headers !== "undefined"; + const hasDocuments = typeof candidate.documents !== "undefined"; + if (hasHeaders === hasDocuments) { + fail("/", "exactly one of headers and documents must be present"); + } + if (hasHeaders) { + if (!Array.isArray(candidate.headers)) fail("/headers", "must be an array"); + (candidate.headers as unknown[]).forEach((header, index) => validateRawDocumentHeader(header, `/headers/${index}`)); + } else { + if (!Array.isArray(candidate.documents)) fail("/documents", "must be an array"); + (candidate.documents as unknown[]).forEach((document, index) => validateRawDocumentRecord(document, `/documents/${index}`)); + } + if (typeof candidate.edges !== "undefined") { + if (!Array.isArray(candidate.edges)) fail("/edges", "must be an array"); + (candidate.edges as unknown[]).forEach((edge, index) => validateEdge(edge, `/edges/${index}`)); + } + requireOptionalNonEmptyString(candidate.shardId, "/shardId"); + for (const counter of ["shardEpoch", "shardLogSequence"] as const) { + const value = candidate[counter]; + if (typeof value !== "undefined" && (typeof value !== "number" || !Number.isSafeInteger(value))) { + fail(`/${counter}`, "must be an integer"); + } + } +} + +function validateRawDocumentHeader(input: unknown, instancePath: string): void { + if (!input || typeof input !== "object") { + throw new Error(`Validation failed for raw CultNet document header: ${instancePath}: must be an object`); + } + const candidate = input as Record; + requireNonEmptyString(candidate.schemaId, `${instancePath}/schemaId`); + requireOptionalNonEmptyString(candidate.schemaName, `${instancePath}/schemaName`); + requireOptionalNonEmptyString(candidate.schemaVersion, `${instancePath}/schemaVersion`); + requireOptionalNonEmptyString(candidate.schemaContentHash, `${instancePath}/schemaContentHash`); + requireNonEmptyString(candidate.recordKey, `${instancePath}/recordKey`); + requireNonEmptyString(candidate.storedAt, `${instancePath}/storedAt`); + requireOptionalNonEmptyString(candidate.sourceRuntimeId, `${instancePath}/sourceRuntimeId`); + requireOptionalNonEmptyString(candidate.sourceAgentId, `${instancePath}/sourceAgentId`); + requireOptionalNonEmptyString(candidate.sourceRole, `${instancePath}/sourceRole`); + requireOptionalStringArray(candidate.tags, `${instancePath}/tags`); +} + +function validateEdge(input: unknown, instancePath: string): void { + const fail = (path: string, problem: string): never => { + throw new Error(`Validation failed for cultnet.snapshot_response_raw.v1: ${path}: ${problem}`); + }; + if (!input || typeof input !== "object") { + fail(instancePath, "must be an object"); + } + const candidate = input as Record; + for (const end of ["from", "to"] as const) { + const ref = candidate[end] as Record | undefined; + if (!ref || typeof ref !== "object") { + fail(`${instancePath}/${end}`, "must be an object"); + } + requireNonEmptyString(ref!.schemaId, `${instancePath}/${end}/schemaId`); + requireNonEmptyString(ref!.recordKey, `${instancePath}/${end}/recordKey`); + } + requireNonEmptyString(candidate.role, `${instancePath}/role`); + if (typeof candidate.payloadEncoding !== "undefined" && candidate.payloadEncoding !== "messagepack") { + fail(`${instancePath}/payloadEncoding`, "must equal messagepack"); + } + if (typeof candidate.payload !== "undefined" && !(candidate.payload instanceof Uint8Array)) { + fail(`${instancePath}/payload`, "must be a Uint8Array"); + } +} + function validateDatabaseChangeRawMessage( input: unknown, ): asserts input is CultNetDatabaseChangeRawMessage { @@ -1134,6 +1355,7 @@ function validateBinaryMessageRoot( schemaVersion: | "cultnet.document_put_raw.v0" | "cultnet.snapshot_response_raw.v0" + | "cultnet.snapshot_response_raw.v1" | "cultnet.database_change_raw.v0", ): Record { if (!input || typeof input !== "object") { @@ -1236,6 +1458,11 @@ function normalizeCultNetOptionalNulls( "supportedBodyTransports", ]); return; + case "cultnet.snapshot_request.v1": + case "cultnet.database_subscribe.v1": + case "cultnet.snapshot_response_raw.v1": + normalizeSelectionCarrierNulls(schemaVersion, candidate); + return; case "cultnet.operation_request.v0": stripNullProperties(candidate, ["payloadEncoding", "sourceRuntimeId", "targetRuntimeId"]); return; @@ -1305,6 +1532,11 @@ export const cultNetSchemas = { snapshotResponseSchema, snapshotResponseRawSchema, databaseSubscribeSchema, + selectionSchema, + rawDocumentHeaderSchema, + snapshotRequestV1Schema, + snapshotResponseRawV1Schema, + databaseSubscribeV1Schema, databaseUnsubscribeSchema, databaseChangeRawSchema, schemaDescriptorSchema, diff --git a/packages/cultnet-ts/src/cultmesh-authority.ts b/packages/cultnet-ts/src/cultmesh-authority.ts index c260cf70..9d1c7438 100644 --- a/packages/cultnet-ts/src/cultmesh-authority.ts +++ b/packages/cultnet-ts/src/cultmesh-authority.ts @@ -39,8 +39,8 @@ export interface CultMeshAuthorityRouteCertificate { /** * Maps an advertised route's wire-shaped certificate to the view * `verifyAuthorityRoute` and the transports consume. Shared by the QUIC - * realtime session manager (`cultmesh-ts`) and the browser WebSocket client - * (`cultmesh-browser`), which advertise the same `CultMeshAuthorityRouteMessage` + * realtime session manager (`@gamecult/cultmesh-ts`) and the browser WebSocket client + * (`@gamecult/cultmesh-browser`), which advertise the same `CultMeshAuthorityRouteMessage` * certificate shape and were computing this mapping independently. */ export function routeCertificateView( diff --git a/packages/cultnet-ts/src/idunn-runtime-authority.ts b/packages/cultnet-ts/src/idunn-runtime-authority.ts index 2ec5456c..0ca7f27b 100644 --- a/packages/cultnet-ts/src/idunn-runtime-authority.ts +++ b/packages/cultnet-ts/src/idunn-runtime-authority.ts @@ -1,7 +1,8 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { documentPayloadFromBytes } from "@gamecult/cultcache-ts/document"; +import { readSingleFileStore } from "@gamecult/cultcache-ts"; +import { documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts/document"; import { decode, encode } from "@msgpack/msgpack"; import { @@ -28,7 +29,6 @@ export const IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT = "GAMECULT_IDUNN_CANDIDAT const PROVIDER_ID_DOMAIN = Buffer.from("gamecult.provider-health.identity.v1\0", "utf8"); const PROVIDER_PROTECTOR_CONTEXT = "gamecult-provider-health-identity-v1"; const ACTIVATION_ID_DOMAIN = Buffer.from("idunn.runtime-activation.id.v1\0", "utf8"); -const CULTCACHE_STORE_FORMAT = "cultcache.store.v1"; const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); // Limit: more than this many warmings signed between Idunn's observation and // assertWriteLease would push the leased one out and refuse a valid lease. @@ -319,25 +319,13 @@ function deepFreeze(value: T): T { } function readAuthorityRecord(filePath: string, type: string, schemaId: string): { payload: Uint8Array } { - const decoded = decode(fs.readFileSync(filePath)); - if (!Array.isArray(decoded) || decoded[0] !== CULTCACHE_STORE_FORMAT || !Array.isArray(decoded[1]) || !Array.isArray(decoded[2])) { - throw new Error(`Idunn authority file ${filePath} is not a CultCache v1 store.`); - } - if (decoded[2].length !== 1) throw new Error(`Idunn authority file ${filePath} must contain exactly one record.`); - const matchingRecords = (decoded[2] as unknown[]).filter((raw) => { - const row = array(raw, 4, "CultCache record"); - return row[1] === schemaId; - }); - if (matchingRecords.length !== 1) throw new Error(`Idunn authority file must contain exactly one ${schemaId} record.`); - const row = array(matchingRecords[0], 4, "CultCache record"); - const catalog = (decoded[1] as unknown[]).find((raw) => { - const entry = Array.isArray(raw) ? raw : []; - return entry[0] === schemaId; - }); - const catalogEntry = array(catalog, 7, "CultCache schema catalog entry"); - if (catalogEntry[1] !== type) throw new Error(`Idunn authority record has unexpected CultCache type ${String(catalogEntry[1])}.`); - if (!(row[3] instanceof Uint8Array)) throw new Error("Idunn authority payload is not MessagePack bytes."); - return { payload: row[3] }; + const store = readSingleFileStore(filePath); + if (store.absent) throw new Error(`Idunn authority file ${filePath} does not exist.`); + if (store.envelopes.length !== 1) throw new Error(`Idunn authority file ${filePath} must contain exactly one record.`); + const record = store.envelopes[0]!; + if (record.schemaId !== schemaId) throw new Error(`Idunn authority file must contain exactly one ${schemaId} record.`); + if (record.type !== type) throw new Error(`Idunn authority record has unexpected CultCache type ${record.type}.`); + return { payload: documentPayloadBytes(record.payload) }; } function decodeExpected(payload: Uint8Array): ExpectedIncarnation { diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 1e856822..94199d02 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -3,6 +3,9 @@ export { encodeCultNetMessageForWire, parseCultNetMessage, validateGhostlightAgentState, + type CultNetDatabaseSubscribeV1Message, + type CultNetSnapshotRequestV1Message, + type CultNetSnapshotResponseRawV1Message, type CultNetDocumentDeleteMessage, type CultNetDocumentMutationContract, type CultNetDocumentPutMessage, @@ -13,7 +16,13 @@ export { type CultNetDatabaseChangeRawMessage, type CultNetRawDocumentRecord, type CultNetRawPayloadEncoding, + CULTNET_ERROR_CODES, + cultNetTextError, + cultNetWriteNotAuthorizedError, + type CultNetErrorCode, + type CultNetErrorDetails, type CultNetErrorMessage, + type CultNetErrorRoutingHint, type CultNetHelloMessage, type CultNetLoginMessage, type CultNetLoginSuccessMessage, @@ -62,6 +71,19 @@ export { type GhostlightVariableMap, type GhostlightWorldState, } from "./contracts"; +export { + CultNetSelectionInvalidError, + validateSelectionShape, + type CultNetCitation, + type CultNetEdge, + type CultNetFieldPredicate, + type CultNetIncoming, + type CultNetRawDocumentHeader, + type CultNetRecordRef, + type CultNetSelection, + type CultNetSelectionOperator, + type CultNetSelectionProjection, +} from "./selection"; export { base64ToBytes, bytesToBase64, @@ -123,6 +145,12 @@ export { type CultNetRudpSessionOptions, type RudpTransportProfileOptions, } from "./rudp"; +export { + CultNetRudpSocketTransportServer, + type CultNetRudpSocketServerFrame, + type CultNetRudpSocketServerOptions, + type CultNetRudpSocketServerPeer, +} from "./rudp-server"; export { invokeCultNetOperation, startCultNetOperationServer, diff --git a/packages/cultnet-ts/src/replication.ts b/packages/cultnet-ts/src/replication.ts index e12eaf8e..32ee4163 100644 --- a/packages/cultnet-ts/src/replication.ts +++ b/packages/cultnet-ts/src/replication.ts @@ -1,15 +1,16 @@ import type { AnyCultCacheDocumentDefinition, CultCache, - CultCacheDocumentDefinition, CultCacheDocumentValue, CultCacheEnvelope, } from "@gamecult/cultcache-ts"; import { + SchemaConflictError, decodeDocumentPayload, documentPayloadBytes, documentPayloadFromBytes, encodeDocumentPayload, + schemaIdentityOf, } from "@gamecult/cultcache-ts"; import { @@ -42,7 +43,9 @@ export function defineCultNetDocumentBinding< export class CultNetDocumentRegistry { readonly #bindings = new Map(); - readonly #schemaBindings = new Map(); + // The binding that owns each schema id, and every binding listing an id as compatible, in registration order. + readonly #owners = new Map(); + readonly #listers = new Map(); constructor(bindings: Iterable = []) { for (const binding of bindings) { @@ -51,8 +54,32 @@ export class CultNetDocumentRegistry { } register(binding: CultNetDocumentBinding): this { + // One definition owns a type, a schema id and a schema name, derived as CultCache derives them. Another definition + // claiming any of them is refused, so a schema never resolves to two types. + const identity = schemaIdentityOf(binding.definition); + for (const holder of this.#bindings.values()) { + if (holder.definition === binding.definition) { + continue; + } + const held = schemaIdentityOf(holder.definition); + const claimed = + holder.definition.type === binding.definition.type ? `type "${binding.definition.type}"` + : held.schemaId === identity.schemaId ? `schema id "${identity.schemaId}"` + : held.schemaName === identity.schemaName ? `schema name "${identity.schemaName}"` + : undefined; + if (claimed) { + throw new SchemaConflictError(identity.schemaId, [held.schemaName, identity.schemaName], "", + `CultNet ${claimed} is already bound to type "${holder.definition.type}"; type "${binding.definition.type}" (schema id "${identity.schemaId}") cannot also be bound.`); + } + } this.#bindings.set(binding.definition.type, binding); - this.#schemaBindings.set(schemaIdForBinding(binding), binding); + this.#owners.set(identity.schemaId, binding); + for (const schemaId of identity.compatibleSchemaIds) { + const listers = this.#listers.get(schemaId) ?? []; + if (schemaId !== identity.schemaId && !listers.some(lister => lister.definition === binding.definition)) { + this.#listers.set(schemaId, [...listers, binding]); + } + } return this; } @@ -60,16 +87,22 @@ export class CultNetDocumentRegistry { return this.#bindings.get(documentType); } + // The binding a record under this schema id belongs to: the id's owner, else the one binding that lists it as + // compatible. An id nothing declares resolves to nothing. An id that several bindings list and none owns is refused, + // as the cache refuses it, whatever order the bindings were registered in. getBySchemaId(schemaId: string): CultNetDocumentBinding | undefined { - const exact = this.#schemaBindings.get(schemaId); - if (exact) { - return exact; + const owner = this.#owners.get(schemaId); + if (owner) { + return owner; } - - const requestedSchemaIds = new Set([schemaId]); - return this.#uniqueBindings().find(binding => - schemaMatchesBinding(schemaIdForBinding(binding), binding, requestedSchemaIds), - ); + const listers = this.#listers.get(schemaId) ?? []; + if (listers.length > 1) { + const types = listers.map(lister => lister.definition.type).sort(); + throw new SchemaConflictError(schemaId, listers.map(lister => schemaIdentityOf(lister.definition).schemaName).sort(), "", + `CultNet schema id "${schemaId}" is owned by no bound type and declared compatible by several (${types.join(", ")}), ` + + "so it resolves to none. Bind the type that owns the id, or declare it on one type."); + } + return listers[0]; } createDocumentPutMessage( @@ -94,10 +127,11 @@ export class CultNetDocumentRegistry { recordKey: documentKey, storedAt: options.storedAt ?? new Date().toISOString(), payload: parsed, - sourceRuntimeId: options.sourceRuntimeId, - sourceAgentId: options.sourceAgentId, - sourceRole: options.sourceRole, - tags: options.tags, + // Absent optional fields must be omitted, not set to undefined: MessagePack encodes undefined as nil and the peer rejects it. + ...(options.sourceRuntimeId !== undefined && { sourceRuntimeId: options.sourceRuntimeId }), + ...(options.sourceAgentId !== undefined && { sourceAgentId: options.sourceAgentId }), + ...(options.sourceRole !== undefined && { sourceRole: options.sourceRole }), + ...(options.tags !== undefined && { tags: options.tags }), }, }; } @@ -169,7 +203,7 @@ export class CultNetDocumentRegistry { const binding = this.#requireBinding(envelope.type); const schemaId = schemaIdForEnvelope(envelope, binding); - if (requestedSchemaIds && !schemaMatchesBinding(schemaId, binding, requestedSchemaIds)) { + if (requestedSchemaIds && !answersTo(schemaId, binding, requestedSchemaIds)) { continue; } @@ -206,7 +240,7 @@ export class CultNetDocumentRegistry { const binding = this.#requireBinding(envelope.type); const schemaId = schemaIdForEnvelope(envelope, binding); - if (requestedSchemaIds && !schemaMatchesBinding(schemaId, binding, requestedSchemaIds)) { + if (requestedSchemaIds && !answersTo(schemaId, binding, requestedSchemaIds)) { continue; } @@ -236,6 +270,20 @@ export class CultNetDocumentRegistry { ); } + /** + * The canonical schema id a put or delete naming `schemaId` writes under, from the id alone, or `undefined` when no + * binding takes it (or several claim it). A server asks its write authority about this id, not the alias a peer sent. + */ + canonicalSchemaId(schemaId: string): string | undefined { + try { + const binding = this.getBySchemaId(schemaId); + return binding ? schemaIdForBinding(binding) : undefined; + } catch (error) { + if (error instanceof SchemaConflictError) return undefined; + throw error; + } + } + async applyDocumentDeleteMessage( cache: CultCache, message: CultNetDocumentDeleteMessage, @@ -274,16 +322,13 @@ export class CultNetDocumentRegistry { binding: CultNetDocumentBinding; envelope: CultCacheEnvelope; } { - const resolution = this.#resolveRawDocumentBinding(message.document); - const { binding } = resolution; + const binding = this.#requireSchemaBinding(message.document.schemaId); return { binding, envelope: { key: message.document.recordKey, type: binding.definition.type, - schemaId: resolution.preserveIncomingSchemaId - ? message.document.schemaId - : schemaIdForBinding(binding), + schemaId: schemaIdForBinding(binding), payload: documentPayloadFromBytes(new Uint8Array(documentPayloadBytes(message.document.payload))), storedAt: message.document.storedAt, }, @@ -334,35 +379,6 @@ export class CultNetDocumentRegistry { return binding; } - #resolveRawDocumentBinding(document: CultNetRawDocumentRecord): { - binding: CultNetDocumentBinding; - preserveIncomingSchemaId: boolean; - } { - const exact = this.getBySchemaId(document.schemaId); - if (exact) { - return { binding: exact, preserveIncomingSchemaId: true }; - } - - const payload = documentPayloadFromBytes(new Uint8Array(documentPayloadBytes(document.payload))); - for (const binding of this.#uniqueBindings()) { - try { - decodeDocumentPayload(binding.definition, payload); - return { binding, preserveIncomingSchemaId: false }; - } catch { - // Keep looking; a foreign schema id is acceptable only when the payload validates locally. - } - } - - throw new Error(`No CultNet document binding is registered for schema "${document.schemaId}".`); - } - - #uniqueBindings(): CultNetDocumentBinding[] { - return [...new Set([ - ...this.#schemaBindings.values(), - ...this.#bindings.values(), - ])]; - } - #createRawDocumentRecord(envelope: CultCacheEnvelope): CultNetRawDocumentRecord { const binding = this.#requireBinding(envelope.type); return { @@ -376,43 +392,17 @@ export class CultNetDocumentRegistry { } function schemaIdForBinding(binding: CultNetDocumentBinding): string { - return binding.definition.schemaId ?? binding.definition.type; + return schemaIdentityOf(binding.definition).schemaId; } -function schemaMatchesBinding( +// A held record matches a snapshot filter when the filter names the id it carries or any id its binding answers to. +function answersTo( schemaId: string, binding: CultNetDocumentBinding, requestedSchemaIds: Set, ): boolean { - if (requestedSchemaIds.has(schemaId)) { - return true; - } - - const definition = binding.definition; - if (definition.schemaId && requestedSchemaIds.has(definition.schemaId)) { - return true; - } - if (definition.schemaName && requestedSchemaIds.has(definition.schemaName)) { - return true; - } - if (definition.schemaVersion && requestedSchemaIds.has(definition.schemaVersion)) { - return true; - } - if (definition.compatibleSchemaIds?.some(candidate => requestedSchemaIds.has(candidate))) { - return true; - } - - return Array.from(requestedSchemaIds) - .some(candidate => inferSchemaName(candidate) === definition.schemaName); -} - -function inferSchemaName(schemaId: string): string | undefined { - const marker = schemaId.lastIndexOf(".v"); - if (marker <= 0 || marker + 2 >= schemaId.length) { - return undefined; - } - const version = schemaId.slice(marker + 2); - return /^\d+$/u.test(version) ? schemaId.slice(0, marker) : undefined; + return requestedSchemaIds.has(schemaId) + || schemaIdentityOf(binding.definition).compatibleSchemaIds.some(candidate => requestedSchemaIds.has(candidate)); } function schemaIdForEnvelope( diff --git a/packages/cultnet-ts/src/rudp-server.ts b/packages/cultnet-ts/src/rudp-server.ts new file mode 100644 index 00000000..37e8695a --- /dev/null +++ b/packages/cultnet-ts/src/rudp-server.ts @@ -0,0 +1,390 @@ +import { EventEmitter } from "node:events"; +import { type RemoteInfo, type Socket } from "node:dgram"; + +import type { CultNetTransportProfile } from "./contracts"; +import { + CultNetRudpSession, + MAX_UDP4_DATAGRAM_BYTES, + MAX_UDP6_DATAGRAM_BYTES, + RUDP_FIXED_HEADER_BYTES, + channelOptions, + createRudpTransportProfile, + decodeRudpPacket, + encodeRudpPacket, + isPermanentSendError, + sendRudpDatagram, + type CultNetRudpPacket, +} from "./rudp"; +import type { CultNetReconnectPolicy, CultNetTransportFrame, CultNetTransportStats } from "./transport"; + +export interface CultNetRudpSocketServerOptions { + runtimeId: string; + /** A bound UDP socket. The server owns it from here: `close()` closes it. */ + socket: Socket; + connectionId: number; + /** The first local sequence of every peer session. Unset, each session draws its own. */ + initialSequence?: number; + resendDelayMs?: number; + resendPollMs?: number; + transportId?: string; + maxPayloadBytes?: number; + maxFragmentBytes?: number; + maxPendingReliablePackets?: number; + reconnectPolicy?: CultNetReconnectPolicy; + /** A Connect from an endpoint beyond this many peers is dropped. Default 256. */ + maxPeers?: number; + /** + * A peer that has sent nothing for this long is ended and told so. TypeScript only: the + * Rust and C# servers never reap. Default `Infinity`, which keeps idle peers until they leave. + */ + peerTimeoutMs?: number; + /** How long `close()` waits for the goodbyes to leave the socket. Default 1000. */ + closeGraceMs?: number; +} + +/** One remote endpoint's session. A peer that reconnects from the same endpoint is a new object. */ +export interface CultNetRudpSocketServerPeer { + readonly remoteHost: string; + readonly remotePort: number; + /** The exact bytes of the Connect that opened this peer's session. */ + readonly connectPayload: Uint8Array; + readonly connected: boolean; + readonly outstandingReliablePacketCount: number; +} + +export interface CultNetRudpSocketServerFrame extends CultNetTransportFrame { + peer: CultNetRudpSocketServerPeer; +} + +type PeerEntry = { + peer: CultNetRudpSocketServerPeer; + session: CultNetRudpSession; + key: string; +}; + +/** The goodbye for a session that refused a packet; built after the reset, or its ack would acknowledge the refused frame. */ +function refusedGoodbye(session: CultNetRudpSession): CultNetRudpPacket { + session.resetPeerState(); + return session.createDisconnect(utf8("session refused a packet")); +} + +const utf8 = (text: string) => new TextEncoder().encode(text); + +/** + * One UDP socket serving many peers. Every remote address and port gets its own reliable + * session, opened by its Connect: a second client never takes the first one's session, and + * a client that reconnects from the same endpoint replaces only its own. The wire format is + * the single-peer transport's; the shape matches the Rust `CultNetRudpServerHub` and the C# + * `CultNetRudpSocketTransportServer`. + * + * Reply to a peer with `send(peer, ...)` on the peer a frame arrived from. + * + * Events: `peerConnected` (peer), `frame` ({ peer, channelId, payload }), `pong` ({ peer, payload }), + * `peerDisconnected` ({ peer, reason }), `close`, `error`. + */ +export class CultNetRudpSocketTransportServer extends EventEmitter { + readonly profile: CultNetTransportProfile; + readonly #socket: Socket; + readonly #options: CultNetRudpSocketServerOptions; + readonly #entries = new Map(); + readonly #timer: NodeJS.Timeout; + readonly #maxDatagramBytes: number; + readonly #maxPeers: number; + readonly #peerTimeoutMs: number; + #closed = false; + #closing = false; + readonly #stats: CultNetTransportStats = { + bytesReceived: 0, + bytesSent: 0, + framesReceived: 0, + framesSent: 0, + packetsDropped: 0, + sendFailures: 0, + }; + + constructor(options: CultNetRudpSocketServerOptions) { + super(); + this.#options = options; + this.#socket = options.socket; + this.#maxPeers = options.maxPeers ?? 256; + this.#peerTimeoutMs = options.peerTimeoutMs ?? Infinity; + if (!Number.isInteger(this.#maxPeers) || this.#maxPeers < 1) { + throw new Error("RUDP server maxPeers must be a positive integer."); + } + const address = this.#socket.address(); + this.#maxDatagramBytes = + typeof address !== "string" && String(address.family).endsWith("6") ? MAX_UDP6_DATAGRAM_BYTES : MAX_UDP4_DATAGRAM_BYTES; + this.profile = createRudpTransportProfile(options.runtimeId, { + transportId: options.transportId, + host: typeof address === "string" ? undefined : address.address, + port: typeof address === "string" ? undefined : address.port, + maxPayloadBytes: options.maxPayloadBytes, + maxFragmentBytes: options.maxFragmentBytes, + maxPendingReliablePackets: options.maxPendingReliablePackets, + reconnectPolicy: options.reconnectPolicy, + }); + + this.#socket.on("message", (wire, remote) => this.#receiveDatagram(wire, remote)); + this.#socket.on("close", () => { + if (this.#closed) return; + this.#closed = true; + clearInterval(this.#timer); + this.#entries.clear(); + this.emit("close"); + }); + this.#socket.on("error", (error) => this.emit("error", error instanceof Error ? error : new Error(String(error)))); + this.#timer = setInterval(() => this.#tick(), options.resendPollMs ?? 25); + this.#timer.unref?.(); + } + + /** The peers with a live session. */ + get peers(): CultNetRudpSocketServerPeer[] { + return [...this.#entries.values()].map((entry) => entry.peer); + } + + get stats(): CultNetTransportStats { + return { ...this.#stats }; + } + + /** Sends to one peer. Throws when that peer's session has ended or was replaced. */ + send(peer: CultNetRudpSocketServerPeer, channelId: string, payload: Uint8Array): void { + const entry = this.#liveEntry(peer); + const largestPayload = + this.#options.maxFragmentBytes === undefined ? payload.byteLength : Math.min(payload.byteLength, this.#options.maxFragmentBytes); + if (RUDP_FIXED_HEADER_BYTES + Buffer.byteLength(channelId, "utf8") + largestPayload > this.#maxDatagramBytes) { + throw Object.assign(new Error("RUDP datagram is larger than UDP can carry."), { code: "EMSGSIZE" }); + } + const packets = entry.session.sendMany(channelId, payload, { + ...channelOptions(channelId), + nowMs: Date.now(), + maxFragmentBytes: this.#options.maxFragmentBytes, + }); + for (const packet of packets) { + const thrown = this.#transmit(entry, packet); + if (thrown) throw thrown; + } + this.#stats.framesSent += 1; + } + + /** Ends one peer's session, telling it why. */ + disconnect(peer: CultNetRudpSocketServerPeer, reason: Uint8Array = new Uint8Array()): void { + const entry = this.#liveEntry(peer); + this.#endPeer(entry, entry.session.createDisconnect(Uint8Array.from(reason))); + } + + /** + * Ends every peer and releases the socket once the goodbyes have left it (bounded by + * `closeGraceMs`, default 1000): closing the socket at once would drop them. + */ + async close(): Promise { + if (this.#closed) return; + this.#closing = true; + clearInterval(this.#timer); + const sent = [...this.#entries.values()].map((entry) => + this.#endPeer(entry, entry.session.createDisconnect(utf8("server closed")))); + let bound: NodeJS.Timeout | undefined; + await Promise.race([ + Promise.all(sent), + new Promise((resolve) => { bound = setTimeout(resolve, this.#options.closeGraceMs ?? 1_000); }), + ]); + clearTimeout(bound); + if (this.#closed) return; + this.#closed = true; + this.#socket.close(); + } + + #liveEntry(peer: CultNetRudpSocketServerPeer): PeerEntry { + const entry = this.#entries.get(`${peer.remoteHost}:${peer.remotePort}`); + if (!entry || entry.peer !== peer) { + throw new Error("RUDP peer is not connected."); + } + return entry; + } + + /** Removes the peer, tells the caller, and says goodbye on the wire. Idempotent per entry. */ + #endPeer(entry: PeerEntry, goodbye: CultNetRudpPacket): Promise { + if (this.#entries.get(entry.key) !== entry) return Promise.resolve(); + this.#entries.delete(entry.key); + this.emit("peerDisconnected", { peer: entry.peer, reason: goodbye.payload }); + // Best-effort: the session is over whether or not the peer hears it. + const wire = encodeRudpPacket(goodbye); + this.#stats.bytesSent += wire.length; + return new Promise((resolve) => { + try { + this.#socket.send(wire, entry.peer.remotePort, entry.peer.remoteHost, () => resolve()); + } catch { + resolve(); + } + }); + } + + #receiveDatagram(wire: Buffer, remote: RemoteInfo): void { + if (this.#closing) return; + this.#stats.bytesReceived += wire.length; + // What a datagram carries is the sender's business: a malformed frame, another + // session's connection id and a sender no Connect admitted are dropped and counted. + let packet: CultNetRudpPacket; + try { + packet = decodeRudpPacket(wire); + } catch { + this.#stats.packetsDropped += 1; + return; + } + if (packet.connectionId !== this.#options.connectionId) { + this.#stats.packetsDropped += 1; + return; + } + const key = `${remote.address}:${remote.port}`; + const existing = this.#entries.get(key); + + if (packet.packetType === "connect") { + this.#receiveConnect(packet, remote, key, existing); + return; + } + if (!existing) { + this.#stats.packetsDropped += 1; + return; + } + this.#receiveFromPeer(existing, packet); + } + + #receiveConnect(packet: CultNetRudpPacket, remote: RemoteInfo, key: string, existing: PeerEntry | undefined): void { + if (existing?.session.connectRepeats(packet)) { + // A repeat of the Connect it accepted: the Accept owed, nothing restarted. + try { + this.#sendInPoll(existing, existing.session.acceptConnect(packet, Date.now())); + } catch { + this.#stats.packetsDropped += 1; + this.#endPeer(existing, refusedGoodbye(existing.session)); + } + return; + } + if (!existing && this.#entries.size >= this.#maxPeers) { + this.#stats.packetsDropped += 1; + return; + } + const session = new CultNetRudpSession({ + connectionId: this.#options.connectionId, + initialSequence: this.#options.initialSequence, + resendDelayMs: this.#options.resendDelayMs, + maxPendingReliablePackets: this.#options.maxPendingReliablePackets, + }); + let accept: CultNetRudpPacket; + try { + accept = session.acceptConnect(packet, Date.now()); + } catch { + this.#stats.packetsDropped += 1; + return; + } + const entry: PeerEntry = { + key, + session, + peer: { + remoteHost: remote.address, + remotePort: remote.port, + connectPayload: Uint8Array.from(packet.payload ?? []), + get connected() { return session.connected; }, + get outstandingReliablePacketCount() { return session.outstandingReliablePacketCount; }, + }, + }; + if (this.#transmit(entry, accept)) { + // The peer cannot be answered, so no session starts. + this.#stats.packetsDropped += 1; + return; + } + if (existing) { + // Same endpoint, new Connect generation: only this peer's old session ends. + this.#entries.delete(key); + this.emit("peerDisconnected", { peer: existing.peer, reason: utf8("replaced by a new Connect generation") }); + } + this.#entries.set(key, entry); + this.emit("peerConnected", entry.peer); + } + + #receiveFromPeer(entry: PeerEntry, packet: CultNetRudpPacket): void { + const session = entry.session; + let result: ReturnType; + try { + result = session.receive(packet, Date.now()); + } catch { + // receive() has already recorded the packet's reliable sequence, so the session + // cannot be kept: a retransmit would be acknowledged and the frame silently lost. + this.#stats.packetsDropped += 1; + this.#endPeer(entry, refusedGoodbye(session)); + return; + } + try { + if (result.reply) this.#sendInPoll(entry, result.reply); + for (const ready of result.readyToSend ?? []) this.#sendInPoll(entry, ready); + if (result.pong) { + this.emit("pong", { peer: entry.peer, payload: result.pongPayload ?? new Uint8Array() }); + } + for (const frame of result.delivered) { + this.#stats.framesReceived += 1; + this.emit("frame", { peer: entry.peer, channelId: frame.channelId, payload: frame.payload }); + } + if (result.disconnected) { + if (this.#entries.get(entry.key) === entry) { + this.#entries.delete(entry.key); + this.emit("peerDisconnected", { peer: entry.peer, reason: result.disconnectReason ?? new Uint8Array(0) }); + } + return; + } + if (this.#entries.get(entry.key) === entry && (packet.packetType === "data" || result.delivered.length > 0)) { + this.#sendInPoll(entry, session.createAckForReceived(packet.sequence)); + } + } catch (error) { + this.emit("error", error instanceof Error ? error : new Error(String(error))); + } + } + + #tick(): void { + const now = Date.now(); + for (const entry of [...this.#entries.values()]) { + if (this.#entries.get(entry.key) !== entry) continue; + if (entry.session.checkTimeout(now, this.#peerTimeoutMs)) { + this.#endPeer(entry, entry.session.createDisconnect(utf8("session timed out"))); + continue; + } + const generation = entry.session.generation; + for (const packet of entry.session.dueResends(now)) { + this.#sendInPoll(entry, packet); + // A resend that can never be sent ended the session and what it owed. + if (entry.session.generation !== generation || this.#entries.get(entry.key) !== entry) break; + } + } + } + + /** A send with no caller to hand a failure to: it ends that peer, never the poll. */ + #sendInPoll(entry: PeerEntry, packet: CultNetRudpPacket): void { + const thrown = this.#transmit(entry, packet); + if (thrown) this.#sendFailed(entry, thrown); + } + + /** Sends one packet to the peer and returns the failure the send threw, if any. */ + #transmit(entry: PeerEntry, packet: CultNetRudpPacket): Error | undefined { + const wire = encodeRudpPacket(packet); + this.#stats.bytesSent += wire.length; + let thrown: Error | undefined; + let sending = true; + const generation = entry.session.generation; + sendRudpDatagram(this.#socket, wire, entry.peer.remotePort, entry.peer.remoteHost, (error) => { + if (sending) { + thrown = error; + } else if (entry.session.generation === generation) { + this.#sendFailed(entry, error); + } + }); + sending = false; + return thrown; + } + + /** A datagram that can never be sent ends that peer; any other failure is a lost datagram. */ + #sendFailed(entry: PeerEntry, error: Error): void { + if (isPermanentSendError(error)) { + this.#endPeer(entry, entry.session.endUnsendableSession(error)); + return; + } + this.#stats.sendFailures += 1; + } +} diff --git a/packages/cultnet-ts/src/rudp.ts b/packages/cultnet-ts/src/rudp.ts index e68293d3..89939ef3 100644 --- a/packages/cultnet-ts/src/rudp.ts +++ b/packages/cultnet-ts/src/rudp.ts @@ -29,11 +29,11 @@ import { const RUDP_MAGIC = [0x43, 0x4e, 0x52, 0x30] as const; // CNR0 const RUDP_VERSION = 0; -const RUDP_FIXED_HEADER_BYTES = 36; +export const RUDP_FIXED_HEADER_BYTES = 36; /// The largest UDP payload a datagram can carry: 65,535 less the UDP header and, /// over IPv4, the minimal IP header. IPv6's header is outside its payload length. -const MAX_UDP4_DATAGRAM_BYTES = 65_507; -const MAX_UDP6_DATAGRAM_BYTES = 65_527; +export const MAX_UDP4_DATAGRAM_BYTES = 65_507; +export const MAX_UDP6_DATAGRAM_BYTES = 65_527; const MAX_CHANNEL_ID_BYTES = 255; export const CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS = 32; const RUDP_RECEIVED_SEQUENCE_WINDOW = 4_096; @@ -1588,7 +1588,7 @@ export function createRudpTransportProfile( }; } -function channelOptions(channelId: string): { reliable: boolean; ordered: boolean; sequenced: boolean } { +export function channelOptions(channelId: string): { reliable: boolean; ordered: boolean; sequenced: boolean } { if (channelId === "schema") { return { reliable: true, ordered: true, sequenced: false }; } diff --git a/packages/cultnet-ts/src/selection-carriers.ts b/packages/cultnet-ts/src/selection-carriers.ts new file mode 100644 index 00000000..21090f03 --- /dev/null +++ b/packages/cultnet-ts/src/selection-carriers.ts @@ -0,0 +1,178 @@ +// The two shape conversions the selection/v1 carriers need between the reference's wire and TypeScript: +// the reference writes every declared key, nil included, in declaration order (MessagePack-CSharp map +// mode; schema notes R-S/R-X), so a received message arrives with explicit nulls that are normalised +// to absent keys, and a sent message is rebuilt with every key in the reference's order. + +import type { + CultNetDatabaseSubscribeV1Message, + CultNetRawDocumentRecord, + CultNetSnapshotRequestV1Message, + CultNetSnapshotResponseRawV1Message, +} from "./contracts"; +import type { CultNetEdge, CultNetRawDocumentHeader, CultNetRecordRef, CultNetSelection } from "./selection"; + +export type CultNetSelectionCarrier = + | CultNetSnapshotRequestV1Message + | CultNetDatabaseSubscribeV1Message + | CultNetSnapshotResponseRawV1Message; + +type Bag = Record; + +const isBag = (value: unknown): value is Bag => !!value && typeof value === "object" && !Array.isArray(value); + +function stripNulls(candidate: unknown, keys: readonly string[]): void { + if (!isBag(candidate)) return; + for (const key of keys) { + if (candidate[key] === null) delete candidate[key]; + } +} + +function eachBag(list: unknown, visit: (item: Bag) => void): void { + if (!Array.isArray(list)) return; + for (const item of list) { + if (isBag(item)) visit(item); + } +} + +/** Drops the nil an absent selection member travels as, at every depth the selection carries one. */ +export function normalizeSelectionNulls(selection: unknown): void { + stripNulls(selection, ["schemas", "keys", "fields", "cites", "cited", "limit", "cursor"]); + if (!isBag(selection)) return; + eachBag(selection.fields, field => stripNulls(field, ["values", "number"])); + stripNulls(selection.cites, ["role"]); +} + +/** Drops the nil an absent member travels as, for one selection/v1 carrier message. */ +export function normalizeSelectionCarrierNulls( + schemaVersion: CultNetSelectionCarrier["schemaVersion"], + candidate: Bag, +): void { + switch (schemaVersion) { + case "cultnet.snapshot_request.v1": + stripNulls(candidate, ["shardId", "shardEpoch"]); + normalizeSelectionNulls(candidate.selection); + return; + case "cultnet.database_subscribe.v1": + stripNulls(candidate, ["consumerRuntimeId", "bodyIds", "supportedBodyTransports"]); + normalizeSelectionNulls(candidate.selection); + return; + case "cultnet.snapshot_response_raw.v1": + stripNulls(candidate, ["next", "headers", "documents", "edges", "shardId", "shardEpoch", "shardLogSequence"]); + eachBag(candidate.headers, header => stripNulls(header, HEADER_OPTIONAL)); + eachBag(candidate.documents, document => stripNulls(document, HEADER_OPTIONAL)); + eachBag(candidate.edges, edge => stripNulls(edge, ["payloadEncoding", "payload"])); + return; + } +} + +const HEADER_OPTIONAL = ["schemaName", "schemaVersion", "schemaContentHash", "sourceRuntimeId", "sourceAgentId", "sourceRole", "tags"] as const; + +const orNull = (value: T | undefined | null): T | null => value ?? null; + +function recordRefWire(ref: CultNetRecordRef) { + return { schemaId: ref.schemaId, recordKey: ref.recordKey }; +} + +function selectionWire(selection: CultNetSelection) { + return { + schemas: orNull(selection.schemas), + keys: orNull(selection.keys), + fields: selection.fields + ? selection.fields.map(field => ({ + index: field.index, + op: field.op, + values: orNull(field.values), + number: orNull(field.number), + })) + : null, + cites: selection.cites + ? { target: recordRefWire(selection.cites.target), role: orNull(selection.cites.role) } + : null, + cited: selection.cited ? { role: selection.cited.role, exists: selection.cited.exists } : null, + projection: selection.projection ?? "header", + descending: selection.descending ?? false, + limit: orNull(selection.limit), + cursor: orNull(selection.cursor), + }; +} + +function headerWire(header: CultNetRawDocumentHeader) { + return { + schemaId: header.schemaId, + schemaName: orNull(header.schemaName), + schemaVersion: orNull(header.schemaVersion), + schemaContentHash: orNull(header.schemaContentHash), + recordKey: header.recordKey, + storedAt: header.storedAt, + sourceRuntimeId: orNull(header.sourceRuntimeId), + sourceAgentId: orNull(header.sourceAgentId), + sourceRole: orNull(header.sourceRole), + tags: orNull(header.tags), + }; +} + +function documentWire(document: CultNetRawDocumentRecord) { + return { + schemaId: document.schemaId, + schemaName: orNull(document.schemaName), + schemaVersion: orNull(document.schemaVersion), + schemaContentHash: orNull(document.schemaContentHash), + recordKey: document.recordKey, + storedAt: document.storedAt, + payloadEncoding: document.payloadEncoding, + payload: document.payload, + sourceRuntimeId: orNull(document.sourceRuntimeId), + sourceAgentId: orNull(document.sourceAgentId), + sourceRole: orNull(document.sourceRole), + tags: orNull(document.tags), + }; +} + +function edgeWire(edge: CultNetEdge) { + return { + from: recordRefWire(edge.from), + role: edge.role, + to: recordRefWire(edge.to), + payloadEncoding: orNull(edge.payloadEncoding), + payload: orNull(edge.payload), + }; +} + +/** The reference's nil-keyed, declaration-ordered shape for one selection/v1 carrier message. */ +export function encodeSelectionCarrierForWire(message: CultNetSelectionCarrier): unknown { + switch (message.schemaVersion) { + case "cultnet.snapshot_request.v1": + return { + schemaVersion: message.schemaVersion, + messageId: message.messageId, + selection: selectionWire(message.selection), + shardId: orNull(message.shardId), + shardEpoch: orNull(message.shardEpoch), + }; + case "cultnet.database_subscribe.v1": + return { + schemaVersion: message.schemaVersion, + messageId: message.messageId, + subscriptionId: message.subscriptionId, + selection: selectionWire(message.selection), + includeSnapshot: message.includeSnapshot ?? true, + consumerRuntimeId: orNull(message.consumerRuntimeId), + bodyIds: orNull(message.bodyIds), + supportedBodyTransports: orNull(message.supportedBodyTransports), + }; + case "cultnet.snapshot_response_raw.v1": + return { + schemaVersion: message.schemaVersion, + messageId: message.messageId, + matched: message.matched, + asOf: message.asOf, + next: orNull(message.next), + headers: message.headers ? message.headers.map(headerWire) : null, + documents: message.documents ? message.documents.map(documentWire) : null, + edges: message.edges ? message.edges.map(edgeWire) : null, + shardId: orNull(message.shardId), + shardEpoch: orNull(message.shardEpoch), + shardLogSequence: orNull(message.shardLogSequence), + }; + } +} diff --git a/packages/cultnet-ts/src/selection.ts b/packages/cultnet-ts/src/selection.ts new file mode 100644 index 00000000..2bba1ebe --- /dev/null +++ b/packages/cultnet-ts/src/selection.ts @@ -0,0 +1,115 @@ +// cultnet.selection.v1 payload shapes (docs/cultnet-selection-cut.md, section 2), the carriers' common +// vocabulary: one typed selection, the header/edge rows a page answers with, and the door refusal. +// The messages that carry them live in contracts.ts; the nil-keyed wire encoding, null normalization and +// response validation live in selection-carriers.ts. + +export type CultNetSelectionOperator = "any_of" | "lt" | "le" | "ge" | "gt"; +export type CultNetSelectionProjection = "header" | "document"; + +/** One predicate over a single declared index alias. `values` iff any_of; `number` (canonical decimal string) iff a comparison. */ +export interface CultNetFieldPredicate { + index: string; + op: CultNetSelectionOperator; + values?: string[]; + number?: string; +} + +/** Identifies one row on the wire by schema and record key. */ +export interface CultNetRecordRef { + schemaId: string; + recordKey: string; +} + +/** Selects rows whose declared reference names `target`; an absent `role` matches any declared reference. */ +export interface CultNetCitation { + target: CultNetRecordRef; + role?: string; +} + +/** Selects rows some other row does (`exists`) or does not name in `role`. */ +export interface CultNetIncoming { + role: string; + exists: boolean; +} + +/** Absent `schemas`/`keys` mean every schema/key; a present empty list is refused at the door. */ +export interface CultNetSelection { + schemas?: string[]; + keys?: string[]; + fields?: CultNetFieldPredicate[]; + cites?: CultNetCitation; + cited?: CultNetIncoming; + /** Default "header". */ + projection?: CultNetSelectionProjection; + descending?: boolean; + /** Clamped to 1..=200 by the answering server. */ + limit?: number; + /** Opaque; minted by the answering server. */ + cursor?: string; +} + +/** A raw document record minus its payload and payloadEncoding. */ +export interface CultNetRawDocumentHeader { + schemaId: string; + schemaName?: string; + schemaVersion?: string; + schemaContentHash?: string; + recordKey: string; + storedAt: string; + sourceRuntimeId?: string; + sourceAgentId?: string; + sourceRole?: string; + tags?: string[]; +} + +/** One edge a hop traversed. `payload` is present under document projection only. */ +export interface CultNetEdge { + from: CultNetRecordRef; + role: string; + to: CultNetRecordRef; + payloadEncoding?: "messagepack"; + payload?: Uint8Array; +} + +/** + * Refused typed at the door: a selection the declared shape cannot support. Carried back as + * `cultnet.error.v0 { code: "selection_invalid", details: { field, value } }`. + */ +export class CultNetSelectionInvalidError extends Error { + constructor( + readonly field: string, + readonly value: string | null, + message: string, + ) { + super(message); + this.name = "CultNetSelectionInvalidError"; + } +} + +/** + * The declaration-independent half of the door, matching the reference's `ValidateShape`: an empty or + * blank `schemas`/`keys` entry or list, and an unrecognised `projection`. Checks that need the + * declared schemas (index aliases, reference roles, cites target) belong to the selection engine. + */ +export function validateSelectionShape(selection: CultNetSelection): void { + const list = (field: "schemas" | "keys") => { + const entries = selection[field]; + if (entries === undefined || entries === null) return; + if (entries.length === 0) { + throw new CultNetSelectionInvalidError( + field, null, `selection.${field} is present and empty; omit it to reach every ${field === "schemas" ? "schema" : "key"}.`); + } + for (const entry of entries) { + if (typeof entry !== "string" || entry.trim().length === 0) { + throw new CultNetSelectionInvalidError(field, entry, `selection.${field} carries an empty or whitespace entry.`); + } + } + }; + list("schemas"); + list("keys"); + const projection = selection.projection ?? "header"; + if (projection !== "header" && projection !== "document") { + throw new CultNetSelectionInvalidError( + "projection", String(projection), `selection.projection "${String(projection)}" is neither "header" nor "document".`); + } +} diff --git a/packages/cultnet-ts/test/carrier-vectors.test.ts b/packages/cultnet-ts/test/carrier-vectors.test.ts new file mode 100644 index 00000000..63f5826f --- /dev/null +++ b/packages/cultnet-ts/test/carrier-vectors.test.ts @@ -0,0 +1,171 @@ +// The reference's own bytes for the cultnet.error.v0 code/details shape and the selection/v1 +// carriers (`contracts/cultnet/interop/carrier-vectors.cs-written.json`, written by the C# +// CarrierVectorTests). Each vector is decoded, parsed, and re-encoded: the re-encoding must be the +// reference's exact bytes, and the decoded message must equal the reference's own JSON of the object. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { decode, encode } from "@msgpack/msgpack"; + +import { encodeCultNetMessageForWire, parseCultNetMessage } from "../src/contracts"; + +const FILE = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "carrier-vectors.cs-written.json"); +type Vector = { name: string; schemaVersion: string; messagePackBase64: string; expected: unknown }; +const vectors: Vector[] = JSON.parse(readFileSync(FILE, "utf8")).vectors; +const vectorsFor = (prefix: string) => vectors.filter(v => v.name.startsWith(prefix)); +const bytesOf = (v: Vector) => new Uint8Array(Buffer.from(v.messagePackBase64, "base64")); + +/** Uint8Array to base64, recursively, so a decoded message compares with the reference's JSON. */ +function jsonShape(value: unknown): unknown { + if (value instanceof Uint8Array) return Buffer.from(value).toString("base64"); + if (Array.isArray(value)) return value.map(jsonShape); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, jsonShape(v)])); + } + return value; +} + +const reencode = (message: Parameters[0]) => + new Uint8Array(encode(encodeCultNetMessageForWire(message), { ignoreUndefined: true })); + +test("every error vector keeps the reference's code and details through parse and re-encode", () => { + const errors = vectorsFor("error_"); + assert.ok(errors.length >= 5); + for (const v of errors) { + const raw = decode(bytesOf(v)); + assert.deepEqual(jsonShape(raw), v.expected, `${v.name}: decoded bytes`); + const message = parseCultNetMessage(raw); + assert.equal(message.schemaVersion, "cultnet.error.v0"); + assert.deepEqual(jsonShape(message), v.expected, `${v.name}: parsed message`); + assert.deepEqual(reencode(message), bytesOf(v), `${v.name}: re-encoded bytes`); + } +}); + +test("an error whose code is outside the schema's closed enum is refused", () => { + const raw = decode(bytesOf(vectors.find(v => v.name === "error_cursor_stale")!)) as Record; + assert.throws(() => parseCultNetMessage({ ...raw, code: "made_up_code" }), /cultnet\.error\.v0/); +}); + +// ---- selection/v1 carriers ---- + +for (const prefix of ["snapshot_request_v1", "database_subscribe_v1", "snapshot_response_raw_v1"]) { + test(`${prefix} vectors: decode, parse, and re-encode to the reference's exact bytes`, () => { + const group = vectorsFor(prefix); + assert.ok(group.length >= 2, `${prefix}: vectors present`); + for (const v of group) { + const raw = decode(bytesOf(v)); + assert.deepEqual(jsonShape(raw), v.expected, `${v.name}: decoded bytes`); + const message = parseCultNetMessage(raw); + assert.equal(message.schemaVersion, v.schemaVersion); + assert.deepEqual(reencode(message), bytesOf(v), `${v.name}: re-encoded bytes`); + } + }); +} + +test("a parsed v1 message drops the nil an absent member travels as", () => { + const full = parseCultNetMessage(decode(bytesOf(vectors.find(v => v.name === "snapshot_request_v1_defaults")!))) as unknown as + { selection: Record; shardId?: unknown }; + assert.deepEqual(Object.keys(full.selection).sort(), ["descending", "projection"]); + assert.equal("shardId" in full, false); + const page = parseCultNetMessage(decode(bytesOf(vectors.find(v => v.name === "snapshot_response_raw_v1_headers")!))) as unknown as + { headers: Record[]; documents?: unknown }; + assert.equal("documents" in page, false); + assert.equal("tags" in page.headers[0], false); + assert.deepEqual(page.headers[1].tags, ["alpha", "beta"]); +}); + +test("edge payloads and document payloads arrive as bytes, not text", () => { + const page = parseCultNetMessage(decode(bytesOf(vectors.find(v => v.name === "snapshot_response_raw_v1_documents_and_edges")!))) as unknown as + { edges: { payload?: Uint8Array }[]; documents: { payload: Uint8Array }[] }; + assert.deepEqual(Array.from(page.edges[1].payload!), [1, 2, 3]); + assert.equal("payload" in page.edges[0], false); + assert.deepEqual(Array.from(page.documents[0].payload), [0x81, 0xa1, 0x6b, 0x01]); +}); + +const request = () => decode(bytesOf(vectors.find(v => v.name === "snapshot_request_v1_full_selection_and_shard")!)) as + { selection: Record } & Record; +const refusedField = (message: unknown): string | undefined => { + try { + parseCultNetMessage(message); + } catch (error) { + return (error as { field?: string }).field; + } + return "accepted"; +}; + +test("the door refuses an empty or blank schemas/keys entry with a typed field, never as every-schema", () => { + const withSelection = (patch: Record) => ({ ...request(), selection: { ...request().selection, ...patch } }); + assert.equal(refusedField(withSelection({ schemas: [] })), "schemas"); + assert.equal(refusedField(withSelection({ keys: ["a-eq", " "] })), "keys"); + assert.equal(refusedField(withSelection({ projection: "everything" })), "projection"); +}); + +test("the schema refuses a comparison whose number is not a canonical decimal, and a predicate that mixes values and number", () => { + const withField = (field: Record) => { + const message = request(); + return { ...message, selection: { ...message.selection, fields: [field] } }; + }; + for (const number of ["05", "1.50", "+1", "1e3", "-0", " 1"]) { + assert.throws(() => parseCultNetMessage(withField({ index: "mass", op: "ge", values: null, number })), /snapshot_request\.v1/, number); + } + assert.throws(() => parseCultNetMessage(withField({ index: "mass", op: "ge", values: ["1"], number: "1" })), /snapshot_request\.v1/); + assert.throws(() => parseCultNetMessage(withField({ index: "mass", op: "near", values: null, number: "1" })), /snapshot_request\.v1/); +}); + +test("a selection page with both or neither of headers and documents is refused", () => { + const page = decode(bytesOf(vectors.find(v => v.name === "snapshot_response_raw_v1_headers")!)) as Record; + assert.throws(() => parseCultNetMessage({ ...page, documents: [] }), /exactly one of headers and documents/); + assert.throws(() => parseCultNetMessage({ ...page, headers: null }), /exactly one of headers and documents/); +}); + +// ---- selection vectors: the reference's and Rust's selection bytes ---- + +const INTEROP = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop"); +type SelectionVector = { name: string; selectionMessagePackBase64: string; expectedRefusalField?: string | null }; +const selectionVectors = (file: string): SelectionVector[] => + JSON.parse(readFileSync(join(INTEROP, file), "utf8")).vectors; + +import { CultNetSelectionInvalidError, validateSelectionShape, type CultNetSelection } from "../src/selection"; + +for (const file of ["selection-vectors.cs-written.json", "selection-vectors.rs-written.json"]) { + test(`${file}: every selection decodes inside a v1 request and the schema door agrees with the vector`, () => { + const all = selectionVectors(file); + assert.ok(all.length >= 20); + for (const v of all) { + const selection = decode(new Uint8Array(Buffer.from(v.selectionMessagePackBase64, "base64"))); + const message = { schemaVersion: "cultnet.snapshot_request.v1", messageId: "m", selection, shardId: null, shardEpoch: null }; + if (v.expectedRefusalField === "schemas" || v.expectedRefusalField === "keys") { + assert.equal(refusedField(message), v.expectedRefusalField, `${v.name}: shape refusal`); + continue; + } + if (v.expectedRefusalField) { + // Declaration-dependent refusals (cites target, index aliases) need the selection engine's schemas. + const parsed = parseCultNetMessage(message) as unknown as { selection: CultNetSelection }; + validateSelectionShape(parsed.selection); + continue; + } + const parsed = parseCultNetMessage(message) as unknown as { selection: CultNetSelection }; + validateSelectionShape(parsed.selection); + const rewritten = new Uint8Array(encode( + (encodeCultNetMessageForWire(parsed as never) as { selection: unknown }).selection)); + if (file.includes("cs-written")) { + // The reference's bytes are the nil-keyed shape: our encoder must reproduce them exactly. + assert.deepEqual(rewritten, new Uint8Array(Buffer.from(v.selectionMessagePackBase64, "base64")), + `${v.name}: selection re-encodes to the reference's bytes`); + } else { + // Rust omits absent members; what it means must survive our nil-keyed rewrite unchanged. + const again = parseCultNetMessage({ ...message, selection: decode(rewritten) }) as unknown as { selection: CultNetSelection }; + assert.deepEqual(again.selection, parsed.selection, `${v.name}: selection meaning survives re-encode`); + } + } + }); +} + +test("CultNetSelectionInvalidError carries the field and offending value", () => { + assert.throws( + () => validateSelectionShape({ keys: ["ok", ""] }), + (error: unknown) => error instanceof CultNetSelectionInvalidError && error.field === "keys" && error.value === "", + ); +}); diff --git a/packages/cultnet-ts/test/content.test.ts b/packages/cultnet-ts/test/content.test.ts index 3ff58d48..ec23b64e 100644 --- a/packages/cultnet-ts/test/content.test.ts +++ b/packages/cultnet-ts/test/content.test.ts @@ -49,14 +49,14 @@ const HASH = "a".repeat(64); const manifestOf = (over: Partial = {}): CultMeshCdnArtifactManifest => ({ artifactId: "id", kind: "asset", version: "1", contentHash: HASH, sizeBytes: 4, mimeType: "text/plain", createdAtUtc: "t", - chunks: [{ chunkHash: HASH, offset: 0, sizeBytes: 4, recordKey: `mesh:cdn:chunk:${HASH}` }], tags: ["t"], metadata: [["k", "v"]], + chunks: [{ chunkHash: HASH, offset: 0, sizeBytes: 4, recordKey: `mesh:cdn:chunk:${HASH}`, id: "0" }], tags: ["t"], metadata: [["k", "v"]], ...over, }); // Wire bytes of a ten-array with any field replaced, metadata as a plain object (no integer-like keys). const fieldsOf = (over: Record = {}) => { const m = manifestOf(); const fields: unknown[] = [m.artifactId, m.kind, m.version, m.contentHash, m.sizeBytes, m.mimeType, m.createdAtUtc, - m.chunks.map(c => [c.chunkHash, c.offset, c.sizeBytes, c.recordKey]), m.tags, { k: "v" }]; + m.chunks.map(c => [c.chunkHash, c.offset, c.sizeBytes, c.recordKey, c.id]), m.tags, { k: "v" }]; for (const [index, value] of Object.entries(over)) fields[Number(index)] = value; return fields; }; @@ -95,7 +95,7 @@ test("the packed chunks are copies that rejoin to the body, each named by the ha assert.deepEqual(Array.from(chunks.flatMap(c => Array.from(c.payload))), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); for (const [i, c] of chunks.entries()) { assert.equal(c.chunkHash, await sha256Hex(c.payload)); - assert.deepEqual(manifest.chunks[i], { chunkHash: c.chunkHash, offset: i * 4, sizeBytes: c.payload.length, recordKey: chunkRecordKey(c.chunkHash) }); + assert.deepEqual(manifest.chunks[i], { chunkHash: c.chunkHash, offset: i * 4, sizeBytes: c.payload.length, recordKey: chunkRecordKey(c.chunkHash), id: String(i * 4) }); } const twins = await packContent("x", new Uint8Array(8), { chunkSizeBytes: 4 }); assert.equal(twins.manifest.chunks[0]!.chunkHash, twins.manifest.chunks[1]!.chunkHash, "equal chunks share a hash"); @@ -216,7 +216,7 @@ test("a metadata key that is not a string names the field", () => { }); test("validateManifest refuses negative size, a gap, an overlap and a size-sum mismatch with the C# messages", () => { - const chunk = (offset: number, sizeBytes: number) => ({ chunkHash: HASH, offset, sizeBytes, recordKey: "k" }); + const chunk = (offset: number, sizeBytes: number) => ({ chunkHash: HASH, offset, sizeBytes, recordKey: "k", id: String(offset) }); const refuses = (over: Partial, message: string) => assert.throws(() => validateManifest(manifestOf(over)), new Error(message)); refuses({ sizeBytes: -1, chunks: [] }, "CDN artifact manifest has a negative size."); @@ -232,7 +232,7 @@ test("validateManifest refuses negative size, a gap, an overlap and a size-sum m }); test("validateManifest refuses a size that is not a whole number below 2^53 and a chunk size an int cannot hold, whether decoded or hand-built", () => { - const chunk = (offset: number, sizeBytes: number) => ({ chunkHash: HASH, offset, sizeBytes, recordKey: "k" }); + const chunk = (offset: number, sizeBytes: number) => ({ chunkHash: HASH, offset, sizeBytes, recordKey: "k", id: String(offset) }); const refuses = (over: Partial, message: string) => assert.throws(() => validateManifest(manifestOf(over)), new Error(message)); const manifestMessage = "CDN artifact manifest size is not a whole number below 2^53."; @@ -405,6 +405,23 @@ test("nil in a reference slot reads as null and writes back as nil; a missing tr assert.deepEqual(short, { artifactId: "id", kind: "asset", version: "", contentHash: "", sizeBytes: 0, mimeType: "application/octet-stream", createdAtUtc: "", chunks: [], tags: [], metadata: [] }); }); +test("a chunk reference's id is slot 4: nil reads null and writes nil, an absent slot reads \"\" and is written, packContent writes the decimal offset", async () => { + const chunk = (id: unknown[]) => [HASH, 0, 4, "k", ...id]; + const read = (id: unknown[]) => decodeManifest(encode(fieldsOf({ 7: [chunk(id)] }) as unknown[], { useBigInt64: true, maxDepth: 1000 })).chunks[0]!; + const nilled = encode(fieldsOf({ 7: [chunk([null])] }), { useBigInt64: true, maxDepth: 1000 }); + const nilManifest = decodeManifest(nilled); + assert.equal(nilManifest.chunks[0]!.id, null); + assert.deepEqual(encodeManifest(nilManifest), nilled); + const absent = decodeManifest(encode(fieldsOf({ 7: [[HASH, 0, 4, "k"]] }), { useBigInt64: true, maxDepth: 1000 })); + assert.equal(absent.chunks[0]!.id, ""); + assert.deepEqual(decodeManifest(encodeManifest(absent)).chunks[0], { chunkHash: HASH, offset: 0, sizeBytes: 4, recordKey: "k", id: "" }); + assert.equal(read(["7"]).id, "7"); + assert.throws(() => read([0]), /chunks.id/); + const packed = await packContent("x", new Uint8Array(9), { chunkSizeBytes: 4 }); + assert.deepEqual(packed.manifest.chunks.map(c => c.id), ["0", "4", "8"]); + assert.deepEqual((await packContent("x", new Uint8Array(0))).manifest.chunks.map(c => c.id), ["0"]); +}); + // Refusals that are the same refusal in the reference but would read differently if the wrong rule fired: the message // names the true cause (the sign, the nil chunk, the reserved code, the early end), never a neighbouring field. test("a refusal names its true cause", () => { @@ -433,7 +450,7 @@ test("a lone surrogate is written as U+FFFD at every string length, short and lo const withText = (value: string): CultMeshCdnArtifactManifest => ({ ...base, artifactId: value, kind: value, version: value, mimeType: value, createdAtUtc: value, - chunks: [{ ...base.chunks[0]!, recordKey: value }], + chunks: [{ ...base.chunks[0]!, chunkHash: value, recordKey: value, id: value }], tags: [value], metadata: [[value, value]], }); diff --git a/packages/cultnet-ts/test/cultnet-error.test.ts b/packages/cultnet-ts/test/cultnet-error.test.ts new file mode 100644 index 00000000..e234f105 --- /dev/null +++ b/packages/cultnet-ts/test/cultnet-error.test.ts @@ -0,0 +1,99 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { decode, encode } from "@msgpack/msgpack"; + +import { + CULTNET_ERROR_CODES, + cultNetSchemas, + cultNetWriteNotAuthorizedError, + encodeCultNetMessageForWire, + parseCultNetMessage, + type CultNetErrorMessage, +} from "../src"; + +// contracts/cultnet/interop/error-vectors.json: bytes captured from MessagePack C# 3.1.7 and pinned +// by cultnet-rs/tests/error_message.rs (Rust encodes to exactly these bytes). +const VECTORS = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "error-vectors.json"); +const vectors = (JSON.parse(readFileSync(VECTORS, "utf8")) as { + vectors: { name: string; messagePackHex: string; message: unknown }[]; +}).vectors; + +const bytesOf = (hex: string): Uint8Array => Uint8Array.from(Buffer.from(hex, "hex")); +const hexOf = (bytes: Uint8Array): string => Buffer.from(bytes).toString("hex").toUpperCase(); +const wireOf = (message: unknown): Uint8Array => encode(encodeCultNetMessageForWire(message as CultNetErrorMessage)); + +test("the error vector file carries a case for every closed code", () => { + const codes = new Set(vectors.map((v) => (v.message as CultNetErrorMessage).code)); + for (const code of CULTNET_ERROR_CODES) { + assert.ok(codes.has(code), `no vector for ${code}`); + } +}); + +test("a null code with null routingHint and details is a valid error message (no captured bytes carry one)", () => { + const message = parseCultNetMessage({ + schemaVersion: "cultnet.error.v0", error: "boom", routingHint: null, code: null, details: null, + }); + assert.equal((message as CultNetErrorMessage).code, null); +}); + +test("the closed code union equals the schema's enum", () => { + const schemaCodes = ((cultNetSchemas.errorSchema.properties.code as { enum?: (string | null)[] }).enum ?? []).filter((c) => c !== null); + assert.deepEqual([...CULTNET_ERROR_CODES].sort(), [...schemaCodes].sort()); +}); + +test("C#/Rust-written error bytes decode into the typed message with every key", () => { + for (const vector of vectors) { + const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex))); + assert.deepEqual(parsed, vector.message, vector.name); + assert.deepEqual(Object.keys(parsed), ["schemaVersion", "error", "routingHint", "code", "details"], vector.name); + } +}); + +test("a parsed error message re-encodes to the same bytes", () => { + for (const vector of vectors) { + const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex))); + assert.equal(hexOf(wireOf(parsed)), vector.messagePackHex, vector.name); + } +}); + +test("dropping code or details breaks the round trip and the decode", () => { + const vector = vectors.find((v) => v.name === "cursor_stale")!; + for (const dropped of ["code", "details", "routingHint"]) { + const copy = { ...(decode(bytesOf(vector.messagePackHex)) as Record) }; + delete copy[dropped]; + assert.throws(() => parseCultNetMessage(copy), /cultnet\.error\.v0/, `missing ${dropped} must be refused`); + assert.notEqual(hexOf(encode(copy)), vector.messagePackHex); + } +}); + +test("a code outside the closed enum is refused, as is a malformed details object", () => { + const base = decode(bytesOf(vectors.find((v) => v.name === "cursor_stale")!.messagePackHex)) as Record; + assert.throws(() => parseCultNetMessage({ ...base, code: "not_a_code" }), /cultnet\.error\.v0/); + assert.throws(() => parseCultNetMessage({ ...base, code: 7 }), /cultnet\.error\.v0/); + assert.throws( + () => parseCultNetMessage({ ...base, details: { field: null, value: null, asOf: 1, current: 2, extra: 1 } }), + /cultnet\.error\.v0/, + ); + assert.throws( + () => parseCultNetMessage({ ...base, details: { field: null, value: null, asOf: -1, current: 2 } }), + /cultnet\.error\.v0/, + ); +}); + +test("the write_not_authorized factory writes the C# vector's bytes and names the key", () => { + const vector = vectors.find((v) => v.name === "write_not_authorized")!; + assert.equal(hexOf(wireOf(cultNetWriteNotAuthorizedError("some:key", "not granted"))), vector.messagePackHex); + assert.deepEqual(cultNetWriteNotAuthorizedError("some:key", "not granted"), vector.message); +}); + +test("a write_not_authorized refusal with no reason, or a blank one, says only the code", () => { + assert.equal(cultNetWriteNotAuthorizedError("k").error, "write_not_authorized"); + assert.equal(cultNetWriteNotAuthorizedError("k", " ").error, "write_not_authorized"); + assert.equal(cultNetWriteNotAuthorizedError("k", null).error, "write_not_authorized"); + // C# string.IsNullOrWhiteSpace counts U+0085 as white space and not U+FEFF; JS trim() disagrees on both. + assert.equal(cultNetWriteNotAuthorizedError("k", "\u0085").error, "write_not_authorized"); + assert.equal(cultNetWriteNotAuthorizedError("k", "\ufeff").error, "write_not_authorized: \ufeff"); +}); diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index a6f19800..37d963a8 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -11,8 +11,10 @@ import { z } from "zod"; import { decode as decodeMsgpack, encode } from "@msgpack/msgpack"; import { CultCache, + SchemaConflictError, SingleFileMessagePackBackingStore, defineDocumentType, + schemaIdentityOf, } from "@gamecult/cultcache-ts"; import { @@ -1346,6 +1348,56 @@ test("CultNet raw replication preserves CultCache payload bytes for bit-compatib } }); +test("CultNet raw replication under a compatible schema id leaves a store that reopens and takes the next write", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-raw-compatible-")); + + try { + const definition = defineDocumentType({ + type: "tests.raw-compatible", + schema: z.object({ name: z.string() }), + schemaId: "tests.raw-compatible.current", + schemaName: "tests.raw-compatible", + schemaVersion: "tests.raw_compatible.v1", + compatibleSchemaIds: ["tests.raw-compatible.current", "tests.raw-compatible.older"], + }); + const registry = new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition, payloadSchemaVersion: "tests.raw_compatible.v1" }), + ]); + const open = async (): Promise => { + const cache = CultCache.builder() + .withDocumentType(definition) + .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "target.msgpack"))) + .build(); + await cache.pullAllBackingStores(); + return cache; + }; + + const origin = CultCache.builder().withDocumentType(definition).build(); + await origin.put(definition, "seed", { name: "seed" }); + const payload = origin.getRequiredEnvelope(definition, "seed").payload; + + const target = await open(); + await registry.applyRawDocumentPutMessage(target, { + schemaVersion: "cultnet.document_put_raw.v0", + messageId: "raw-compatible-1", + document: { + schemaId: "tests.raw-compatible.older", + recordKey: "replicated", + storedAt: "2026-09-30T00:00:00.0000000Z", + payloadEncoding: "messagepack", + payload, + }, + } as Parameters[1]); + + const reopened = await open(); + assert.deepEqual(reopened.getRequired(definition, "replicated"), { name: "seed" }); + await reopened.put(definition, "next", { name: "next" }); + assert.deepEqual((await open()).getRequired(definition, "next"), { name: "next" }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + test("operation envelopes preserve typed service routing and payload correlation", () => { const request = parseCultNetMessage({ schemaVersion: "cultnet.operation_request.v0", @@ -1702,162 +1754,204 @@ test("rudp sequence-neutral acknowledgements interoperate with ordered receivers assert.deepEqual(receiver.receive(request).delivered.map(frame => Buffer.from(frame.payload).toString("utf8")), ["snapshot"]); }); -test("CultNet raw replication applies compatible foreign-schema snapshots", async () => { - const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-foreign-schema-raw-")); +// A replicated record whose schema id no binding declares is refused, whatever its payload parses as: the registry never +// guesses a type from a payload's shape and never relabels a record it does not understand. +test("CultNet raw replication refuses a record under a schema id no binding declares, whatever its shape", async () => { + const note = defineDocumentType({ + type: "p5.note", schemaId: "p5.note.v1", schemaName: "p5.note", schema: z.object({ body: z.string() }), + }); + // Registered first, and accepts every payload: shape routing would store the record under it. + const anything = defineDocumentType({ type: "p5.anything", schema: z.any() }); + const registry = new CultNetDocumentRegistry([ + defineCultNetDocumentBinding({ definition: anything }), + defineCultNetDocumentBinding({ definition: note }), + ]); + const origin = CultCache.builder().withDocumentType(note).build(); + await origin.put(note, "k", { body: "from a peer" }); + const snapshot = registry.createRawSnapshotResponse(origin, "p5"); + assert.equal(snapshot.documents[0]?.schemaId, "p5.note.v1"); + assert.equal(snapshot.documents[0]?.payloadEncoding, "messagepack"); - try { - const documentDefinition = defineDocumentType({ - type: "cultnet.note", - schemaId: "cultnet.note.v1", - schemaName: "cultnet.note", - schemaVersion: "cultnet.note.v1", - schema: z.object({ - schema_version: z.string(), - noteId: z.string(), - body: z.string(), + for (const schemaId of ["runtime.generated.p5.note.ui.99", "p5.note.v7", "p5.note"]) { + const target = CultCache.builder().withDocumentType(note).withDocumentType(anything).build(); + await assert.rejects( + registry.applyRawSnapshotResponse(target, { + ...snapshot, + documents: snapshot.documents.map(document => ({ ...document, schemaId })), }), - name: "noteId", - }); - const registry = new CultNetDocumentRegistry([ - defineCultNetDocumentBinding({ definition: documentDefinition }), - ]); - const originCache = CultCache.builder() - .withDocumentType(documentDefinition) - .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "origin.msgpack"))) - .build(); - const targetCache = CultCache.builder() - .withDocumentType(documentDefinition) - .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "target.msgpack"))) - .build(); - - await originCache.put(documentDefinition, "note:foreign-schema", { - schema_version: "cultnet.note.v1", - noteId: "note:foreign-schema", - body: "accepted by payload shape", - }); - const rawSnapshot = registry.createRawSnapshotResponse(originCache, "foreign-schema-raw"); - rawSnapshot.documents[0]!.schemaId = "runtime.generated.cultnet.note.ui.99"; - - await registry.applyRawSnapshotResponse(targetCache, rawSnapshot); - - assert.equal( - targetCache.getRequired(documentDefinition, "note:foreign-schema").body, - "accepted by payload shape", + (error: unknown) => error instanceof Error + && error.message === `No CultNet document binding is registered for schema "${schemaId}".`, ); - assert.equal( - targetCache.getRequiredEnvelope(documentDefinition, "note:foreign-schema").schemaId, - "cultnet.note.v1", - ); - } finally { - rmSync(tempDir, { recursive: true, force: true }); + assert.deepEqual(target.snapshot(), [], `nothing was stored for ${schemaId}`); } }); -test("CultNet document registry filters snapshots by schema aliases", async () => { - const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-alias-snapshot-")); +// The registry stamps and resolves a definition's schema id exactly as CultCache does, so a record a cache wrote is read +// back as the type that wrote it on every peer. +test("CultNet registry derives a definition's schema id as the cache does, and refuses what the cache refuses", async () => { + const shape = z.object({ body: z.string() }); + const a = defineDocumentType({ type: "p1.a", schemaName: "p1.shared", schema: shape }); + const b = defineDocumentType({ type: "p1.b", schemaId: "p1.shared", schemaName: "p1.b", schema: shape }); + const fillers = [0, 1].map(index => defineCultNetDocumentBinding({ + definition: defineDocumentType({ type: `p1.filler${index}`, schema: shape }), + })); - try { - const documentDefinition = defineDocumentType({ - type: "cultnet.alias_note", - schemaId: "sha256:cultnet-alias-note", - schemaName: "cultnet.alias_note", - schemaVersion: "cultnet.alias_note.v1", - schema: z.object({ - schema_version: z.string(), - noteId: z.string(), - body: z.string(), - }), - name: "noteId", + assert.deepEqual(schemaIdentityOf(a), { + schemaId: "p1.shared", schemaName: "p1.shared", schemaVersion: "p1.shared.v1", compatibleSchemaIds: ["p1.shared"], + }); + // The cache refuses B beside A: A's records carry "p1.shared". + await assert.rejects(CultCache.builder().withDocumentType(a).build().registerDocumentType(b), SchemaConflictError); + for (const [first, second] of [[a, b], [b, a]]) { + // Two bindings stand ahead of the holder, so a check that looks only at the first holder misses it. + const registry = new CultNetDocumentRegistry([...fillers, defineCultNetDocumentBinding({ definition: first })]); + assert.throws(() => registry.register(defineCultNetDocumentBinding({ definition: second })), (error: unknown) => { + assert.ok(error instanceof SchemaConflictError, String(error)); + assert.equal(error.schemaId, "p1.shared"); + return true; }); - const registry = new CultNetDocumentRegistry([ - defineCultNetDocumentBinding({ definition: documentDefinition }), - ]); - const cache = CultCache.builder() - .withDocumentType(documentDefinition) - .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "alias.msgpack"))) - .build(); - - await cache.put(documentDefinition, "note:alias", { - schema_version: "cultnet.alias_note.v1", - noteId: "note:alias", - body: "filtered by alias", - }); - - const typedSnapshot = registry.createSnapshotResponse(cache, "typed-alias", { - schemaVersion: "cultnet.snapshot_request.v0", - messageId: "typed-alias", - schemaIds: ["cultnet.alias_note.v1"], - }); - const rawSnapshot = registry.createRawSnapshotResponse(cache, "raw-alias", { - schemaVersion: "cultnet.snapshot_request.v0", - messageId: "raw-alias", - schemaIds: ["cultnet.alias_note.v1"], - }); - - assert.equal(typedSnapshot.documents[0]?.schemaId, "sha256:cultnet-alias-note"); - assert.equal(typedSnapshot.documents[0]?.recordKey, "note:alias"); - assert.equal(rawSnapshot.documents[0]?.schemaId, "sha256:cultnet-alias-note"); - assert.equal(rawSnapshot.documents[0]?.recordKey, "note:alias"); - } finally { - rmSync(tempDir, { recursive: true, force: true }); + assert.equal(registry.get(second.type), undefined); } + + const registry = new CultNetDocumentRegistry([...fillers, defineCultNetDocumentBinding({ definition: a })]); + const origin = CultCache.builder().withDocumentType(a).build(); + await origin.put(a, "k", { body: "written by a" }); + const stamped = origin.getRequiredEnvelope(a, "k").schemaId; + assert.equal(stamped, "p1.shared"); + assert.equal(registry.createDocumentPutMessage(registry.get("p1.a")!, "m", "k", { body: "x" }).document.schemaId, stamped); + assert.equal(registry.createRawDocumentPutMessage(registry.get("p1.a")!, "m", "k", { body: "x" }).document.schemaId, stamped); + assert.equal(registry.getBySchemaId(stamped!)?.definition, a); + + const peer = CultCache.builder().withDocumentType(a).build(); + await registry.applyRawSnapshotResponse(peer, registry.createRawSnapshotResponse(origin, "p1")); + assert.deepEqual(peer.getRequired(a, "k"), { body: "written by a" }); + assert.equal(peer.getRequiredEnvelope(a, "k").schemaId, stamped); }); -test("CultNet document registry applies typed put and delete messages by schema alias", async () => { - const tempDir = mkdtempSync(join(tmpdir(), "cultnetts-alias-typed-")); +// An id nobody owns resolves to the one binding that lists it as compatible. Several listers and no owner resolve to +// none, and the refusal does not depend on the order they were registered in. An owner beats every lister. +test("CultNet registry refuses an unowned schema id that several bindings list, in any order", async () => { + const shape = z.object({ body: z.string() }); + const first = defineDocumentType({ type: "p2.first", schemaId: "p2.first", compatibleSchemaIds: ["p2.old"], schema: shape }); + const second = defineDocumentType({ type: "p2.second", schemaId: "p2.second", compatibleSchemaIds: ["p2.old"], schema: shape }); + const owner = defineDocumentType({ type: "p2.owner", schemaId: "p2.old", schema: shape }); + const bind = (definition: typeof first) => defineCultNetDocumentBinding({ definition }); - try { - const documentDefinition = defineDocumentType({ - type: "cultnet.typed_alias_note", - schemaId: "sha256:cultnet-typed-alias-note", - schemaName: "cultnet.typed_alias_note", - schemaVersion: "cultnet.typed_alias_note.v1", - schema: z.object({ - schema_version: z.string(), - noteId: z.string(), - body: z.string(), - }), - name: "noteId", + for (const order of [[first, second], [second, first]]) { + const registry = new CultNetDocumentRegistry(order.map(bind)); + assert.throws(() => registry.getBySchemaId("p2.old"), (error: unknown) => { + assert.ok(error instanceof SchemaConflictError, String(error)); + assert.equal(error.schemaId, "p2.old"); + assert.deepEqual(error.schemaNames, ["p2.first", "p2.second"]); + assert.equal(error.recordKey, ""); + assert.ok(error.message.includes("(p2.first, p2.second)"), error.message); + assert.ok(error.message.includes("so it resolves to none. Bind the type that owns the id"), error.message); + return true; }); - const registry = new CultNetDocumentRegistry([ - defineCultNetDocumentBinding({ definition: documentDefinition }), - ]); - const cache = CultCache.builder() - .withDocumentType(documentDefinition) - .withGenericStore(new SingleFileMessagePackBackingStore(join(tempDir, "typed-alias.msgpack"))) - .build(); + const cache = CultCache.builder().withDocumentType(first).withDocumentType(second).build(); + const origin = CultCache.builder().withDocumentType(first).build(); + await origin.put(first, "k", { body: "old" }); + const document = { ...registry.createRawSnapshotResponse(origin, "p2").documents[0]!, schemaId: "p2.old" }; + await assert.rejects( + registry.applyRawDocumentPutMessage(cache, { schemaVersion: "cultnet.document_put_raw.v0", messageId: "p2", document }), + SchemaConflictError, + ); + assert.deepEqual(cache.snapshot(), []); - await registry.applyDocumentPutMessage(cache, { + for (const withOwner of [[...order, owner], [owner, ...order]]) { + assert.equal(new CultNetDocumentRegistry(withOwner.map(bind)).getBySchemaId("p2.old")?.definition, owner); + } + } + // One lister, bound twice, is still one lister. + const single = new CultNetDocumentRegistry([bind(first), bind(first)]); + assert.equal(single.getBySchemaId("p2.old")?.definition, first); + // A snapshot filtered by an id a binding lists returns that binding's records under their own id. + const origin = CultCache.builder().withDocumentType(first).build(); + await origin.put(first, "k", { body: "listed" }); + const filter = { schemaVersion: "cultnet.snapshot_request.v0" as const, messageId: "f", schemaIds: ["p2.old"] }; + assert.deepEqual(single.createRawSnapshotResponse(origin, "f", filter).documents.map(document => document.schemaId), ["p2.first"]); + assert.deepEqual(single.createSnapshotResponse(origin, "f", filter).documents.map(document => document.schemaId), ["p2.first"]); +}); + +// A schema id is only ever an id a definition declares. A version string, a schema name, or an id that differs from a +// declared one by its version suffix names nothing. +test("CultNet registry resolves only declared schema ids, never a version, a name or an inferred version", async () => { + const note = defineDocumentType({ + type: "p3.note", + schemaId: "p3.note.v1", + schemaName: "p3.note", + schemaVersion: "p4.note.v3", + schema: z.object({ body: z.string() }), + }); + const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]); + const cache = CultCache.builder().withDocumentType(note).build(); + await cache.put(note, "k", { body: "held" }); + + assert.equal(registry.getBySchemaId("p3.note.v1")?.definition, note); + for (const undeclared of ["p3.note.v7", "p4.note.v3", "p3.note"]) { + assert.equal(registry.getBySchemaId(undeclared), undefined, undeclared); + const filter = { schemaVersion: "cultnet.snapshot_request.v0" as const, messageId: "f", schemaIds: [undeclared] }; + assert.deepEqual(registry.createSnapshotResponse(cache, "f", filter).documents, [], undeclared); + assert.deepEqual(registry.createRawSnapshotResponse(cache, "f", filter).documents, [], undeclared); + await assert.rejects(registry.applyDocumentPutMessage(cache, { schemaVersion: "cultnet.document_put.v0", - messageId: "typed-alias-put", - document: { - schemaId: "cultnet.typed_alias_note.v1", - recordKey: "note:typed-alias", - storedAt: "2026-06-27T00:00:00.000Z", - payload: { - schema_version: "cultnet.typed_alias_note.v1", - noteId: "note:typed-alias", - body: "typed alias applied", - }, - }, - }); - - assert.equal( - cache.getRequired(documentDefinition, "note:typed-alias").body, - "typed alias applied", - ); - assert.equal(registry.getBySchemaId("cultnet.typed_alias_note.v1")?.definition.type, documentDefinition.type); - - assert.equal(await registry.applyDocumentDeleteMessage(cache, { - schemaVersion: "cultnet.document_delete.v0", - messageId: "typed-alias-delete", - schemaId: "cultnet.typed_alias_note.v1", - recordKey: "note:typed-alias", - }), true); - assert.equal(cache.get(documentDefinition, "note:typed-alias"), undefined); - } finally { - rmSync(tempDir, { recursive: true, force: true }); + messageId: "put", + document: { schemaId: undeclared, recordKey: "k", storedAt: "2026-09-30T00:00:00.000Z", payload: { body: "relabelled" } }, + }), /No CultNet document binding/); + await assert.rejects(registry.applyDocumentDeleteMessage(cache, { + schemaVersion: "cultnet.document_delete.v0", messageId: "delete", schemaId: undeclared, recordKey: "k", + }), /No CultNet document binding/); + assert.deepEqual(cache.getRequired(note, "k"), { body: "held" }); } + const filter = { schemaVersion: "cultnet.snapshot_request.v0" as const, messageId: "f", schemaIds: ["p3.note.v1"] }; + assert.equal(registry.createSnapshotResponse(cache, "f", filter).documents.length, 1); + assert.equal(registry.createRawSnapshotResponse(cache, "f", filter).documents.length, 1); +}); + +test("CultNet document registry binds one type per schema and refuses a second, typed", () => { + const noteShape = z.object({ noteId: z.string() }); + const owner = defineDocumentType({ type: "tests.note", schemaId: "id.note", schemaName: "tests.note", schema: noteShape }); + const sameId = defineDocumentType({ type: "tests.note-ui", schemaId: "id.note", schemaName: "tests.note-ui", schema: noteShape }); + const sameName = defineDocumentType({ type: "tests.note-named", schemaId: "id.other", schemaName: "tests.note", schema: noteShape }); + const sameType = defineDocumentType({ type: "tests.note", schemaId: "id.third", schemaName: "tests.third", schema: noteShape }); + const lister = defineDocumentType({ + type: "tests.note-lister", schemaId: "id.lister", schemaName: "tests.note-lister", compatibleSchemaIds: ["id.note"], schema: noteShape, + }); + + for (const [claimant, schemaId, names] of [ + [sameId, "id.note", ["tests.note", "tests.note-ui"]], + [sameName, "id.other", ["tests.note", "tests.note"]], + [sameType, "id.third", ["tests.note", "tests.third"]], + ] as const) { + for (const order of [[owner, claimant], [claimant, owner]]) { + // Two bindings are registered ahead of the holder, so a conflict loop that checks only the first holder fails. + const registry = new CultNetDocumentRegistry([ + ...["tests.filler-a", "tests.filler-b"].map(type => defineCultNetDocumentBinding({ + definition: defineDocumentType({ type, schema: noteShape }), + })), + defineCultNetDocumentBinding({ definition: order[0] }), + ]); + assert.throws(() => registry.register(defineCultNetDocumentBinding({ definition: order[1] })), (error: unknown) => { + assert.ok(error instanceof SchemaConflictError, String(error)); + const ownerFirst = order[0] === owner; + assert.equal(error.schemaId, ownerFirst ? schemaId : "id.note"); + assert.deepEqual(error.schemaNames, ownerFirst ? names : [...names].reverse()); + assert.equal(error.recordKey, ""); + assert.ok(error.message.includes(`schema id "${error.schemaId}"`), error.message); + return true; + }); + // The refused binding left nothing behind: the schema still resolves to the first type. + assert.equal(registry.getBySchemaId(order[0].schemaId!)?.definition, order[0]); + assert.equal(registry.get(order[1].type)?.definition, order[1].type === order[0].type ? order[0] : undefined); + } + } + + // The same definition may be bound again, and a definition listing the id as compatible is not a second owner. + const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: owner })]); + registry.register(defineCultNetDocumentBinding({ definition: owner, payloadSchemaVersion: "tests.note.v2" })); + registry.register(defineCultNetDocumentBinding({ definition: lister })); + assert.equal(registry.get("tests.note")?.payloadSchemaVersion, "tests.note.v2"); + assert.equal(registry.getBySchemaId("id.note")?.definition, owner); + assert.equal(registry.getBySchemaId("id.lister")?.definition, lister); }); test("CultNet interop slot compatibility defaults missing trailing fields and rejects mismatched slots", () => { @@ -2965,3 +3059,34 @@ test("RUDP operation service admits a restarted client on the same address", asy await server.close(); } }); + +// MessagePack encodes undefined as nil, and a peer rejects a document whose optional source fields are nil. +test("document put message omits unset optional keys so the encoded wire has no nil", () => { + const definition = defineDocumentType({ type: "putnil.doc", schema: z.object({ body: z.string() }) }); + const binding = defineCultNetDocumentBinding({ definition }); + const registry = new CultNetDocumentRegistry([binding]); + + const bare = registry.createDocumentPutMessage(binding, "m", "k", { body: "x" }); + assert.deepEqual(Object.keys(bare.document).sort(), ["payload", "recordKey", "schemaId", "storedAt"]); + const decodedBare = decodeMsgpack(encode(bare)) as { document: Record }; + assert.ok(Object.values(decodedBare.document).every(value => value !== null), "no nil values on the wire"); + assert.ok(!("sourceRuntimeId" in decodedBare.document)); + + const stamped = registry.createDocumentPutMessage(binding, "m", "k", { body: "x" }, + { sourceRuntimeId: "r", sourceAgentId: "a", sourceRole: "role", tags: ["t"] }); + assert.deepEqual(decodeMsgpack(encode(stamped)), stamped); + assert.equal(stamped.document.sourceRuntimeId, "r"); + assert.deepEqual(stamped.document.tags, ["t"]); +}); + +test("CultNet registry names the canonical schema id of a compatible alias, and none for an unknown or conflicted id", () => { + const shape = z.object({ body: z.string() }); + const owner = defineDocumentType({ type: "p3.owner", schemaId: "p3.owner.v2", compatibleSchemaIds: ["p3.owner.v1"], schema: shape }); + const first = defineDocumentType({ type: "p3.first", schemaId: "p3.first", compatibleSchemaIds: ["p3.shared"], schema: shape }); + const second = defineDocumentType({ type: "p3.second", schemaId: "p3.second", compatibleSchemaIds: ["p3.shared"], schema: shape }); + const registry = new CultNetDocumentRegistry([owner, first, second].map(definition => defineCultNetDocumentBinding({ definition }))); + assert.equal(registry.canonicalSchemaId("p3.owner.v1"), "p3.owner.v2"); + assert.equal(registry.canonicalSchemaId("p3.owner.v2"), "p3.owner.v2"); + assert.equal(registry.canonicalSchemaId("p3.nobody"), undefined); + assert.equal(registry.canonicalSchemaId("p3.shared"), undefined, "an id several bindings claim resolves to none"); +}); diff --git a/packages/cultnet-ts/test/first-call.test.ts b/packages/cultnet-ts/test/first-call.test.ts new file mode 100644 index 00000000..977a6e5b --- /dev/null +++ b/packages/cultnet-ts/test/first-call.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; + +// The TypeScript CultNet first-call snippet that docs/start/README.md shows: define a typed document, send it +// over a loopback TCP pipe as a cultnet.document_put_raw.v0 message, receive it into a second cache. The docs show +// the region between the `snippet` markers verbatim, except that the package import reads +// "@gamecult/cultnet-ts" where this file reads "../src"; the docs test below fails when they differ. +// snippet:begin +import { createServer, connect } from "node:net"; +import { z } from "zod"; +import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts"; +import { + CultNetDocumentRegistry, + createTcpFramedCultNetPeer, + defineCultNetDocumentBinding, +} from "../src"; + +const noteDocument = defineDocumentType({ + type: "note", + schemaId: "gamecult.first_call_note.v1", + schemaName: "gamecult.first_call_note", + schemaVersion: "gamecult.first_call_note.v1", + schema: z.object({ title: z.string(), body: z.string() }), + name: "title", +}); +const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]); + +async function firstCall() { + const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const; + const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache + + // Receiver: a loopback listener whose peer applies each document put to the inbox. + const received = new Promise((resolve, reject) => { + const server = createServer((socket) => { + const peer = createTcpFramedCultNetPeer(socket, options); + peer.on("invalidMessage", reject); + peer.on("message", async (message) => { + if (message.schemaVersion !== "cultnet.document_put_raw.v0") return; + await documents.applyRawDocumentPutMessage(inbox, message); + peer.close(); + server.close(); + resolve(); + }); + }).listen(0, "127.0.0.1", () => { + // Sender: connect, build a typed put message, send it. + const { port } = server.address() as { port: number }; + const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options); + sender.send( + documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", { + title: "Hello", + body: "sent over CultNet", + }), + ); + received.finally(() => sender.close()); + }); + }); + await received; + return inbox.get(noteDocument, "note:hello"); +} +// snippet:end + +test("send a typed document over loopback and receive it into a second cache", async () => { + const note = await firstCall(); + assert.deepEqual(note, { title: "Hello", body: "sent over CultNet" }); +}); + +test("docs show the tested snippet", async () => { + // Compiled to dist-test/test/first-call.test.js, so the package root is two levels up. + const source = await readFile(join(__dirname, "..", "..", "test", "first-call.test.ts"), "utf8"); + const snippet = source + .split("// snippet:begin\n")[1] + .split("// snippet:end\n")[0] + .replace('"../src"', '"@gamecult/cultnet-ts"'); + const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n"); + assert.ok(docs.includes("```ts\n" + snippet + "```"), "docs/start/README.md no longer shows test/first-call.test.ts"); +}); diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 2aff6d92..2b45e3b1 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -3,8 +3,9 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import dgram from "node:dgram"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; -import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts"; +import { StoreUnreadableError, documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts"; import { decode, encode } from "@msgpack/msgpack"; import { @@ -55,7 +56,7 @@ function expectedFacts(set: FixtureSet) { function openAuthority( context: TestContext, set: FixtureSet, - options: { machineId?: "etc" | "dbus"; lease?: string } = {}, + options: { machineId?: "etc" | "dbus"; lease?: string; bundle?: string } = {}, ): IdunnRuntimeAuthority { const facts = expectedFacts(set); const originalEnvironment = { ...process.env }; @@ -63,7 +64,7 @@ function openAuthority( for (const key of Object.keys(process.env)) if (!(key in originalEnvironment)) delete process.env[key]; Object.assign(process.env, originalEnvironment); }); - process.env.GAMECULT_IDUNN_RUNTIME_BUNDLE = path.join(FIXTURES, set); + process.env.GAMECULT_IDUNN_RUNTIME_BUNDLE = options.bundle ?? path.join(FIXTURES, set); process.env.GAMECULT_IDUNN_CANDIDATE_BIND = facts.candidate; if (options.lease) process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE = path.join(FIXTURES, set, options.lease); else delete process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE; @@ -85,6 +86,22 @@ function openAuthority( return loadIdunnRuntimeAuthorityFromEnvironment(facts.target, facts.healthContract); } +/** A copy of one Rust-written bundle in a scratch directory, with `change` applied to the bytes of the named file. */ +function bundleWith(context: TestContext, set: FixtureSet, file: string, change: (bytes: Buffer) => Buffer | undefined): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "idunn-authority-")); + context.after(() => fs.rmSync(dir, { recursive: true, force: true })); + for (const name of ["expected.cc", "activation.cc"]) { + const bytes = fixtureFile(set, name); + const changed = name === file ? change(bytes) : bytes; + if (changed !== undefined) fs.writeFileSync(path.join(dir, name), changed); + } + return dir; +} + +function withHeader(header: string): (bytes: Buffer) => Buffer { + return (bytes) => Buffer.from(encode([header, ...(decode(bytes) as unknown[]).slice(1)])); +} + function signerFor(context: TestContext, set: FixtureSet, capacityDelta = 0, lease?: string) { const authority = openAuthority(context, set, { lease }); const capabilities = expectedFacts(set).capabilities.map((capability) => ({ ...capability, capacity: capability.capacity + capacityDelta })); @@ -134,6 +151,70 @@ test("rejects a credential file holding more than one envelope", (context) => { ); }); +// The authority files are read by cultcache-ts's one store reader, so a store it reads is read here and a file it +// refuses is refused here with the same typed error and the same text. +for (const file of ["expected.cc", "activation.cc"]) { + test(`${file}: a store marked for element ids opens, as the one reader reads it`, (context) => { + const bundle = bundleWith(context, "web", file, withHeader("cultcache.store.v3")); + assert.equal(openAuthority(context, "web", { bundle }).expected.target, expectedFacts("web").target); + }); + + test(`${file}: a zero-byte file is refused as unreadable, not read as an empty store or a missing one`, (context) => { + const bundle = bundleWith(context, "web", file, () => Buffer.alloc(0)); + assert.throws(() => openAuthority(context, "web", { bundle }), StoreUnreadableError); + }); + + test(`${file}: a file that is not there is refused`, (context) => { + const bundle = bundleWith(context, "web", file, () => undefined); + assert.throws(() => openAuthority(context, "web", { bundle }), /does not exist/); + }); + + test(`${file}: a refused header is never echoed, whether or not it starts at the store prefix`, (context) => { + for (const header of ["cultcache.store.SECRET", "cultcache.store.SECRETcultcache.store.v9", "SECRETcultcache.store.v9", "cultcache.store.v9"]) { + const bundle = bundleWith(context, "web", file, withHeader(header)); + assert.throws( + () => openAuthority(context, "web", { bundle }), + (error: Error) => error instanceof StoreUnreadableError && (header === "cultcache.store.v9" ? error.message.includes(header) : !error.message.includes("SECRET")), + header, + ); + } + }); +} + +// What an authority file may hold once the one reader has accepted it: exactly one record, under the schema +// id and the type the loader asked for. Each case edits the Rust-written store and changes one thing. +function withStore(change: (store: unknown[][]) => void): (bytes: Buffer) => Buffer { + return (bytes) => { + const store = decode(bytes) as unknown[][]; + change(store); + return Buffer.from(encode(store)); + }; +} + +test("an authority file holding two records is refused", (context) => { + const bundle = bundleWith(context, "web", "expected.cc", withStore((store) => { + const second = [...(store[2]![0] as unknown[])]; + second[0] = "second"; + store[2]!.push(second); + })); + assert.throws(() => openAuthority(context, "web", { bundle }), /must contain exactly one record/); +}); + +test("an authority file whose one record is under another schema id is refused", (context) => { + const bundle = bundleWith(context, "web", "expected.cc", () => fixtureFile("web", "activation.cc")); + assert.throws( + () => openAuthority(context, "web", { bundle }), + (error: Error) => /must contain exactly one .+ record/.test(error.message) && !error.message.includes("unexpected CultCache type"), + ); +}); + +test("an authority record whose catalog names another type is refused, naming that type", (context) => { + const bundle = bundleWith(context, "web", "expected.cc", withStore((store) => { + (store[1]![0] as unknown[])[1] = "idunn.other_type"; + })); + assert.throws(() => openAuthority(context, "web", { bundle }), /unexpected CultCache type idunn.other_type/); +}); + function presenceSlot(document: { payload: DocumentPayload }, slot: number): unknown { return (decode(documentPayloadBytes(document.payload)) as unknown[])[slot]; } @@ -380,13 +461,13 @@ function loadUnderDgramStub(modulePath: string): string { } test("the idunn-runtime subpath loads the signer and loader without loading dgram; the package root does load it", () => { - const subpath = require.resolve("cultnet-ts/idunn-runtime"); + const subpath = require.resolve("@gamecult/cultnet-ts/idunn-runtime"); assert.equal(loadUnderDgramStub(subpath), "loaded"); - assert.equal(loadUnderDgramStub(require.resolve("cultnet-ts")), "failed: dgram was loaded"); + assert.equal(loadUnderDgramStub(require.resolve("@gamecult/cultnet-ts")), "failed: dgram was loaded"); const exported = require(subpath) as Record; assert.equal(typeof exported.createIdunnRuntimeSigner, "function"); assert.equal(typeof exported.loadIdunnRuntimeAuthorityFromEnvironment, "function"); assert.equal(exported.createIdunnRuntimePresencePublisher, undefined); assert.equal(exported.signIdunnRuntimePresence, undefined); - assert.equal((require("cultnet-ts") as Record).signIdunnRuntimePresence, undefined); + assert.equal((require("@gamecult/cultnet-ts") as Record).signIdunnRuntimePresence, undefined); }); diff --git a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts index 598d6fe6..d0e732ce 100644 --- a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts +++ b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts @@ -1286,9 +1286,9 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state schemaVersion: INTEROP_SCHEMA_VERSION, schemaId: interopNoteSchemaId, }, - [{ role: "payload", schemaId: interopNoteSchemaId, schemaVersion: INTEROP_SCHEMA_VERSION }], - [{ role: "log", uri: "cultcache://bundle:ts-python-witness/log", mediaType: "text/plain" }], - [{ stage: "roundtrip", startedAt: "2026-06-13T00:00:03Z", completedAt: "2026-06-13T00:00:04Z", latencyMs: 1 }], + [{ id: "c0e1a2b3c4d5", role: "payload", schemaId: interopNoteSchemaId, schemaVersion: INTEROP_SCHEMA_VERSION }], + [{ id: "a0e1a2b3c4d5", role: "log", uri: "cultcache://bundle:ts-python-witness/log", mediaType: "text/plain" }], + [{ id: "e0e1a2b3c4d5", stage: "roundtrip", startedAt: "2026-06-13T00:00:03Z", completedAt: "2026-06-13T00:00:04Z", latencyMs: 1 }], { pipelineId: "interop", runId: "ts-python-witness", runtimeId: "ts-witness", agentId: "ts-agent" }, ]), sourceRuntimeId: "ts-witness", @@ -1306,6 +1306,7 @@ test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state assert.equal(witnessSlots[1], "interop-proof"); assert.equal(witnessSlots[3].subjectId, "note:python-peer"); assert.equal(witnessSlots[7].runtimeId, "ts-witness"); + assert.deepEqual([witnessSlots[4][0].id, witnessSlots[5][0].id, witnessSlots[6][0].id], ["c0e1a2b3c4d5", "a0e1a2b3c4d5", "e0e1a2b3c4d5"], "element ids cross TS to Python and back"); const expectedPeers = ["csharp-peer", "python-peer", "rust-peer", "ts-peer"]; diff --git a/packages/cultnet-ts/test/rudp-server.test.ts b/packages/cultnet-ts/test/rudp-server.test.ts new file mode 100644 index 00000000..6ba976d3 --- /dev/null +++ b/packages/cultnet-ts/test/rudp-server.test.ts @@ -0,0 +1,196 @@ +// One server socket serves many peers, each with its own reliable session. +import assert from "node:assert/strict"; +import test from "node:test"; +import dgram, { type Socket } from "node:dgram"; +import { + CultNetRudpSession, + CultNetRudpSocketTransportConnection, + CultNetRudpSocketTransportServer, + decodeRudpPacket, + encodeRudpPacket, + type CultNetRudpSocketServerPeer, +} from "../src"; + +const connectionId = 0x10203091; +const enc = (text: string) => new TextEncoder().encode(text); +const dec = (bytes: Uint8Array) => new TextDecoder().decode(bytes); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function bind(): Promise { + const socket = dgram.createSocket("udp4"); + await new Promise((resolve) => socket.bind(0, "127.0.0.1", resolve)); + return socket; +} + +async function waitFor(predicate: () => boolean, description: string): Promise { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > 3_000) throw new Error(`Timed out waiting for ${description}.`); + await sleep(5); + } +} + +type Client = { connection: CultNetRudpSocketTransportConnection; socket: Socket; received: string[]; closed: () => boolean }; + +async function startServer(extra: Record = {}) { + const socket = await bind(); + const server = new CultNetRudpSocketTransportServer({ + runtimeId: "many-peer-server", socket, connectionId, resendDelayMs: 10, resendPollMs: 5, ...extra, + }); + const requests: { peer: CultNetRudpSocketServerPeer; text: string }[] = []; + const connected: CultNetRudpSocketServerPeer[] = []; + const left: { peer: CultNetRudpSocketServerPeer; reason: string }[] = []; + server.on("peerConnected", (peer: CultNetRudpSocketServerPeer) => connected.push(peer)); + server.on("peerDisconnected", ({ peer, reason }: { peer: CultNetRudpSocketServerPeer; reason: Uint8Array }) => left.push({ peer, reason: dec(reason) })); + // Every request is answered on the peer it arrived from. + server.on("frame", ({ peer, payload }: { peer: CultNetRudpSocketServerPeer; payload: Uint8Array }) => { + requests.push({ peer, text: dec(payload) }); + server.send(peer, "schema", enc(`re:${dec(payload)}`)); + }); + const port = (socket.address() as { port: number }).port; + return { server, port, requests, connected, left }; +} + +async function startClient(port: number, connectPayload = new Uint8Array()): Promise { + const socket = await bind(); + const connection = new CultNetRudpSocketTransportConnection({ + runtimeId: "client", socket, mode: "client", remoteHost: "127.0.0.1", remotePort: port, + connectionId, resendDelayMs: 10, resendPollMs: 5, + }); + const received: string[] = []; + let closed = false; + connection.on("frame", ({ payload }: { payload: Uint8Array }) => received.push(dec(payload))); + connection.on("close", () => { closed = true; }); + connection.connect(connectPayload); + await waitFor(() => connection.connected, "client handshake"); + return { connection, socket, received, closed: () => closed }; +} + +/** A bare session on a socket, so a goodbye can be sent and the socket released only after it left. */ +async function rawClient(port: number) { + const socket = await bind(); + const session = new CultNetRudpSession({ connectionId }); + const transmit = (packet: ReturnType) => + new Promise((resolve) => socket.send(encodeRudpPacket(packet), port, "127.0.0.1", () => resolve())); + socket.on("message", (wire) => { session.receive(decodeRudpPacket(wire), Date.now()); }); + await transmit(session.createConnect(Date.now())); + await waitFor(() => session.connected, "raw client handshake"); + return { async leave() { await transmit(session.createDisconnect()); socket.close(); } }; +} + +test("two clients on different ports talk through one server socket and neither resets the other", async () => { + const { server, port, requests } = await startServer(); + const a = await startClient(port, enc("alpha")); + const b = await startClient(port, enc("beta")); + try { + assert.equal(server.peers.length, 2); + assert.deepEqual(server.peers.map((peer) => dec(peer.connectPayload)).sort(), ["alpha", "beta"]); + for (let round = 0; round < 5; round += 1) { + a.connection.send("schema", enc(`a${round}`)); + b.connection.send("schema", enc(`b${round}`)); + } + await waitFor(() => a.received.length === 5 && b.received.length === 5, "both clients' replies"); + assert.deepEqual(a.received, [0, 1, 2, 3, 4].map((n) => `re:a${n}`)); + assert.deepEqual(b.received, [0, 1, 2, 3, 4].map((n) => `re:b${n}`)); + assert.equal(requests.length, 10); + assert.equal(a.connection.connected && b.connection.connected, true); + assert.equal(a.closed() || b.closed(), false); + assert.equal(server.peers.length, 2); + } finally { + a.connection.close(); b.connection.close(); await server.close(); + } +}); + +test("a client reconnecting from the same endpoint replaces only its own session", async () => { + const { server, port, connected, left } = await startServer(); + const a = await startClient(port); + const b = await startClient(port); + try { + const oldPeer = connected[0]!; + a.connection.connect(); + await waitFor(() => connected.length === 3, "the reconnect to be accepted"); + assert.equal(left.length, 1); + assert.equal(left[0]!.peer, oldPeer); + assert.equal(left[0]!.reason, "replaced by a new Connect generation"); + assert.throws(() => server.send(oldPeer, "schema", enc("stale")), /not connected/); + assert.equal(server.peers.length, 2); + + a.connection.send("schema", enc("after")); + b.connection.send("schema", enc("still")); + await waitFor(() => a.received.includes("re:after") && b.received.includes("re:still"), "replies after the reconnect"); + assert.equal(b.closed(), false); + } finally { + a.connection.close(); b.connection.close(); await server.close(); + } +}); + +test("a peer that leaves, or goes silent, is dropped and the others keep their sessions", async () => { + const { server, port, left } = await startServer({ peerTimeoutMs: 150 }); + const leaver = await rawClient(port); + const silent = await startClient(port); + const steady = await startClient(port); + try { + await leaver.leave(); + await waitFor(() => server.peers.length === 2, "the leaver to be dropped"); + assert.deepEqual(left.map((entry) => entry.reason), [""]); + + // Only the steady peer speaks; the silent one is reaped and told so. + const keepAlive = setInterval(() => steady.connection.ping(), 20); + try { + await waitFor(() => server.peers.length === 1, "the silent peer to be reaped"); + } finally { + clearInterval(keepAlive); + } + assert.ok(left.some((entry) => entry.reason === "session timed out")); + await waitFor(() => silent.closed(), "the reaped client to hear the goodbye"); + assert.equal(steady.closed(), false); + steady.connection.send("schema", enc("alive")); + await waitFor(() => steady.received.includes("re:alive"), "the surviving peer's reply"); + } finally { + silent.connection.close(); steady.connection.close(); await server.close(); + } +}); + +test("Connects beyond maxPeers and datagrams from unadmitted endpoints are dropped", async () => { + const { server, port } = await startServer({ maxPeers: 1 }); + const first = await startClient(port); + const stranger = await bind(); + try { + const wire = Buffer.from(encodeRudpPacket(new CultNetRudpSession({ connectionId }).createConnect(Date.now()))); + stranger.send(wire, port, "127.0.0.1"); + const data = new CultNetRudpSession({ connectionId, initialSequence: 9 }); + data.createConnect(0); + stranger.send(Buffer.from(encodeRudpPacket(data.createPing())), port, "127.0.0.1"); + await waitFor(() => server.stats.packetsDropped >= 1, "the extra Connect to be dropped"); + await sleep(30); + assert.equal(server.peers.length, 1); + first.connection.send("schema", enc("ok")); + await waitFor(() => first.received.includes("re:ok"), "the admitted peer's reply"); + } finally { + stranger.close(); first.connection.close(); await server.close(); + } +}); + +test("close() delivers a Disconnect to every peer before the socket is released", async () => { + const { server, port } = await startServer(); + const peers = await Promise.all([0, 1, 2].map(async () => { + const socket = await bind(); + const session = new CultNetRudpSession({ connectionId }); + const types: string[] = []; + socket.on("message", (wire) => { + const packet = decodeRudpPacket(wire); + types.push(packet.packetType); + session.receive(packet, Date.now()); + }); + socket.send(encodeRudpPacket(session.createConnect(Date.now())), port, "127.0.0.1"); + await waitFor(() => session.connected, "raw handshake"); + return { socket, types }; + })); + try { + assert.equal(server.peers.length, 3); + await server.close(); + await waitFor(() => peers.every((peer) => peer.types.includes("disconnect")), "every peer to observe the Disconnect"); + } finally { + peers.forEach((peer) => peer.socket.close()); + } +}); diff --git a/packages/heimdall-consent/Cargo.lock b/packages/heimdall-consent/Cargo.lock new file mode 100644 index 00000000..758d85ea --- /dev/null +++ b/packages/heimdall-consent/Cargo.lock @@ -0,0 +1,716 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cultcache-rs" +version = "0.3.1" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive", + "fs2", + "redb", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "serde_stacker", + "stacker", + "uuid", + "windows-sys 0.59.0", +] + +[[package]] +name = "cultcache-rs-derive" +version = "0.2.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "heimdall-consent" +version = "0.1.0" +dependencies = [ + "anyhow", + "cultcache-rs", + "serde", + "tempfile", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redb" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b317ef681e18f6091efbfab532eaf924a135c492d9d0299d0fbfae07e92a39b" +dependencies = [ + "libc", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_stacker" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" +dependencies = [ + "serde", + "serde_core", + "stacker", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/heimdall-consent/Cargo.toml b/packages/heimdall-consent/Cargo.toml new file mode 100644 index 00000000..9c2b6729 --- /dev/null +++ b/packages/heimdall-consent/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "heimdall-consent" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[lib] +name = "heimdall_consent" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +cultcache-rs = { path = "../cultcache-rs" } +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +tempfile = "3" diff --git a/packages/heimdall-consent/README.md b/packages/heimdall-consent/README.md new file mode 100644 index 00000000..3782e720 --- /dev/null +++ b/packages/heimdall-consent/README.md @@ -0,0 +1,36 @@ +# heimdall-consent + +Typed CultCache records for Heimdall's answer collector: `NoticeAcceptance`, `Answer` and `MemberTerms`, +plus the rules that need no store to state (`Answer::is_training_data`, `Answer::redacted`, +`validate`). Cut from `docs/answer-collector-consent.md` in GameCult/Heimdall PR #6 at doc commit +`aaeb175` (PR head); the doc's tables and Representation section are the contract, and its rulings are on the PR +(comments 682 and 759). Specification only: no collector, tally, export or tombstone hashing lives here. + +Entry points: `src/lib.rs`; golden vectors `tests/vectors/consent_golden.csv`, run by `tests/golden.rs` +(`cargo test`). Planned, not live: the collector, the tally, the training export. + +## Where each ruling lands (doc lines at aaeb175) + +| Ruling | Doc | Crate | +|---|---|---| +| Timestamps are Unix ms UTC, signed 64-bit | L118-119; `received_at` is collector-assigned, L95, L208-209 | `i64` on `accepted_at`, `received_at`, `terms_accepted_at` | +| Ids are non-empty opaque strings, `event_id` verbatim; every version field (`notice_version`, the `@N` of a sample id, `terms_version`) is a positive integer, no leading zeros, no sign, at most 4294967295 (Heimdall ruling 2026-11-07, one rule; doc line "Version rule" under Representation, Heimdall PR #6 commit d6a8d42, now in the doc at aaeb175) | L120-136 | `check_version` is the single rule; `check_sample_id` and `MemberTerms::validate` call it; `notice_version: u32` with `validate()` refusing 0; golden vector `newcomer` carries `terms_version` 4294967295, and case `one-version-rule` pins 4294967295 accepted, 4294967296 and `01` refused for `notice_version`, the sample id `@N` and `terms_version` | +| Key parts joined with U+001F; empty part or part containing it refused | L137-138 | `key()` | +| Tombstone is the exited `Answer` with `text_hash` (32 bytes); HMAC-SHA-256 keyed by a per-member salt held by the member-side service; the collector cannot compute it | L94, L165-185, L221-226 | `Answer.text_hash`; `redacted([u8; 32])` takes the service's output and refuses an answer with no `text` (L181-182); `validate()` requires 32 bytes and `exited` | +| A reply with no established notice version or scope is stored with both absent, writes no acceptance, is excluded from every use | L107-114 | `notice_version`/`scope` optional and absent together (`validate()`); vector case `no-scope` | +| A reply's version is the one pinned at its `origin_server_ts`; changed version needs a fresh first reply | L19-20, L49-51, L112-114 | acceptance key includes the version; `is_training_data` accepts only the acceptance for the answer's own version; vector case `version-change` | +| `MemberTerms` is the typed shape of the member record's `terms_version`/`terms_accepted_at`, not a second store | L68-76 | `MemberTerms` doc comment; the collector does not hold it | +| Training needs acceptance `scope = train` equal to the answer's `scope`, and `accepted_at <= received_at` | L149-156 | `is_training_data` | +| One store per scope plus a store for scopeless replies | L158-163 | not modelled; the collector's layout owns it | + +Rulings of comment 759 (doc L92, L98-105; no open questions remain): + +| Ruling | Doc | Crate | +|---|---|---| +| A live answer must have non-empty `text` and is rejected otherwise; empty text is valid only on an exited answer (text cleared, hash-only tombstone) | L92, L103-105 | `Answer::validate()` refuses a non-exited answer whose `text` is absent or empty; an exited answer holds no text. Vector cases `empty-live-rejected`, `empty-revoked` | +| `received_at` is assigned by the collector; a tie on it breaks by `event_id` compared bytewise, the greater wins as the later reply | L95, L98-101 | `Answer::supersedes`: `(received_at, event_id bytes)` order; the vector harness replaces a stored answer only when the incoming one supersedes it. Vector cases `tie-greater-first`, `tie-greater-last` | + +Not modelled (doc at aaeb175): + +- The `org.gamecult.notice` room state event (L24-37, L132-136): its content is `{version, scope}` with `version` a JSON number, and a string (including `"01"`) is malformed. The crate checks the version only as decimal text (`check_version`); reading, power-level checks and the malformed-means-`show` fallback belong to the collector. +- The registration-checkbox gap (L78-79): the `m.login.terms` checkbox records no version. The doc rules nothing on it; the crate takes `terms_version` as given. diff --git a/packages/heimdall-consent/src/lib.rs b/packages/heimdall-consent/src/lib.rs new file mode 100644 index 00000000..61ecb188 --- /dev/null +++ b/packages/heimdall-consent/src/lib.rs @@ -0,0 +1,225 @@ +//! Typed CultCache records for Heimdall's answer collector, cut from `docs/answer-collector-consent.md` +//! in GameCult/Heimdall PR #6 (doc commit aaeb175): the notice acceptance record, the answer record, +//! and the typed shape of the terms pair on the member record. Field names and types follow the +//! doc's tables and its Representation section; README.md cites the doc lines. + +use anyhow::{Result, bail}; +use cultcache_rs::DatabaseEntry; +use serde::{Deserialize, Serialize}; + +/// What a notice allows, each level including the ones below it. `Train` is declared in terms by the +/// room's notice; nothing here widens a scope. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Scope { + Show, + Study, + Train, +} + +/// Separates key parts (Representation: parts joined with U+001F; an empty part or one containing it +/// is refused, not escaped). +const SEP: char = '\u{1f}'; + +fn key(parts: &[&str]) -> Result { + if let Some(p) = parts.iter().find(|p| p.is_empty() || p.contains(SEP)) { + bail!("key part {p:?} is empty or contains the key separator"); + } + Ok(parts.join(&SEP.to_string())) +} + +fn nonempty(name: &str, value: &str) -> Result<()> { + if value.is_empty() { + bail!("{name} must be a non-empty string"); + } + Ok(()) +} + +/// The one version rule (Heimdall ruling 2026-11-07): a positive integer written without leading +/// zeros or a sign, at most 4294967295 (`u32::MAX`). `01` and `1` would be two keys for one version. +pub fn check_version(version: &str) -> Result { + match version.parse::() { + Ok(v) if v > 0 && !version.starts_with(['+', '0']) => Ok(v), + _ => bail!("version {version:?} must be a positive integer, no leading zeros, at most {}", u32::MAX), + } +} + +/// `@`, the version under `check_version`. The version is part of the id, so it is +/// part of every answer key and no key can name a sample without one. +pub fn check_sample_id(sample_id: &str) -> Result<(&str, u32)> { + let Some((sample, version)) = sample_id.rsplit_once('@') else { + bail!("sample id {sample_id:?} has no @version"); + }; + if sample.is_empty() { + bail!("sample id {sample_id:?} needs a non-empty sample"); + } + Ok((sample, check_version(version)?)) +} + +/// The first reply a member posts under a notice version. Written once, never updated. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "heimdall.notice-acceptance", schema = "HeimdallNoticeAcceptance")] +pub struct NoticeAcceptance { + #[cultcache(key = 0)] + pub member_id: String, + #[cultcache(key = 1)] + pub experiment_id: String, + #[cultcache(key = 2)] + pub notice_version: u32, + #[cultcache(key = 3)] + pub scope: Scope, + /// `origin_server_ts` of the first reply, unix milliseconds UTC. + #[cultcache(key = 4)] + pub accepted_at: i64, + #[cultcache(key = 5)] + pub event_id: String, +} + +impl NoticeAcceptance { + /// Representation: ids are non-empty strings, versions positive. + pub fn validate(&self) -> Result<()> { + nonempty("member_id", &self.member_id)?; + nonempty("experiment_id", &self.experiment_id)?; + nonempty("event_id", &self.event_id)?; + if self.notice_version == 0 { + bail!("notice_version must be positive"); + } + Ok(()) + } + + /// `(experiment_id, member_id, notice_version)`. + pub fn cache_key(experiment_id: &str, member_id: &str, notice_version: u32) -> Result { + key(&[experiment_id, member_id, ¬ice_version.to_string()]) + } +} + +/// One answer. `notice_version` and `scope` are absent together on a reply with no established +/// notice version or no matching acceptance: such a reply is stored, excluded from every use, and +/// writes no acceptance (Answer record, "A reply whose `notice_version` or `scope` is missing"). +/// Once exited the record is the tombstone: `text` absent, `text_hash` set. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "heimdall.answer", schema = "HeimdallAnswer")] +pub struct Answer { + #[cultcache(key = 0)] + pub sample_id: String, + #[cultcache(key = 1)] + pub experiment_id: String, + #[cultcache(key = 2)] + pub member_id: String, + #[cultcache(key = 3, default)] + pub notice_version: Option, + #[cultcache(key = 4, default)] + pub scope: Option, + /// Absent once exited. + #[cultcache(key = 5, default)] + pub text: Option, + /// One-way: set by redaction, never cleared. + #[cultcache(key = 6, default)] + pub exited: bool, + /// Assigned by the collector on receipt, unix milliseconds UTC, not copied from the reply (doc L95); + /// ties are broken by `supersedes`. + #[cultcache(key = 7)] + pub received_at: i64, + #[cultcache(key = 8)] + pub event_id: String, + /// The tombstone hash of `text`: HMAC-SHA-256, 32 raw bytes, computed by the member-side service + /// (it holds the salt). Present only once exited, and only if the collector held `text`. + #[cultcache(key = 9, default)] + pub text_hash: Option>, +} + +impl Answer { + /// `(experiment_id, sample_id, member_id)`; refuses a sample id without a positive version. + pub fn cache_key(experiment_id: &str, sample_id: &str, member_id: &str) -> Result { + check_sample_id(sample_id)?; + key(&[experiment_id, sample_id, member_id]) + } + + /// Representation and the invariants the table implies. + pub fn validate(&self) -> Result<()> { + nonempty("member_id", &self.member_id)?; + nonempty("experiment_id", &self.experiment_id)?; + nonempty("event_id", &self.event_id)?; + check_sample_id(&self.sample_id)?; + if self.notice_version == Some(0) { + bail!("notice_version must be positive"); + } + if self.notice_version.is_some() != self.scope.is_some() { + bail!("notice_version and scope are present together or absent together"); + } + if self.exited && self.text.is_some() { + bail!("an exited answer holds no text"); + } + if !self.exited && self.text.as_deref().is_none_or(str::is_empty) { + bail!("a live answer needs non-empty text; empty text is valid only on a revoked answer"); + } + if let Some(hash) = &self.text_hash { + if !self.exited || hash.len() != 32 { + bail!("text_hash is 32 bytes and only on an exited answer"); + } + } + Ok(()) + } + + /// Replacement rule for two answers at one key: the later reply is the greater `(received_at, + /// event_id)`. `received_at` is assigned by the collector; a tie breaks by `event_id` compared + /// bytewise, the greater winning (Heimdall PR #6 comment 759). Insertion order never decides. + pub fn supersedes(&self, earlier: &Answer) -> bool { + (self.received_at, self.event_id.as_bytes()) > (earlier.received_at, earlier.event_id.as_bytes()) + } + + /// Redaction is exit: text erased, `exited` set, the tombstone hash kept. The hash comes from the + /// member-side service, which holds the salt; this crate never computes it. A reply the collector + /// never collected has no text and gets no tombstone, so an answer without `text` is refused. + pub fn redacted(&self, text_hash: [u8; 32]) -> Result { + if self.text.is_none() { + bail!("no text held for event {:?}: nothing to tombstone", self.event_id); + } + Ok(Answer { text: None, exited: true, text_hash: Some(text_hash.to_vec()), ..self.clone() }) + } + + /// Training rule (doc "Training rule"): scope `train`; an acceptance for this answer's + /// `(experiment_id, member_id, notice_version)` whose own scope is `train` and equals the answer's, + /// with `accepted_at <= received_at`; not exited. A changed notice version needs its own acceptance, + /// so an acceptance for another version never covers the answer. No recorded scope, version or + /// acceptance means excluded. + pub fn is_training_data(&self, acceptance: Option<&NoticeAcceptance>) -> bool { + let (Some(Scope::Train), Some(version), Some(acc)) = (self.scope, self.notice_version, acceptance) else { + return false; + }; + !self.exited + && acc.notice_version == version + && acc.experiment_id == self.experiment_id + && acc.member_id == self.member_id + && acc.scope == Scope::Train + && Some(acc.scope) == self.scope + && acc.accepted_at <= self.received_at + } +} + +/// The typed shape of the `terms_version` / `terms_accepted_at` pair on the Heimdall member record +/// (newcomers only, recorded by the `m.login.terms` stage). It is not a second store and the +/// collector does not hold it. Members who joined before the stage, and Minds, have no such pair. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "heimdall.member-terms", schema = "HeimdallMemberTerms")] +pub struct MemberTerms { + #[cultcache(key = 0)] + pub member_id: String, + #[cultcache(key = 1)] + pub terms_version: String, + /// Unix milliseconds UTC. + #[cultcache(key = 2)] + pub terms_accepted_at: i64, +} + +impl MemberTerms { + pub fn validate(&self) -> Result<()> { + nonempty("member_id", &self.member_id)?; + check_version(&self.terms_version).map(drop) + } + + /// `member_id`. + pub fn cache_key(member_id: &str) -> Result { + key(&[member_id]) + } +} diff --git a/packages/heimdall-consent/tests/golden.rs b/packages/heimdall-consent/tests/golden.rs new file mode 100644 index 00000000..615d5cb4 --- /dev/null +++ b/packages/heimdall-consent/tests/golden.rs @@ -0,0 +1,470 @@ +use cultcache_rs::{CultCache, SingleFileMessagePackBackingStore}; +use heimdall_consent::*; + +const VECTORS: &str = include_str!("vectors/consent_golden.csv"); + +fn new_cache(dir: &tempfile::TempDir) -> CultCache { + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + let _ = cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("consent.cc"))); + cache.pull_all_backing_stores().unwrap(); + cache +} + +fn scope(s: &str) -> Option { + match s { + "" => None, + "show" => Some(Scope::Show), + "study" => Some(Scope::Study), + "train" => Some(Scope::Train), + other => panic!("unknown scope {other}"), + } +} + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +fn put_answer(cache: &mut CultCache, a: &Answer) { + cache.put(Answer::cache_key(&a.experiment_id, &a.sample_id, &a.member_id).unwrap(), a).unwrap(); +} + +/// The collector's replace rule: an incoming answer replaces the stored one only if it is the later reply. +fn put_if_later(cache: &mut CultCache, a: &Answer) { + let k = Answer::cache_key(&a.experiment_id, &a.sample_id, &a.member_id).unwrap(); + if cache.get::(&k).unwrap().is_some_and(|old| !a.supersedes(&old)) { + return; + } + cache.put(k, a).unwrap(); +} + +fn training_events(cache: &CultCache) -> Vec { + let mut ids: Vec = cache + .get_all::() + .unwrap() + .iter() + .filter(|a| { + let acc = a.notice_version.and_then(|v| { + cache.get::(&NoticeAcceptance::cache_key(&a.experiment_id, &a.member_id, v).unwrap()).unwrap() + }); + a.is_training_data(acc.as_ref()) + }) + .map(|a| a.event_id.clone()) + .collect(); + ids.sort(); + ids +} + +#[test] +fn golden_vectors() { + let mut cases: Vec<(&str, Vec>)> = Vec::new(); + for line in VECTORS.lines().filter(|l| !l.starts_with('#') && !l.trim().is_empty()) { + let f: Vec<&str> = line.split(',').collect(); + assert_eq!(f.len(), 10, "row `{line}`"); + match cases.last_mut() { + Some((name, rows)) if *name == f[0] => rows.push(f), + _ => cases.push((f[0], vec![f])), + } + } + assert_eq!(cases.len(), 10, "fixture shrank"); + for (name, rows) in cases { + let dir = tempfile::tempdir().unwrap(); + let mut cache = new_cache(&dir); + for f in rows { + let (op, member, room, nv, sc, sample, payload, ts, event) = (f[1], f[2], f[3], f[4], f[5], f[6], f[7], f[8], f[9]); + let ctx = format!("case {name} op {op} event {event}"); + let ts: i64 = ts.parse().unwrap_or(0); + let nv: Option = nv.parse().ok(); + match op { + "reply" | "answer_raw" => { + if op == "reply" { + if let (Some(v), Some(s)) = (nv, scope(sc)) { + let k = NoticeAcceptance::cache_key(room, member, v).unwrap(); + if cache.get::(&k).unwrap().is_none() { + cache + .put(k, &NoticeAcceptance { + member_id: member.into(), + experiment_id: room.into(), + notice_version: v, + scope: s, + accepted_at: ts, + event_id: event.into(), + }) + .unwrap(); + } + } + } + let answer = Answer { + sample_id: sample.into(), + experiment_id: room.into(), + member_id: member.into(), + notice_version: nv, + scope: scope(sc), + text: Some(payload.into()), + exited: false, + received_at: ts, + event_id: event.into(), + text_hash: None, + }; + answer.validate().unwrap(); + put_if_later(&mut cache, &answer); + } + "reply_rejected" => { + let answer = Answer { + sample_id: sample.into(), + experiment_id: room.into(), + member_id: member.into(), + notice_version: nv, + scope: scope(sc), + text: Some(payload.into()), + exited: false, + received_at: ts, + event_id: event.into(), + text_hash: None, + }; + assert!(answer.validate().is_err(), "{ctx}: must be refused"); + } + "answer_revoked" => { + let answer = Answer { + sample_id: sample.into(), + experiment_id: room.into(), + member_id: member.into(), + notice_version: nv, + scope: scope(sc), + text: None, + exited: true, + received_at: ts, + event_id: event.into(), + text_hash: if payload.is_empty() { None } else { Some(hex(payload)) }, + }; + answer.validate().unwrap(); + put_if_later(&mut cache, &answer); + } + "expect_winner" => { + let a = cache.get_required::(&Answer::cache_key(room, sample, member).unwrap()).unwrap(); + assert_eq!((a.event_id.as_str(), a.text.as_deref()), (event, Some(payload)), "{ctx}"); + } + "redact" => { + let k = Answer::cache_key(room, sample, member).unwrap(); + let a = cache.get_required::(&k).unwrap(); + assert_eq!(a.event_id, event, "{ctx}"); + let hash: [u8; 32] = hex(payload).try_into().expect("tombstone hash is 32 bytes"); + put_answer(&mut cache, &a.redacted(hash).unwrap()); + } + "terms" => { + let terms = MemberTerms { member_id: member.into(), terms_version: payload.into(), terms_accepted_at: ts }; + terms.validate().unwrap(); + cache.put(MemberTerms::cache_key(member).unwrap(), &terms).unwrap(); + } + "expect_terms" => { + let t = cache.get_required::(&MemberTerms::cache_key(member).unwrap()).unwrap(); + assert_eq!((t.member_id.as_str(), t.terms_version.as_str(), t.terms_accepted_at), (member, payload, ts), "{ctx}"); + } + "expect_no_terms" => assert!(cache.get::(&MemberTerms::cache_key(member).unwrap()).unwrap().is_none(), "{ctx}"), + "expect_counts" => { + let want = format!( + "answers={};acceptances={}", + cache.get_all::().unwrap().len(), + cache.get_all::().unwrap().len() + ); + assert_eq!(want, payload, "{ctx}"); + } + "expect_train" => assert_eq!(training_events(&cache).join(";"), payload, "{ctx}"), + "expect_exited" => { + let a = cache.get_required::(&Answer::cache_key(room, sample, member).unwrap()).unwrap(); + assert!(a.exited && a.text.is_none(), "{ctx}"); + assert_eq!(a.text_hash, Some(hex(payload)), "{ctx}"); + assert_eq!((a.event_id.as_str(), a.received_at, a.sample_id.as_str()), (event, ts, sample), "{ctx}"); + } + "expect_acceptance" => { + let a = cache + .get_required::(&NoticeAcceptance::cache_key(room, member, nv.unwrap()).unwrap()) + .unwrap(); + assert_eq!((a.notice_version, Some(a.scope), a.accepted_at), (nv.unwrap(), scope(sc), ts), "{ctx}"); + assert_eq!(a.event_id, payload, "{ctx}"); + } + "version_ok" | "version_refused" => { + // member column names the field; payload is the version text as it arrives + let ok = match member { + "notice_version" => check_version(payload).is_ok_and(|v| { + NoticeAcceptance { + member_id: "m".into(), + experiment_id: room.into(), + notice_version: v, + scope: Scope::Train, + accepted_at: 1, + event_id: "e".into(), + } + .validate() + .is_ok() + }), + "sample_id" => check_sample_id(&format!("s@{payload}")).is_ok(), + "terms_version" => { + MemberTerms { member_id: "m".into(), terms_version: payload.into(), terms_accepted_at: 1 }.validate().is_ok() + } + other => panic!("unknown version field {other}"), + }; + assert_eq!(ok, op == "version_ok", "{ctx} field {member} version {payload}"); + } + other => panic!("unknown op {other}"), + } + } + } +} + +#[test] +fn records_survive_a_reopen() { + let dir = tempfile::tempdir().unwrap(); + let mut cache = new_cache(&dir); + let a = Answer { + sample_id: "s@1".into(), + experiment_id: "r".into(), + member_id: "m".into(), + notice_version: Some(2), + scope: Some(Scope::Train), + text: None, + exited: true, + received_at: 5, + event_id: "e".into(), + text_hash: Some(vec![7; 32]), + }; + a.validate().unwrap(); + put_answer(&mut cache, &a); + let reopened = new_cache(&dir); + assert_eq!(reopened.get_required::(&Answer::cache_key("r", "s@1", "m").unwrap()).unwrap(), a); +} + +#[test] +fn key_refuses_unversioned_sample_ids() { + for bad in ["s", "s@", "s@0", "s@x", "@1", "s@-1", "s@+1", "s@4294967296", "s@01", "s@00", "s@001"] { + assert!(Answer::cache_key("r", bad, "m").is_err(), "{bad}"); + } + assert!(Answer::cache_key("r", "s@4294967295", "m").is_ok()); + assert!(Answer::cache_key("r", "s@1", "m").unwrap() != Answer::cache_key("r", "s@2", "m").unwrap()); +} + +#[test] +fn one_version_rule_everywhere() { + for ok in ["1", "2", "4294967295"] { + let n: u32 = ok.parse().unwrap(); + assert_eq!(check_version(ok).unwrap(), n, "{ok}"); + assert_eq!(check_sample_id(&format!("s@{ok}")).unwrap(), ("s", n), "s@{ok}"); + assert!(MemberTerms { member_id: "m".into(), terms_version: ok.into(), terms_accepted_at: 1 }.validate().is_ok(), "terms {ok}"); + } + for bad in ["0", "4294967296", "4294967297", "8589934593", "99999999999999999999", "01", "+1", "-1", "", "2026-11"] { + assert!(check_version(bad).is_err(), "{bad}"); + assert!(check_sample_id(&format!("s@{bad}")).is_err(), "s@{bad}"); + assert!(MemberTerms { member_id: "m".into(), terms_version: bad.into(), terms_accepted_at: 1 }.validate().is_err(), "terms {bad}"); + } +} + +#[test] +fn notice_version_zero_is_refused_in_both_records() { + let acc = NoticeAcceptance { + member_id: "m".into(), + experiment_id: "r".into(), + notice_version: 0, + scope: Scope::Train, + accepted_at: 1, + event_id: "e".into(), + }; + assert!(acc.validate().is_err()); + assert!(NoticeAcceptance { notice_version: u32::MAX, ..acc }.validate().is_ok()); +} + +#[test] +fn acceptance_after_the_answer_does_not_cover_it() { + let a = Answer { + sample_id: "s@1".into(), + experiment_id: "r".into(), + member_id: "m".into(), + notice_version: Some(1), + scope: Some(Scope::Train), + text: Some("t".into()), + exited: false, + received_at: 100, + event_id: "e".into(), + text_hash: None, + }; + let acc = |at| NoticeAcceptance { + member_id: "m".into(), + experiment_id: "r".into(), + notice_version: 1, + scope: Scope::Train, + accepted_at: at, + event_id: "e".into(), + }; + assert!(a.is_training_data(Some(&acc(100)))); + assert!(!a.is_training_data(Some(&acc(101)))); + assert!(!a.is_training_data(None)); +} + +fn answer() -> Answer { + Answer { + sample_id: "s@1".into(), + experiment_id: "r".into(), + member_id: "m".into(), + notice_version: Some(1), + scope: Some(Scope::Train), + text: Some("t".into()), + exited: false, + received_at: 100, + event_id: "e".into(), + text_hash: None, + } +} + +#[test] +fn acceptance_for_another_notice_version_never_covers_the_answer() { + let acc = NoticeAcceptance { + member_id: "m".into(), + experiment_id: "r".into(), + notice_version: 1, + scope: Scope::Train, + accepted_at: 1, + event_id: "e0".into(), + }; + let mut a = answer(); + assert!(a.is_training_data(Some(&acc))); + a.notice_version = Some(2); + assert!(!a.is_training_data(Some(&acc)), "a changed notice version needs a fresh first reply"); +} + +#[test] +fn training_needs_acceptance_scope_equal_to_the_answers_scope() { + let mut acc = NoticeAcceptance { + member_id: "m".into(), + experiment_id: "r".into(), + notice_version: 1, + scope: Scope::Study, + accepted_at: 1, + event_id: "e0".into(), + }; + assert!(!answer().is_training_data(Some(&acc))); + acc.scope = Scope::Train; + assert!(answer().is_training_data(Some(&acc))); +} + +#[test] +fn a_reply_never_collected_gets_no_tombstone() { + let mut a = answer(); + a.text = None; + assert!(a.redacted([0; 32]).is_err()); + let t = answer().redacted([9; 32]).unwrap(); + assert!(t.exited && t.text.is_none() && t.text_hash == Some(vec![9; 32])); + assert!(t.redacted([9; 32]).is_err(), "exit is one-way and a tombstone has no text to hash again"); +} + +#[test] +fn validate_enforces_the_representation_rules() { + assert!(answer().validate().is_ok()); + let cases: Vec<(&str, Box)> = vec![ + ("empty member", Box::new(|a| a.member_id.clear())), + ("empty event", Box::new(|a| a.event_id.clear())), + ("version zero", Box::new(|a| a.notice_version = Some(0))), + ("version without scope", Box::new(|a| a.scope = None)), + ("scope without version", Box::new(|a| a.notice_version = None)), + ("live without text", Box::new(|a| a.text = None)), + ("live with empty text", Box::new(|a| a.text = Some(String::new()))), + ("exited with text", Box::new(|a| a.exited = true)), + ("hash on a live answer", Box::new(|a| a.text_hash = Some(vec![0; 32]))), + ("short hash", Box::new(|a| { + a.exited = true; + a.text = None; + a.text_hash = Some(vec![0; 31]); + })), + ]; + for (name, break_it) in cases { + let mut a = answer(); + break_it(&mut a); + assert!(a.validate().is_err(), "{name}"); + } + let mut revoked = answer(); + revoked.text = None; + revoked.exited = true; + assert!(revoked.validate().is_ok(), "empty text is valid on a revoked answer"); + let mut unscoped = answer(); + unscoped.notice_version = None; + unscoped.scope = None; + assert!(unscoped.validate().is_ok(), "a scopeless reply is stored"); + let acc = NoticeAcceptance { + member_id: "m".into(), + experiment_id: "r".into(), + notice_version: 0, + scope: Scope::Show, + accepted_at: 1, + event_id: "e".into(), + }; + assert!(acc.validate().is_err()); + assert!(MemberTerms { member_id: "m".into(), terms_version: String::new(), terms_accepted_at: 1 }.validate().is_err()); +} + +#[test] +fn later_reply_is_greater_received_at_then_bytewise_event_id() { + let at = |t, e: &str| Answer { received_at: t, event_id: e.into(), ..answer() }; + assert!(at(2, "a").supersedes(&at(1, "z"))); + assert!(!at(1, "z").supersedes(&at(2, "a"))); + assert!(at(1, "a").supersedes(&at(1, "B")), "bytewise: 0x61 > 0x42"); + assert!(at(1, "z").supersedes(&at(1, "aa")), "bytewise, not by length"); + assert!(!at(1, "e").supersedes(&at(1, "e"))); +} + +#[test] +fn keys_join_the_parts_with_u001f_and_refuse_empty_or_separator_parts() { + assert_eq!(Answer::cache_key("r", "s@1", "m").unwrap(), "r\u{1f}s@1\u{1f}m"); + assert_eq!(NoticeAcceptance::cache_key("r", "m", 3).unwrap(), "r\u{1f}m\u{1f}3"); + assert_eq!(MemberTerms::cache_key("m").unwrap(), "m"); + for (experiment, sample, member) in [("", "s@1", "m"), ("r", "s@1", ""), ("r\u{1f}x", "s@1", "m"), ("r", "s\u{1f}x@1", "m"), ("r", "s@1", "m\u{1f}x")] { + assert!(Answer::cache_key(experiment, sample, member).is_err(), "{experiment:?} {sample:?} {member:?}"); + } + assert!(NoticeAcceptance::cache_key("", "m", 1).is_err()); + assert!(NoticeAcceptance::cache_key("r", "", 1).is_err()); + assert!(NoticeAcceptance::cache_key("r\u{1f}", "m", 1).is_err()); + assert!(NoticeAcceptance::cache_key("r", "m\u{1f}", 1).is_err()); + assert!(MemberTerms::cache_key("").is_err()); + assert!(MemberTerms::cache_key("m\u{1f}").is_err()); + // Parts that would collide if the separator were not refused or were `,` or empty. + assert_ne!(Answer::cache_key("a", "s@1", "bc").unwrap(), Answer::cache_key("ab", "s@1", "c").unwrap()); +} + +#[test] +fn an_acceptance_for_another_member_or_experiment_never_covers_the_answer() { + let acc = NoticeAcceptance { + member_id: "m".into(), + experiment_id: "r".into(), + notice_version: 1, + scope: Scope::Train, + accepted_at: 1, + event_id: "e0".into(), + }; + assert!(answer().is_training_data(Some(&acc))); + let other_member = NoticeAcceptance { member_id: "m2".into(), ..acc.clone() }; + let other_experiment = NoticeAcceptance { experiment_id: "r2".into(), ..acc }; + assert!(!answer().is_training_data(Some(&other_member))); + assert!(!answer().is_training_data(Some(&other_experiment))); +} + +/// The doc states the rule once: the answer's scope is `train`, the acceptance's own scope is `train`, +/// and the two are equal. The code keeps all three conditions (any two imply the third, so a single +/// removal is an equivalent mutant); this pins the rule itself over every pair of scopes. +#[test] +fn only_a_train_answer_under_a_train_acceptance_is_training_data() { + let scopes = [Scope::Show, Scope::Study, Scope::Train]; + for answer_scope in scopes { + for acceptance_scope in scopes { + let a = Answer { scope: Some(answer_scope), ..answer() }; + let acc = NoticeAcceptance { + member_id: "m".into(), + experiment_id: "r".into(), + notice_version: 1, + scope: acceptance_scope, + accepted_at: 1, + event_id: "e0".into(), + }; + let want = answer_scope == Scope::Train && acceptance_scope == Scope::Train; + assert_eq!(a.is_training_data(Some(&acc)), want, "answer {answer_scope:?} under acceptance {acceptance_scope:?}"); + } + } +} diff --git a/packages/heimdall-consent/tests/vectors/consent_golden.csv b/packages/heimdall-consent/tests/vectors/consent_golden.csv new file mode 100644 index 00000000..4f6e4048 --- /dev/null +++ b/packages/heimdall-consent/tests/vectors/consent_golden.csv @@ -0,0 +1,78 @@ +# case,op,member,experiment,notice_version,scope,sample_id,payload,ts,event_id +# Ops run in order against a real CultCache; each case starts empty. Timestamps are unix ms. +# reply: first reply under a (room, member, notice_version) writes an acceptance, then the answer replaces any earlier one at (room, sample_id, member); payload is the text. +# answer_raw: stores the answer with no acceptance written. redact: replaces the answer whose event_id matches with its tombstone; payload is the 32-byte tombstone hash as 64 hex chars (the member-side service's output; the crate never computes it). +# A reply with no established notice version or scope is stored with both absent (doc L107-114, Answer record), writes no acceptance, and is excluded. answer_raw with a version but no acceptance models a lost or never-written acceptance: still excluded (doc L152-158). +# reply_rejected: validate must refuse the answer (nothing stored). answer_revoked: stores an exited answer with no text; payload is the tombstone hash (empty means none). expect_winner: the stored answer's event_id and text must equal event and payload. +# Replacement at one key: the later reply is the greater (received_at, event_id), event_id compared bytewise; input order never decides (doc L98-101 and Heimdall PR #6 comment 759). +# terms: member field terms_version (payload) and terms_accepted_at (ts). expect_terms: the stored pair for that member reads back as payload and ts; expect_no_terms: the member has none. expect_*: payload is what must hold. +# version_ok / version_refused: the member column names the field (notice_version, sample_id, terms_version), payload is the version text; a sample id is checked as s@. One rule for all three: positive integer, no leading zeros, at most 4294967295 (Heimdall PR #6 d6a8d42, doc at aaeb175). +# Expected values are written by hand from docs/answer-collector-consent.md (Heimdall PR #6, doc commit aaeb175), not computed by the crate. +versioned-key,reply,m1,room1,1,train,s1@1,first,100,e1 +versioned-key,reply,m1,room1,1,train,s1@2,second,200,e2 +versioned-key,reply,m1,room1,1,train,s1@1,replaced,300,e3 +versioned-key,expect_counts,,,,,,answers=2;acceptances=1,, +versioned-key,expect_train,,,,,,e2;e3,, +no-scope,reply,m2,room1,,,s1@1,unscoped,100,e4 +no-scope,reply,m2,room1,,,s1@2,unversioned,110,e5 +no-scope,answer_raw,m3,room1,1,train,s1@1,no-acceptance,120,e6 +no-scope,reply,m4,room1,1,study,s1@1,study-only,130,e7 +no-scope,reply,m5,room1,1,train,s1@1,trainable,140,e8 +no-scope,expect_counts,,,,,,answers=5;acceptances=2,, +no-scope,expect_train,,,,,,e8,, +redacted-reply,reply,m6,room1,1,train,s1@1,secret words,100,e9 +redacted-reply,reply,m7,room1,1,train,s1@1,kept,110,e10 +redacted-reply,redact,m6,room1,,,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,,e9 +redacted-reply,expect_exited,m6,room1,,,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,100,e9 +redacted-reply,expect_train,,,,,,e10,, +redacted-reply,expect_counts,,,,,,answers=2;acceptances=2,, +newcomer,terms,m8,,,,,4294967295,50, +newcomer,expect_terms,m8,,,,,4294967295,50, +newcomer,expect_no_terms,m9,,,,,,, +newcomer,reply,m8,room1,3,train,s1@1,hello,100,e11 +newcomer,reply,m9,room1,3,train,s1@1,veteran,110,e12 +newcomer,reply,m8,room1,4,study,s1@1,again,200,e13 +newcomer,expect_acceptance,m8,room1,3,train,,e11,100, +newcomer,expect_acceptance,m8,room1,4,study,,e13,200, +newcomer,expect_acceptance,m9,room1,3,train,,e12,110, +newcomer,expect_counts,,,,,,answers=2;acceptances=3,, +newcomer,expect_train,,,,,,e12,, +version-change,reply,m10,room1,1,train,s1@1,under v1,100,e14 +version-change,answer_raw,m10,room1,2,train,s1@2,v2 without a fresh first reply,200,e15 +version-change,expect_counts,,,,,,answers=2;acceptances=1,, +version-change,expect_train,,,,,,e14,, +version-change,reply,m10,room1,2,train,s1@3,first reply under v2,300,e16 +version-change,expect_counts,,,,,,answers=3;acceptances=2,, +version-change,expect_train,,,,,,e14;e16,, +empty-live-rejected,reply_rejected,m11,room1,,,s1@1,,100,e17 +empty-live-rejected,expect_counts,,,,,,answers=0;acceptances=0,, +empty-live-rejected,reply,m11,room1,,,s1@1,now filled,110,e18 +empty-live-rejected,expect_winner,m11,room1,,,s1@1,now filled,,e18 +empty-revoked,answer_revoked,m12,room1,1,train,s1@1,,100,e19 +empty-revoked,answer_revoked,m13,room1,1,train,s1@1,a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90,100,e20 +empty-revoked,expect_counts,,,,,,answers=2;acceptances=0,, +empty-revoked,expect_train,,,,,,,, +tie-greater-first,reply,m14,room1,,,s1@1,upper-B,500,B +tie-greater-first,reply,m14,room1,,,s1@1,lower-a,500,a +tie-greater-first,reply,m15,room1,,,s1@1,short-z,500,z +tie-greater-first,reply,m15,room1,,,s1@1,long-aa,500,aa +tie-greater-first,reply,m16,room1,,,s1@1,newer-ts-first,600,a +tie-greater-first,reply,m16,room1,,,s1@1,older-ts-last,500,z +tie-greater-first,expect_winner,m14,room1,,,s1@1,lower-a,,a +tie-greater-first,expect_winner,m15,room1,,,s1@1,short-z,,z +tie-greater-first,expect_winner,m16,room1,,,s1@1,newer-ts-first,,a +tie-greater-last,reply,m14,room1,,,s1@1,lower-a,500,a +tie-greater-last,reply,m14,room1,,,s1@1,upper-B,500,B +tie-greater-last,reply,m15,room1,,,s1@1,long-aa,500,aa +tie-greater-last,reply,m15,room1,,,s1@1,short-z,500,z +tie-greater-last,expect_winner,m14,room1,,,s1@1,lower-a,,a +tie-greater-last,expect_winner,m15,room1,,,s1@1,short-z,,z +one-version-rule,version_ok,notice_version,room1,,,,4294967295,,version_ok-notice_version-4294967295 +one-version-rule,version_refused,notice_version,room1,,,,4294967296,,version_refused-notice_version-4294967296 +one-version-rule,version_refused,notice_version,room1,,,,01,,version_refused-notice_version-01 +one-version-rule,version_ok,sample_id,room1,,,,4294967295,,version_ok-sample_id-4294967295 +one-version-rule,version_refused,sample_id,room1,,,,4294967296,,version_refused-sample_id-4294967296 +one-version-rule,version_refused,sample_id,room1,,,,01,,version_refused-sample_id-01 +one-version-rule,version_ok,terms_version,room1,,,,4294967295,,version_ok-terms_version-4294967295 +one-version-rule,version_refused,terms_version,room1,,,,4294967296,,version_refused-terms_version-4294967296 +one-version-rule,version_refused,terms_version,room1,,,,01,,version_refused-terms_version-01 diff --git a/packages/heimdall-delegation/Cargo.lock b/packages/heimdall-delegation/Cargo.lock new file mode 100644 index 00000000..e706d732 --- /dev/null +++ b/packages/heimdall-delegation/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "heimdall-delegation" +version = "0.1.0" diff --git a/packages/heimdall-delegation/Cargo.toml b/packages/heimdall-delegation/Cargo.toml new file mode 100644 index 00000000..d57b3ab4 --- /dev/null +++ b/packages/heimdall-delegation/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "heimdall-delegation" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[lib] +name = "heimdall_delegation" +path = "src/lib.rs" diff --git a/packages/heimdall-delegation/README.md b/packages/heimdall-delegation/README.md new file mode 100644 index 00000000..bfe920f6 --- /dev/null +++ b/packages/heimdall-delegation/README.md @@ -0,0 +1,98 @@ +# heimdall-delegation + +Heimdall's delegation resolver: given who is seated, who delegated to whom, and +who voted directly on a topic, it decides whose vote counts where, and publishes +a receipt for every seated person. Pure and deterministic; no I/O, no +dependencies. Entry point: `resolve` in `src/lib.rs`. Live: the resolver and its +property tests (`tests/properties.rs`). Not here: a stake function (the caller +supplies each person's `weight` until the estates' formula exists), storage, and +any CultMesh projection. + +## Rules + +- A person may delegate to any seated person. Delegates may re-delegate, up to + `Config::max_depth` hops. +- A delegated vote counts in the **delegator's** estate. +- Scope narrows along a chain: topic `t` flows only if every link's scope + contains `t` (the receipt carries the intersection). +- A direct vote on the topic overrides the voter's own delegation. +- Cycles, depth violations, unseated or (with `same_estate_only`) cross-estate + delegates, and chains that end without a vote return the vote **uncast** to + its originator. Nothing is re-routed. +- Cap (**placeholder**): per (final holder, originator estate) the holder may + wield at most `cap` (default 1/5) of that estate's total weight. The holder's + own direct vote is not counted against it. Over the cap, every delegator into + that holder from that estate is scaled by the same factor `limit / sum`, in + integer weight by largest remainder; only exact remainder ties fall to + ascending originator id. A receipt's `counted` is its scaled weight and + `weight - counted` is uncast; a share that rounds to zero is + `Uncast(Trimmed)`. Cast + uncast = total weight. + **Caveat:** the cap binds the ledger, not influence. A delegator under an + over-cap holder can copy that holder's ballot and vote directly; the + resolver sees a direct ballot and cannot see the copying. In the Bifrost sim + (`docs/research/2026-11-05-estate-rule-sim/`, commit `5d352c6c`, Bifrost PR + #4), with full copying the cap leaves effective top-holder share unchanged + (difference +0.001 or less at every alpha), while the formal share drops. The + copy rate there is an assumption, not a measurement. +- Revocation (**placeholder**): `Delegation::revoked` is `Some(time)` when + revoked. A revoked delegation is inert; the resolver reads no clock and the + caller owns the ledger. Each receipt carries `revoked_at`, the latest + revocation time among the originator's own revoked delegations covering the + topic (revocations further down a chain are not reported). +- Input order never matters; duplicate roster entries, duplicate direct votes + and overlapping delegations from one person are input errors (**placeholder**: overlapping scopes are rejected, not resolved by precedence). + +## Placeholders until the estates rule + +Four choices are Heimdall's #swarm rulings of 2026-11-05 standing in for the +estates' own decision, and change if they rule otherwise: (1) the cap is +pro-rata, (2) the cap excludes the holder's own direct vote, (3) overlapping +scopes are an input error, (4) revocation is a flag with a time and the caller +owns the ledger. + +## Prior art + +Sources were fetched on 2026-11-05; claims below are what those pages say. + +- **LiquidFeedback, cycles.** The platform treats a cycle as a non-problem: if + everyone in a loop only delegates nobody votes, and if anyone in it acts + directly their delegation is suspended, so the loop breaks + ([Liquid Democracy Journal 3, "Circular Delegations: Myth or Disaster"](https://liquid-democracy-journal.org/issue/3/The_Liquid_Democracy_Journal-Issue003-02-Circular_Delegations_-_Myth_or_Disaster.html)). + Delegations are per area and a single area's set must not form cycles + ([Kling et al. 2015](https://ar5iv.arxiv.org/html/1503.07723)). *Consequence:* + we keep a direct vote overriding the voter's own delegation, and a + cycle with no direct vote returns every member's weight uncast to its + originator rather than re-routing it. We do not forbid cycles at input time + (the ledger is the caller's and may form them); we resolve them. +- **Kling, Kunegis, Hartmann, Strohmaier, Staab 2015** ([ICWSM](https://ojs.aaai.org/index.php/ICWSM/article/view/14618), + [arXiv:1503.07723](https://arxiv.org/abs/1503.07723)), a study of + LiquidFeedback in the German Pirate Party. Weight is power-law distributed + (38 voters held more than 100 delegations); delegation is transitive; super-voters + have high theoretical power but exercise less of it (mean exercised/theoretical + ratio 0.34), and the paper argues potential power is the safer measure. It + proposes no cap. *Consequence:* a cap is our own addition, justified by the + potential-power argument, not by that paper; keep the cap a separate, + swappable `Config` field so the estates can set it to 1/1 and turn it off. + Transitive delegation with `max_depth` stays. +- **Gölz, Kahng, Mackenzie, Procaccia 2018** ([The Fluid Mechanics of Liquid + Democracy](https://arxiv.org/abs/1808.01906), WINE 2018; TEAC 2021). With single + delegation the maximum guru weight grows polynomially in the electorate; + letting voters list more than one option and routing by confluent flow brings + it to O(log log t). It handles cycles in general graphs by processing strongly + connected components, and notes that people who delegate only among themselves + have weight that reaches nobody. It considers caps and rejects them: voters + can coordinate outside the system and copy a delegate's ballot, so a cap + "would not alleviate the problem of super-voters, just reduce + transparency" ([full text](https://ar5iv.arxiv.org/html/1808.01906)). + *Consequences:* (a) a cap bounds what the platform records, not what a + holder can persuade; the receipts' `counted`/uncast split keeps the trimmed + part visible instead of hiding it, which is the transparency their objection + turns on. (b) Pro-rata scaling treats equal delegators equally without a + priority order a holder could game by who delegates first; ascending-id + trimming did not. (c) Multi-option delegation is the research direction for + reducing concentration without a cap; it is out of scope here and would need + the same preference-vs-equal-treatment question the Liquid Democracy Journal + piece raises about preferential delegation. +- **Not checked:** this section does not cover other liquid-democracy + implementations (e.g. Loomio, Democracy Earth) or the cycle literature after + 2018. diff --git a/packages/heimdall-delegation/src/lib.rs b/packages/heimdall-delegation/src/lib.rs new file mode 100644 index 00000000..a6fe97d6 --- /dev/null +++ b/packages/heimdall-delegation/src/lib.rs @@ -0,0 +1,320 @@ +//! Heimdall's delegation resolver. See README.md for the rules. + +use std::collections::{BTreeMap, BTreeSet}; + +pub type PersonId = u64; +pub type EstateId = u64; +pub type Topic = String; +pub type Choice = String; + +/// A seated person. `weight` is supplied by the caller (no stake function yet). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Person { + pub id: PersonId, + pub estate: EstateId, + pub weight: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Scope { + All, + Only(BTreeSet), +} + +impl Scope { + pub fn covers(&self, topic: &str) -> bool { + match self { + Scope::All => true, + Scope::Only(s) => s.contains(topic), + } + } + + pub fn narrow(&self, other: &Scope) -> Scope { + match (self, other) { + (Scope::All, s) | (s, Scope::All) => s.clone(), + (Scope::Only(a), Scope::Only(b)) => Scope::Only(a.intersection(b).cloned().collect()), + } + } + + fn overlaps(&self, other: &Scope) -> bool { + match (self, other) { + (Scope::All, _) | (_, Scope::All) => true, + (Scope::Only(a), Scope::Only(b)) => a.intersection(b).next().is_some(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Delegation { + pub from: PersonId, + pub to: PersonId, + pub scope: Scope, + /// `Some(t)` means revoked at caller-clock time `t`. Revoked delegations are + /// inert; the resolver never reads a clock and the caller owns the ledger. + pub revoked: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Vote { + pub voter: PersonId, + pub topic: Topic, + pub choice: Choice, +} + +/// A fraction of an estate's total weight. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Ratio { + pub num: u64, + pub den: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Config { + /// Maximum hops from originator to final holder; 0 disables delegation. + pub max_depth: usize, + /// Per-estate cap on what one final holder may wield from that estate. + pub cap: Ratio, + /// Fallback switch: refuse delegation across estates. + pub same_estate_only: bool, +} + +impl Default for Config { + fn default() -> Self { + Config { max_depth: 3, cap: Ratio { num: 1, den: 5 }, same_estate_only: false } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum InputError { + DuplicatePerson(PersonId), + DuplicateVote(PersonId), + ZeroCapDenominator, + /// Two live delegations from one person cover a common topic. + OverlappingDelegations(PersonId), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UncastReason { + /// The chain ended at someone who neither voted nor delegated this topic. + NoVote, + Cycle, + DepthExceeded, + /// The next delegate is not seated. + NotSeated, + /// `same_estate_only` and the next delegate is in another estate. + CrossEstate, + /// The cap scaled this originator's whole weight away (rounded to zero). + Trimmed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + Direct { choice: Choice }, + Delegated { + holder: PersonId, + holder_estate: EstateId, + /// originator, delegates..., holder + chain: Vec, + /// Intersection of every link's scope. + scope: Scope, + choice: Choice, + }, + Uncast(UncastReason), +} + +/// Public receipt: one per seated person per topic. `estate` is the estate the +/// weight counts in (the originator's). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Receipt { + pub topic: Topic, + pub originator: PersonId, + pub estate: EstateId, + pub weight: u64, + /// Part of `weight` that counted for the outcome's choice. `weight` for a + /// direct vote, `0` for an uncast one, and for a delegated one `weight` + /// scaled by the cap; `weight - counted` is uncast. + pub counted: u64, + /// Latest revocation time among the originator's own revoked delegations + /// whose scope covers this topic. Those delegations took no part in the + /// resolution; the receipt records that they existed. + pub revoked_at: Option, + pub outcome: Outcome, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Tally { + pub by_choice: BTreeMap, + /// estate -> choice -> weight, counted in the originator's estate. + pub by_estate: BTreeMap>, + pub cast: u64, + pub uncast: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Resolution { + /// Sorted by originator id. + pub receipts: Vec, + pub tally: Tally, +} + +enum Walk { + Direct(Choice), + Held { chain: Vec, scope: Scope, choice: Choice }, + Uncast(UncastReason), +} + +/// Resolve one topic. Pure; the order of every input slice is irrelevant. +pub fn resolve( + config: &Config, + people: &[Person], + delegations: &[Delegation], + votes: &[Vote], + topic: &str, +) -> Result { + if config.cap.den == 0 { + return Err(InputError::ZeroCapDenominator); + } + let mut roster: BTreeMap = BTreeMap::new(); + for p in people { + if roster.insert(p.id, p).is_some() { + return Err(InputError::DuplicatePerson(p.id)); + } + } + let mut direct: BTreeMap = BTreeMap::new(); + for v in votes.iter().filter(|v| v.topic == topic) { + if direct.insert(v.voter, &v.choice).is_some() { + return Err(InputError::DuplicateVote(v.voter)); + } + } + let mut live: BTreeMap> = BTreeMap::new(); + let mut revoked_at: BTreeMap = BTreeMap::new(); + for d in delegations { + match d.revoked { + None => live.entry(d.from).or_default().push(d), + Some(t) if d.scope.covers(topic) => { + let e = revoked_at.entry(d.from).or_insert(t); + *e = (*e).max(t); + } + Some(_) => {} + } + } + for (from, ds) in &live { + for (i, a) in ds.iter().enumerate() { + if ds[i + 1..].iter().any(|b| a.scope.overlaps(&b.scope)) { + return Err(InputError::OverlappingDelegations(*from)); + } + } + } + + let walk = |origin: &Person| -> Walk { + if let Some(c) = direct.get(&origin.id) { + return Walk::Direct((*c).clone()); + } + let mut chain = vec![origin.id]; + let mut scope = Scope::All; + let mut cur = origin.id; + loop { + let Some(d) = live.get(&cur).and_then(|ds| ds.iter().find(|d| d.scope.covers(topic))) else { + return Walk::Uncast(UncastReason::NoVote); + }; + let Some(next) = roster.get(&d.to) else { + return Walk::Uncast(UncastReason::NotSeated); + }; + if config.same_estate_only && next.estate != origin.estate { + return Walk::Uncast(UncastReason::CrossEstate); + } + if chain.contains(&next.id) { + return Walk::Uncast(UncastReason::Cycle); + } + if chain.len() > config.max_depth { + return Walk::Uncast(UncastReason::DepthExceeded); + } + chain.push(next.id); + scope = scope.narrow(&d.scope); + if let Some(c) = direct.get(&next.id) { + return Walk::Held { chain, scope, choice: (*c).clone() }; + } + cur = next.id; + } + }; + + let mut receipts: Vec = Vec::with_capacity(roster.len()); + for p in roster.values() { + let outcome = match walk(p) { + Walk::Direct(choice) => Outcome::Direct { choice }, + Walk::Held { chain, scope, choice } => { + let holder = *chain.last().expect("chain has a holder"); + Outcome::Delegated { holder, holder_estate: roster[&holder].estate, chain, scope, choice } + } + Walk::Uncast(r) => Outcome::Uncast(r), + }; + let counted = if matches!(outcome, Outcome::Direct { .. } | Outcome::Delegated { .. }) { p.weight } else { 0 }; + receipts.push(Receipt { + topic: topic.to_string(), + originator: p.id, + estate: p.estate, + weight: p.weight, + counted, + revoked_at: revoked_at.get(&p.id).copied(), + outcome, + }); + } + + // Cap: per (final holder, originator estate) the holder may wield at most + // floor(estate total * cap) of delegated weight (the holder's own direct + // vote is not part of it). An over-cap group is scaled pro rata: every + // delegator by the same factor limit / sum, in integer weight by largest + // remainder. Only exact remainder ties fall to ascending originator id. + let mut estate_total: BTreeMap = BTreeMap::new(); + for p in roster.values() { + *estate_total.entry(p.estate).or_default() += p.weight as u128; + } + let mut groups: BTreeMap<(PersonId, EstateId), Vec> = BTreeMap::new(); + for (i, r) in receipts.iter().enumerate() { + if let Outcome::Delegated { holder, .. } = r.outcome { + groups.entry((holder, r.estate)).or_default().push(i); + } + } + for ((_, estate), members) in groups { + let limit = estate_total[&estate] * config.cap.num as u128 / config.cap.den as u128; + let sum: u128 = members.iter().map(|&i| receipts[i].weight as u128).sum(); + if sum <= limit { + continue; + } + // members are in ascending originator id (receipts are), so a stable + // sort by descending remainder leaves ids ascending within ties. + let mut shares: Vec<(usize, u128, u128)> = members + .iter() + .map(|&i| { + let scaled = receipts[i].weight as u128 * limit; + (i, scaled / sum, scaled % sum) + }) + .collect(); + let leftover = limit - shares.iter().map(|s| s.1).sum::(); + let mut order: Vec = (0..shares.len()).collect(); + order.sort_by(|&a, &b| shares[b].2.cmp(&shares[a].2)); + for &k in order.iter().take(leftover as usize) { + shares[k].1 += 1; + } + for (i, counted, _) in shares { + receipts[i].counted = counted as u64; + if counted == 0 { + receipts[i].outcome = Outcome::Uncast(UncastReason::Trimmed); + } + } + } + + let mut tally = Tally::default(); + for r in &receipts { + match &r.outcome { + Outcome::Direct { choice } | Outcome::Delegated { choice, .. } => { + *tally.by_choice.entry(choice.clone()).or_default() += r.counted; + *tally.by_estate.entry(r.estate).or_default().entry(choice.clone()).or_default() += r.counted; + tally.cast += r.counted; + tally.uncast += r.weight - r.counted; + } + Outcome::Uncast(_) => tally.uncast += r.weight, + } + } + Ok(Resolution { receipts, tally }) +} diff --git a/packages/heimdall-delegation/tests/properties.rs b/packages/heimdall-delegation/tests/properties.rs new file mode 100644 index 00000000..e4fa61ad --- /dev/null +++ b/packages/heimdall-delegation/tests/properties.rs @@ -0,0 +1,509 @@ +use heimdall_delegation::*; +use std::collections::BTreeSet; + +/// xorshift64*: seeded, dependency-free, reproducible. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545F4914F6CDD1D) + } + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } + fn shuffle(&mut self, v: &mut [T]) { + for i in (1..v.len()).rev() { + v.swap(i, self.below(i as u64 + 1) as usize); + } + } +} + +const TOPIC: &str = "budget"; + +#[derive(Clone)] +struct World { + config: Config, + people: Vec, + delegations: Vec, + votes: Vec, +} + +impl World { + fn resolve(&self) -> Resolution { + resolve(&self.config, &self.people, &self.delegations, &self.votes, TOPIC).unwrap() + } +} + +fn person(id: u64, estate: u64, weight: u64) -> Person { + Person { id, estate, weight } +} +fn del(from: u64, to: u64) -> Delegation { + Delegation { from, to, scope: Scope::All, revoked: None } +} +fn vote(voter: u64, choice: &str) -> Vote { + Vote { voter, topic: TOPIC.into(), choice: choice.into() } +} +fn only(topics: &[&str]) -> Scope { + Scope::Only(topics.iter().map(|t| t.to_string()).collect()) +} +fn world(config: Config, people: Vec, delegations: Vec, votes: Vec) -> World { + World { config, people, delegations, votes } +} +fn loose(max_depth: usize) -> Config { + Config { max_depth, cap: Ratio { num: 1, den: 1 }, same_estate_only: false } +} +fn outcome(r: &Resolution, id: u64) -> &Outcome { + &r.receipts.iter().find(|x| x.originator == id).unwrap().outcome +} + +/// Random world: ids 1..=n, 3 estates. Delegations may form cycles, cross +/// estates, point at unseated ids, carry scopes, and be revoked. At most one +/// delegation per person per scope bucket so inputs stay valid. +fn random_world(rng: &mut Rng) -> World { + let n = 2 + rng.below(14); + let people: Vec = (1..=n).map(|i| person(i, rng.below(3), 1 + rng.below(9))).collect(); + let mut delegations = vec![]; + for from in 1..=n { + match rng.below(4) { + 0 => {} + 1 | 2 => delegations.push(Delegation { + from, + to: 1 + rng.below(n + 2), // n+1, n+2 are unseated + scope: Scope::All, + revoked: (rng.below(6) == 0).then(|| rng.below(1000)), + }), + _ => { + // two disjoint scoped delegations + delegations.push(Delegation { from, to: 1 + rng.below(n), scope: only(&["budget"]), revoked: None }); + delegations.push(Delegation { from, to: 1 + rng.below(n), scope: only(&["war", "trade"]), revoked: None }); + } + } + } + let mut votes = vec![]; + for v in 1..=n { + if rng.below(3) == 0 { + votes.push(vote(v, ["yes", "no", "abstain"][rng.below(3) as usize])); + } + } + let config = Config { + max_depth: rng.below(5) as usize, + cap: Ratio { num: 1 + rng.below(3), den: 1 + rng.below(5) }, + same_estate_only: rng.below(4) == 0, + }; + World { config, people, delegations, votes } +} + +fn worlds(n: u64) -> impl Iterator { + (1..=n).map(|seed| (seed, random_world(&mut Rng(seed * 7919)))) +} + +#[test] +fn order_independence_under_shuffled_input() { + for (seed, w) in worlds(400) { + let base = w.resolve(); + let mut rng = Rng(seed + 1000); + for _ in 0..5 { + let mut s = w.clone(); + rng.shuffle(&mut s.people); + rng.shuffle(&mut s.delegations); + rng.shuffle(&mut s.votes); + assert_eq!(s.resolve(), base, "seed {seed}"); + } + } +} + +#[test] +fn conservation_one_receipt_per_person_and_no_inflation() { + for (seed, w) in worlds(400) { + let r = w.resolve(); + let total: u64 = w.people.iter().map(|p| p.weight).sum(); + assert_eq!(r.receipts.len(), w.people.len(), "seed {seed}"); + assert_eq!(r.tally.cast + r.tally.uncast, total, "seed {seed}"); + assert!(r.tally.cast <= total); + assert_eq!(r.tally.by_choice.values().sum::(), r.tally.cast); + for p in &w.people { + let counted: u64 = r.tally.by_estate.get(&p.estate).map_or(0, |m| m.values().sum()); + let estate_total: u64 = w.people.iter().filter(|q| q.estate == p.estate).map(|q| q.weight).sum(); + assert!(counted <= estate_total, "seed {seed}: estate {} inflated", p.estate); + } + // Delegated weight counts in the delegator's estate, not the holder's. + for rc in &r.receipts { + let p = w.people.iter().find(|p| p.id == rc.originator).unwrap(); + assert_eq!((rc.estate, rc.weight), (p.estate, p.weight)); + } + } +} + +#[test] +fn direct_vote_overrides_any_delegation() { + for (seed, w) in worlds(400) { + let r = w.resolve(); + for v in &w.votes { + assert_eq!(outcome(&r, v.voter), &Outcome::Direct { choice: v.choice.clone() }, "seed {seed}"); + } + } + // Same person with and without a delegation chain pointing elsewhere. + let people = vec![person(1, 0, 1), person(2, 0, 1), person(3, 0, 1)]; + let votes = vec![vote(1, "yes"), vote(3, "no")]; + let with = world(loose(3), people.clone(), vec![del(1, 2), del(2, 3)], votes.clone()).resolve(); + let without = world(loose(3), people, vec![del(2, 3)], votes).resolve(); + assert_eq!(outcome(&with, 1), &Outcome::Direct { choice: "yes".into() }); + assert_eq!(with.receipts[0], without.receipts[0]); +} + +#[test] +fn cycles_return_the_vote_to_every_originator_in_them() { + let people: Vec<_> = (1..=4).map(|i| person(i, 0, 1)).collect(); + let r = world(loose(9), people.clone(), vec![del(1, 2), del(2, 3), del(3, 1), del(4, 1)], vec![]).resolve(); + for id in 1..=4 { + assert_eq!(outcome(&r, id), &Outcome::Uncast(UncastReason::Cycle)); + } + assert_eq!((r.tally.cast, r.tally.uncast), (0, 4)); + // Self-delegation is a cycle too. + let r = world(loose(9), people, vec![del(1, 1)], vec![]).resolve(); + assert_eq!(outcome(&r, 1), &Outcome::Uncast(UncastReason::Cycle)); +} + +#[test] +fn delegated_chains_are_acyclic_and_within_depth() { + for (seed, w) in worlds(400) { + for rc in &w.resolve().receipts { + if let Outcome::Delegated { chain, .. } = &rc.outcome { + let uniq: BTreeSet<_> = chain.iter().collect(); + assert_eq!(uniq.len(), chain.len(), "seed {seed}"); + assert!(chain.len() - 1 <= w.config.max_depth, "seed {seed}"); + assert_eq!(chain[0], rc.originator); + } + } + } +} + +#[test] +fn depth_boundary_is_exact() { + // 1 -> 2 -> 3 -> 4(votes): three hops. + let people: Vec<_> = (1..=4).map(|i| person(i, 0, 1)).collect(); + let d = vec![del(1, 2), del(2, 3), del(3, 4)]; + let v = vec![vote(4, "yes")]; + let at = world(loose(3), people.clone(), d.clone(), v.clone()).resolve(); + assert!(matches!(outcome(&at, 1), Outcome::Delegated { holder: 4, .. })); + let over = world(loose(2), people, d, v).resolve(); + assert_eq!(outcome(&over, 1), &Outcome::Uncast(UncastReason::DepthExceeded)); + assert!(matches!(outcome(&over, 2), Outcome::Delegated { holder: 4, .. })); + assert!(matches!(outcome(&over, 3), Outcome::Delegated { holder: 4, .. })); +} + +#[test] +fn zero_depth_disables_delegation() { + let people = vec![person(1, 0, 1), person(2, 0, 1)]; + let r = world(loose(0), people, vec![del(1, 2)], vec![vote(2, "yes")]).resolve(); + assert_eq!(outcome(&r, 1), &Outcome::Uncast(UncastReason::DepthExceeded)); +} + +#[test] +fn scope_narrows_along_the_chain() { + let people: Vec<_> = (1..=3).map(|i| person(i, 0, 1)).collect(); + let mut a = del(1, 2); + a.scope = only(&["budget", "war"]); + let mut b = del(2, 3); + b.scope = only(&["budget", "trade"]); + let r = world(loose(3), people, vec![a, b], vec![vote(3, "yes")]).resolve(); + match outcome(&r, 1) { + Outcome::Delegated { scope, .. } => assert_eq!(scope, &only(&["budget"])), + o => panic!("{o:?}"), + } + // Topic outside the second link's scope: chain ends at 2, who has no vote. + let mut a = del(1, 2); + a.scope = Scope::All; + let mut b = del(2, 3); + b.scope = only(&["trade"]); + let people: Vec<_> = (1..=3).map(|i| person(i, 0, 1)).collect(); + let r = world(loose(3), people, vec![a, b], vec![vote(3, "yes")]).resolve(); + assert_eq!(outcome(&r, 1), &Outcome::Uncast(UncastReason::NoVote)); +} + +#[test] +fn every_delegated_scope_covers_the_topic() { + for (seed, w) in worlds(400) { + for rc in &w.resolve().receipts { + if let Outcome::Delegated { scope, .. } = &rc.outcome { + assert!(scope.covers(TOPIC), "seed {seed}"); + } + } + } +} + +#[test] +fn revoked_delegation_equals_removed_delegation() { + for (seed, w) in worlds(400) { + let mut revoked = w.clone(); + for d in revoked.delegations.iter_mut().filter(|d| d.from % 2 == 0) { + d.revoked = Some(d.revoked.unwrap_or(7)); + } + let mut removed = w.clone(); + removed.delegations.retain(|d| d.from % 2 != 0); + let (mut a, mut b) = (revoked.resolve(), removed.resolve()); + for r in a.receipts.iter_mut().chain(b.receipts.iter_mut()) { + r.revoked_at = None; + } + assert_eq!(a, b, "seed {seed}"); + } +} + +#[test] +fn unseated_delegate_returns_the_vote() { + let r = world(loose(3), vec![person(1, 0, 4)], vec![del(1, 99)], vec![vote(99, "yes")]).resolve(); + assert_eq!(outcome(&r, 1), &Outcome::Uncast(UncastReason::NotSeated)); + assert_eq!(r.tally.uncast, 4); +} + +#[test] +fn same_estate_only_refuses_cross_estate_hops() { + let people = vec![person(1, 0, 1), person(2, 1, 1), person(3, 0, 1)]; + let d = vec![del(1, 2), del(3, 1)]; + let v = vec![vote(2, "yes")]; + let open = world(loose(3), people.clone(), d.clone(), v.clone()).resolve(); + assert!(matches!(outcome(&open, 1), Outcome::Delegated { holder: 2, holder_estate: 1, .. })); + let mut c = loose(3); + c.same_estate_only = true; + let shut = world(c, people, d, v).resolve(); + assert_eq!(outcome(&shut, 1), &Outcome::Uncast(UncastReason::CrossEstate)); + assert_eq!(outcome(&shut, 3), &Outcome::Uncast(UncastReason::CrossEstate)); + // Delegated votes count in the delegator's estate even across estates. + assert_eq!(open.tally.by_estate[&0]["yes"], 2); + assert_eq!(open.tally.by_estate[&1]["yes"], 1); +} + +#[test] +fn same_estate_switch_only_ever_removes_delegated_votes() { + for (seed, w) in worlds(400) { + let mut shut = w.clone(); + shut.config.same_estate_only = true; + for rc in &shut.resolve().receipts { + if let Outcome::Delegated { holder_estate, .. } = rc.outcome { + assert_eq!(holder_estate, rc.estate, "seed {seed}"); + } + } + } +} + +#[test] +fn cap_scales_every_delegator_by_the_same_factor() { + // Estate 0: ten people of weight 1 plus a weight-10 whale (total 20); + // cap 1/5 -> 4 from estate 0. Eleven delegators into holder 12 (estate 1): + // sum 20 > 4, factor 1/5: the whale keeps 2, the ten keep 0.2 each; the + // two leftover units go to the largest remainders (equal, so ids 1 and 2). + let mut people: Vec<_> = (1..=10).map(|i| person(i, 0, 1)).collect(); + people.push(person(11, 0, 10)); + people.push(person(12, 1, 1)); + let d: Vec<_> = (1..=11).map(|i| del(i, 12)).collect(); + let mut c = loose(1); + c.cap = Ratio { num: 1, den: 5 }; + let r = world(c, people, d, vec![vote(12, "yes")]).resolve(); + let counted: Vec = r.receipts.iter().filter(|x| x.estate == 0).map(|x| x.counted).collect(); + assert_eq!(counted, vec![1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2]); + assert_eq!(outcome(&r, 3), &Outcome::Uncast(UncastReason::Trimmed)); + assert_eq!(r.tally.by_estate[&0]["yes"], 4); + // The holder's own direct vote is outside the cap and untouched. + assert_eq!(r.tally.by_estate[&1]["yes"], 1); + assert_eq!((r.tally.cast, r.tally.uncast), (5, 16)); +} + +#[test] +fn partial_trim_keeps_the_delegation_and_reports_the_uncast_part() { + let people = vec![person(1, 0, 6), person(2, 0, 4), person(3, 1, 1)]; + let mut c = loose(1); + c.cap = Ratio { num: 1, den: 2 }; // limit 5 of estate 0 (total 10) + let r = world(c, people, vec![del(1, 3), del(2, 3)], vec![vote(3, "yes")]).resolve(); + let rc = |id| r.receipts.iter().find(|x| x.originator == id).unwrap(); + assert_eq!((rc(1).counted, rc(2).counted), (3, 2)); // exactly 6/10 and 4/10 of 5 + assert!(matches!(rc(1).outcome, Outcome::Delegated { holder: 3, .. })); + assert_eq!(r.tally.uncast, 5); +} + +#[test] +fn cap_is_per_estate_and_applies_to_the_final_holder() { + // Two estates of 5. Cap 1/5 -> 1 each. Holder 20 gets 1 from each estate. + let mut people: Vec<_> = (1..=5).map(|i| person(i, 0, 1)).collect(); + people.extend((6..=10).map(|i| person(i, 1, 1))); + people.push(person(20, 2, 1)); + let d: Vec<_> = (1..=10).map(|i| del(i, 20)).collect(); + let mut c = loose(1); + c.cap = Ratio { num: 1, den: 5 }; + let r = world(c, people, d, vec![vote(20, "yes")]).resolve(); + for e in [0, 1] { + let got: u64 = r.receipts.iter().filter(|x| x.estate == e).map(|x| x.counted).sum(); + assert_eq!(got, 1, "estate {e}"); + } +} + +/// Worlds built to hit the cap: few holders, most people delegate straight +/// to them, varied weights, tight caps. +fn crowded_world(rng: &mut Rng) -> World { + let n = 6 + rng.below(20); + let holders = 1 + rng.below(3); + let people: Vec = (1..=n).map(|i| person(i, rng.below(3), 1 + rng.below(12))).collect(); + let mut delegations = vec![]; + let mut votes = vec![]; + for i in 1..=n { + if i <= holders { + votes.push(vote(i, "yes")); + } else if rng.below(8) != 0 { + delegations.push(del(i, 1 + rng.below(holders))); + } + } + let config = Config { max_depth: 1, cap: Ratio { num: 1, den: 2 + rng.below(6) }, same_estate_only: false }; + World { config, people, delegations, votes } +} + +fn crowded(n: u64) -> impl Iterator { + (1..=n).map(|seed| (seed, crowded_world(&mut Rng(seed * 104729)))) +} + +fn groups(w: &World, r: &Resolution) -> std::collections::BTreeMap<(u64, u64), Vec> { + let _ = w; + let mut g: std::collections::BTreeMap<(u64, u64), Vec> = Default::default(); + for rc in &r.receipts { + if let Outcome::Delegated { holder, .. } = rc.outcome { + g.entry((holder, rc.estate)).or_default().push(rc.clone()); + } + } + g +} + +fn limit(w: &World, estate: u64) -> u128 { + let total: u128 = w.people.iter().filter(|p| p.estate == estate).map(|p| p.weight as u128).sum(); + total * w.config.cap.num as u128 / w.config.cap.den as u128 +} + +#[test] +fn trim_is_order_independent_under_shuffles() { + let mut trimmed = 0; + for (seed, w) in crowded(400) { + let base = w.resolve(); + trimmed += base.receipts.iter().filter(|r| r.counted < r.weight).count(); + let mut rng = Rng(seed + 5000); + for _ in 0..5 { + let mut s = w.clone(); + rng.shuffle(&mut s.people); + rng.shuffle(&mut s.delegations); + rng.shuffle(&mut s.votes); + assert_eq!(s.resolve(), base, "seed {seed}"); + } + } + assert!(trimmed > 500, "generator must actually exercise the cap, got {trimmed}"); +} + +#[test] +fn delegated_weight_never_exceeds_the_cap_and_fills_it_when_over() { + for (seed, w) in worlds(400).chain(crowded(400)) { + let r = w.resolve(); + for ((_, estate), members) in groups(&w, &r) { + let sum: u128 = members.iter().map(|m| m.weight as u128).sum(); + let got: u128 = members.iter().map(|m| m.counted as u128).sum(); + let lim = limit(&w, estate); + assert!(got <= lim, "seed {seed}: {got} > {lim}"); + if sum > lim { + assert_eq!(got, lim, "seed {seed}: over-cap group must use exactly the cap"); + } else { + assert_eq!(got, sum, "seed {seed}: under-cap group is untouched"); + } + } + } +} + +#[test] +fn no_delegator_is_scaled_more_than_another() { + for (seed, w) in crowded(400) { + let r = w.resolve(); + for ((_, estate), members) in groups(&w, &r).into_iter().chain(trimmed_groups(&w, &r)) { + let sum: u128 = members.iter().map(|m| m.weight as u128).sum(); + let lim = limit(&w, estate); + if sum <= lim { + continue; + } + for m in &members { + // counted is the exact pro-rata share rounded down or up, never further. + let exact = m.weight as u128 * lim; + let (lo, hi) = (exact / sum, exact.div_ceil(sum)); + assert!((lo..=hi).contains(&(m.counted as u128)), "seed {seed}: {m:?}"); + } + // Equal weights are scaled equally to within one unit of rounding, + // and a unit of rounding goes to the lower originator id on a tie. + for a in &members { + for b in &members { + if a.weight == b.weight { + assert!(a.counted.abs_diff(b.counted) <= 1, "seed {seed}"); + if a.counted < b.counted { + assert!(a.originator > b.originator, "seed {seed}: tie broke against ascending id"); + } + } + } + } + } + } +} + +/// Delegators whose whole share rounded to zero are `Uncast(Trimmed)`, so they +/// are not in `groups`; rebuild their groups by following the chain they would +/// have taken (depth 1: their sole live delegation). +fn trimmed_groups(w: &World, r: &Resolution) -> std::collections::BTreeMap<(u64, u64), Vec> { + let mut g = groups(w, r); + for rc in &r.receipts { + if rc.outcome == Outcome::Uncast(UncastReason::Trimmed) { + let to = w.delegations.iter().find(|d| d.from == rc.originator && d.revoked.is_none()).unwrap().to; + g.entry((to, rc.estate)).or_default().push(rc.clone()); + } + } + g +} + +#[test] +fn receipts_carry_the_revocation_time_of_the_originators_own_revoked_delegation() { + let people = vec![person(1, 0, 1), person(2, 0, 1), person(3, 0, 1)]; + let mut a = del(1, 2); + a.revoked = Some(40); + let mut b = del(1, 3); + b.revoked = Some(90); + b.scope = only(&["budget"]); + let mut c = del(1, 3); + c.revoked = Some(500); + c.scope = only(&["war"]); // other topic: not reported + let r = world(loose(2), people, vec![a, b, c], vec![vote(3, "yes")]).resolve(); + let rc = |id| r.receipts.iter().find(|x| x.originator == id).unwrap(); + assert_eq!(rc(1).revoked_at, Some(90)); // latest covering revocation + assert_eq!(outcome(&r, 1), &Outcome::Uncast(UncastReason::NoVote)); // and it stayed inert + assert_eq!(rc(2).revoked_at, None); + // A live delegation alongside an older revoked one still resolves. + let people = vec![person(1, 0, 1), person(2, 0, 1)]; + let mut old = del(1, 2); + old.revoked = Some(3); + let r = world(loose(2), people, vec![old, del(1, 2)], vec![vote(2, "yes")]).resolve(); + assert!(matches!(outcome(&r, 1), Outcome::Delegated { holder: 2, .. })); + assert_eq!(r.receipts[0].revoked_at, Some(3)); +} + +#[test] +fn invalid_input_is_an_error_not_a_guess() { + let p = vec![person(1, 0, 1), person(1, 0, 1)]; + assert_eq!(resolve(&loose(1), &p, &[], &[], TOPIC), Err(InputError::DuplicatePerson(1))); + let p = vec![person(1, 0, 1)]; + let v = vec![vote(1, "a"), vote(1, "b")]; + assert_eq!(resolve(&loose(1), &p, &[], &v, TOPIC), Err(InputError::DuplicateVote(1))); + let mut a = del(1, 2); + a.scope = only(&["budget"]); + let b = del(1, 3); + assert_eq!(resolve(&loose(1), &p, &[a, b], &[], TOPIC), Err(InputError::OverlappingDelegations(1))); + let mut c = loose(1); + c.cap.den = 0; + assert_eq!(resolve(&c, &p, &[], &[], TOPIC), Err(InputError::ZeroCapDenominator)); +} + +#[test] +fn votes_on_other_topics_are_ignored() { + let people = vec![person(1, 0, 1), person(2, 0, 1)]; + let other = Vote { voter: 2, topic: "war".into(), choice: "yes".into() }; + let r = resolve(&loose(2), &people, &[del(1, 2)], &[other], TOPIC).unwrap(); + assert_eq!(r.tally.cast, 0); +} diff --git a/packages/heimdall-stake/Cargo.lock b/packages/heimdall-stake/Cargo.lock new file mode 100644 index 00000000..e51417db --- /dev/null +++ b/packages/heimdall-stake/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "heimdall-stake" +version = "0.1.0" diff --git a/packages/heimdall-stake/Cargo.toml b/packages/heimdall-stake/Cargo.toml new file mode 100644 index 00000000..19fe9296 --- /dev/null +++ b/packages/heimdall-stake/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "heimdall-stake" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[lib] +name = "heimdall_stake" +path = "src/lib.rs" diff --git a/packages/heimdall-stake/README.md b/packages/heimdall-stake/README.md new file mode 100644 index 00000000..5c8ae095 --- /dev/null +++ b/packages/heimdall-stake/README.md @@ -0,0 +1,100 @@ +# heimdall-stake + +Heimdall's stake function: how much weight an amount of money buys one identity. Pure, +deterministic, no I/O, no dependencies; integer cents in, integer micro-weights out +(`MICRO` = 1,000,000 = a weight of 1.0), and an integer-only logarithm, so the same inputs give +the same bits on every platform. Entry points: `weight` and `splitting_gain` in `src/lib.rs`. +Live: the formula, the three Heimdall parameters, the splitting-gain report and their property +tests (`tests/properties.rs`). Not here: the estate rule that normalizes weights, the sybil +adversary, storage, and any projection. Every parameter is a placeholder until the estates rule. + +## First call + +``` +cargo run --example first_call # prints the Meta example and a splitting table +cargo test +``` + +```rust +use heimdall_stake::*; +let p = Params::meta_example(); // unit 1 EUR, log10 +assert_eq!(weight(&p, 100_00).unwrap(), 3 * MICRO); // 100 EUR +assert_eq!(weight(&p, 10_000_00).unwrap(), 5 * MICRO); // 10,000 EUR +let costly = Params { min_amount_per_identity_cents: 50_00, ..p }; +let r = splitting_gain(&costly, 100_00, 3).unwrap(); // 100 EUR as three identities of 33.33 +assert_eq!((r.one_identity, r.split_total), (3 * MICRO, 0)); +``` + +The output feeds `heimdall-delegation`'s integer `weight`; the caller picks the scale. + +## Formula + +Per Meta, `weight = 1 + log_b(amount / unit)`, with the base `b` and the `unit` set by the charter. +Meta's example: unit 1 EUR, `b` = 10: 100 EUR weighs 3, 10,000 EUR weighs 5 (both tested exactly). +Amounts are integer cents; negative and zero amounts weigh 0. The result is clamped at 0 (an +amount under `unit / b` would otherwise go negative) and rounded to the nearest micro-weight. + +Heimdall's parameters on top: + +- `floor_cents`: an amount **at or below** the floor weighs 0. (The brief said both "below" and + "at/below"; at-or-below is implemented and tested.) +- `per_identity_cap`: the most weight one identity can hold, in micro-weights; `None` is uncapped. +- `min_amount_per_identity_cents`: an identity holding **less** than this is ineligible and weighs 0. + +`floor` and `min_amount_per_identity` both zero a weight and compose; they differ in what they are +for (the floor trims dust from the formula, the minimum prices an identity), and the estates may +want one parameter instead of two. + +Properties (tested): monotone in the amount for any parameters; concave above the unit when no +floor or minimum intrudes (the floor and minimum are steps, so the whole function is not concave); +zero at and below the floor; the cap is never exceeded and binds; deterministic; total over every +`i64` including `i64::MIN`; the integer log agrees with `f64` to 2 micro-weights; bad parameters +(`base < 2`, `unit == 0`) are `Err`, not panics. + +## Splitting gain + +`splitting_gain(params, budget_cents, k)` puts one budget in one identity and, separately, evenly +over `k` identities, and reports both totals, the gain, how many split identities are eligible and +the smallest share. `splitting_curve` and `most_profitable_split` sweep `k`. + +The log formula's `+1` is a splitting incentive: with no minimum, 100 EUR as ten identities of +10 EUR weighs 20 against 3 as one. The minimum is the only lever that prices it, and it prices it +as a cliff, not a slope: with a 50 EUR minimum, 100 EUR split in two still gains (5.40 against 3.00 +in the example), and split in three weighs 0. So the minimum has to sit near the size of a typical +budget to deter splitting, and the report is how to see where it does. + +## Why the sybil defence cannot live here + +`weight = 1 + log_b(amount / unit)` pays the constant 1 once per identity. Splitting a budget into +k identities pays that 1 k times and costs only the logarithm's shrinkage, so splitting wins until +the log term goes negative: with unit 1 EUR and b = 10, 100 EUR weighs 3 as one identity, 5.40 as +two, 20 as ten and 100 as one hundred (`tests/vectors/stake_golden.csv`, pinned by +`tests/golden.rs`). No choice of `b` fixes this: `b` scales the log term and never the constant. +A cap or a per-identity minimum only moves the cliff (see Splitting gain). So the defence belongs +outside the function: non-transferable or vesting stake, a per-person cap on total stake, and a +cost to create an identity. This crate states the incentive; it cannot remove it. + +## Where this sits in the estate rule + +This crate does not normalize. The estate rule normalizes weights above it; the weights here are +raw. A sybil rate (how many identities an adversary splits a budget into) is an adversary the +simulation harness plays, not a parameter of this function; `splitting_gain` is its closed-form +probe for the even-split case only. + +## Prior art + +Sources below are from background knowledge and were not re-fetched for this README; verify before +citing. + +- **Quadratic voting** (Lalley and Weyl, "Quadratic Voting: How Mechanism Design Can Radicalize + Democracy", 2018; Posner and Weyl, *Radical Markets*, 2018): the cost of v votes is v², so the + marginal price rises linearly and vote weight grows with the square root of spend. Lalley-Weyl + show it is efficient in large populations under assumptions that include no collusion and no + identity splitting, and that identity splitting is exactly what breaks it. Our weight grows + slower still (log), so the money buys less influence at the top, and the same sybil caveat applies + with the same remedy: a cost per identity. +- **Logarithmic weight** has the same shape as diminishing-returns utility (Bernoulli) and as the + concave caps in some stake-weighted systems; here its role is only concavity, with the charter + choosing how fast through the base. +- **Sybil attack** (Douceur, 2002): without a cost per identity, an open system cannot tell one + participant from many. The per-identity minimum is that cost, in money. diff --git a/packages/heimdall-stake/examples/first_call.rs b/packages/heimdall-stake/examples/first_call.rs new file mode 100644 index 00000000..80275375 --- /dev/null +++ b/packages/heimdall-stake/examples/first_call.rs @@ -0,0 +1,11 @@ +use heimdall_stake::*; +fn main() { + let p = Params::meta_example(); + println!("100 EUR -> {}", weight(&p, 100_00).unwrap() as f64 / MICRO as f64); + println!("10,000 EUR -> {}", weight(&p, 10_000_00).unwrap() as f64 / MICRO as f64); + let costly = Params { min_amount_per_identity_cents: 50_00, ..p }; + for k in [1, 2, 3, 10] { + let r = splitting_gain(&costly, 100_00, k).unwrap(); + println!("100 EUR in {k:>2} identities: {:.2} vs {:.2} (gain {:+.2})", r.split_total as f64 / 1e6, r.one_identity as f64 / 1e6, r.gain as f64 / 1e6); + } +} diff --git a/packages/heimdall-stake/src/lib.rs b/packages/heimdall-stake/src/lib.rs new file mode 100644 index 00000000..4e168f34 --- /dev/null +++ b/packages/heimdall-stake/src/lib.rs @@ -0,0 +1,185 @@ +//! Heimdall's stake function. See README.md for the formula and its placeholders. +//! +//! `weight = 1 + log_b(amount / unit)`, with a floor, a per-identity cap and a minimum amount per +//! identity. Pure and integer-only: amounts are integer cents, weights are integer micro-weights +//! (`MICRO` = 1.0), and the logarithm is computed in fixed point with no floating point, so the +//! same inputs give the same bits on every platform. + +use std::fmt; + +/// Weight in millionths: `MICRO` is a weight of 1.0. Feeds `heimdall-delegation`'s integer weights +/// after the caller picks a scale. +pub type Weight = u64; +pub const MICRO: Weight = 1_000_000; + +/// All of these are placeholders until the estates rule; the charter sets `base` and `unit_cents`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Params { + /// Logarithm base `b`, an integer of at least 2. + pub base: u32, + /// The amount worth weight 1.0, in cents; at least 1. + pub unit_cents: u64, + /// Amounts at or below this weigh 0. + pub floor_cents: u64, + /// Highest weight one identity can hold, in micro-weights; `None` is uncapped. + pub per_identity_cap: Option, + /// An identity holding less than this is not eligible and weighs 0 (the splitting cost). + pub min_amount_per_identity_cents: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParamError { + BaseBelowTwo, + UnitZero, +} + +impl fmt::Display for ParamError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParamError::BaseBelowTwo => write!(f, "log base must be at least 2"), + ParamError::UnitZero => write!(f, "unit must be at least 1 cent"), + } + } +} + +impl std::error::Error for ParamError {} + +impl Params { + /// Meta's example: unit 1 EUR, log10, no floor, no cap, no minimum. + pub fn meta_example() -> Params { + Params { base: 10, unit_cents: 100, floor_cents: 0, per_identity_cap: None, min_amount_per_identity_cents: 0 } + } + + pub fn validate(&self) -> Result<(), ParamError> { + if self.base < 2 { + return Err(ParamError::BaseBelowTwo); + } + if self.unit_cents == 0 { + return Err(ParamError::UnitZero); + } + Ok(()) + } +} + +const FRAC: u32 = 40; + +/// log2(n) for n >= 1 in Q40 fixed point, by the square-and-compare method: no floats, so it is +/// the same everywhere. Error is below 2^-38. +fn log2_q40(n: u64) -> i128 { + let k = 63 - n.leading_zeros(); + let mut m: u128 = if k >= 60 { (n >> (k - 60)) as u128 } else { (n as u128) << (60 - k) }; // [1,2) in Q60 + let mut frac: u128 = 0; + for bit in (0..FRAC).rev() { + m = (m * m) >> 60; + if m >= 1u128 << 61 { + m >>= 1; + frac |= 1u128 << bit; + } + } + ((k as i128) << FRAC) | frac as i128 +} + +/// Weight of one identity holding `amount_cents`. Zero for zero and negative amounts, for amounts at +/// or below the floor, and for amounts under the per-identity minimum. Never panics on any input. +pub fn weight(params: &Params, amount_cents: i64) -> Result { + params.validate()?; + if amount_cents <= 0 { + return Ok(0); + } + let amount = amount_cents as u64; + if amount <= params.floor_cents || amount < params.min_amount_per_identity_cents { + return Ok(0); + } + // micro = 1e6 * (1 + (log2 a - log2 u) / log2 b), rounded to nearest. + let num = (log2_q40(amount) - log2_q40(params.unit_cents)) * MICRO as i128; + let den = log2_q40(params.base as u64); + let half = den / 2; + let rounded = if num >= 0 { (num + half) / den } else { (num - half) / den }; + let raw = (MICRO as i128 + rounded).max(0) as u128; + let capped = match params.per_identity_cap { + Some(cap) => raw.min(cap as u128), + None => raw, + }; + Ok(capped.min(Weight::MAX as u128) as Weight) +} + +/// Total weight of several identities, each weighed on its own (the cap and minimum are per identity). +pub fn total_weight(params: &Params, amounts_cents: &[i64]) -> Result { + params.validate()?; + let mut total: Weight = 0; + for &a in amounts_cents { + total = total.saturating_add(weight(params, a)?); + } + Ok(total) +} + +/// One budget held as a single identity against the same budget split evenly over `k` identities +/// (the first `budget % k` identities hold one extra cent). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SplitReport { + pub budget_cents: i64, + pub k: u64, + pub one_identity: Weight, + pub split_total: Weight, + /// `split_total - one_identity`: positive means splitting pays. + pub gain: i128, + /// Identities of the split that clear the floor and the minimum. + pub eligible_identities: u64, + /// Smallest amount any identity of the split holds. + pub smallest_share_cents: i64, +} + +/// What splitting `budget_cents` over `k` identities does to total weight. `k` of 0 is treated as 1. +pub fn splitting_gain(params: &Params, budget_cents: i64, k: u64) -> Result { + params.validate()?; + let k = k.max(1); + let one_identity = weight(params, budget_cents)?; + let (base_share, extra, smallest) = if budget_cents <= 0 { + (0i64, 0u64, budget_cents) + } else { + let b = budget_cents as u64; + ((b / k) as i64, b % k, (b / k) as i64) + }; + // Identities are of two sizes, so weigh the two sizes instead of looping over k. + let mut split_total: u128 = 0; + let mut eligible = 0u64; + let big = k.min(extra); + let small = k - big; + for (count, amount) in [(big, base_share.saturating_add(1)), (small, base_share)] { + if count == 0 { + continue; + } + let w = weight(params, amount)?; + if w > 0 { + eligible += count; + } + split_total += w as u128 * count as u128; + } + let split_total = split_total.min(Weight::MAX as u128) as Weight; + Ok(SplitReport { + budget_cents, + k, + one_identity, + split_total, + gain: split_total as i128 - one_identity as i128, + eligible_identities: eligible, + smallest_share_cents: smallest, + }) +} + +/// `splitting_gain` for k = 1..=max_k. +pub fn splitting_curve(params: &Params, budget_cents: i64, max_k: u64) -> Result, ParamError> { + (1..=max_k.max(1)).map(|k| splitting_gain(params, budget_cents, k)).collect() +} + +/// The k in 1..=max_k with the largest split total (smallest k on ties), and its report. +pub fn most_profitable_split(params: &Params, budget_cents: i64, max_k: u64) -> Result { + let mut best = splitting_gain(params, budget_cents, 1)?; + for k in 2..=max_k { + let r = splitting_gain(params, budget_cents, k)?; + if r.split_total > best.split_total { + best = r; + } + } + Ok(best) +} diff --git a/packages/heimdall-stake/tests/golden.rs b/packages/heimdall-stake/tests/golden.rs new file mode 100644 index 00000000..d742aa5a --- /dev/null +++ b/packages/heimdall-stake/tests/golden.rs @@ -0,0 +1,30 @@ +use heimdall_stake::*; + +const VECTORS: &str = include_str!("vectors/stake_golden.csv"); + +/// Pins `weight` and `total_weight` to values computed outside this crate (see the fixture header). +/// Split rows are the sybil argument in numbers: the same budget in k identities weighs more. +#[test] +fn golden_vectors() { + let mut rows = 0; + let mut split_rows = 0; + for line in VECTORS.lines().filter(|l| !l.starts_with('#') && !l.trim().is_empty()) { + let f: Vec<&str> = line.split(',').collect(); + let (base, unit, amount, k, expected): (u32, u64, i64, usize, u64) = + (f[1].parse().unwrap(), f[2].parse().unwrap(), f[3].parse().unwrap(), f[4].parse().unwrap(), f[5].parse().unwrap()); + let p = Params { base, unit_cents: unit, floor_cents: 0, per_identity_cap: None, min_amount_per_identity_cents: 0 }; + let got = match f[0] { + "single" => weight(&p, amount).unwrap(), + "split" => { + split_rows += 1; + let (q, r) = (amount / k as i64, (amount % k as i64) as usize); + let shares: Vec = (0..k).map(|i| q + i64::from(i < r)).collect(); + total_weight(&p, &shares).unwrap() + } + other => panic!("unknown vector kind {other}"), + }; + assert_eq!(got, expected, "vector `{line}`"); + rows += 1; + } + assert!(rows >= 20 && split_rows >= 12, "fixture shrank: {rows} rows, {split_rows} split"); +} diff --git a/packages/heimdall-stake/tests/properties.rs b/packages/heimdall-stake/tests/properties.rs new file mode 100644 index 00000000..7e450fbd --- /dev/null +++ b/packages/heimdall-stake/tests/properties.rs @@ -0,0 +1,198 @@ +use heimdall_stake::*; + +/// xorshift64*: seeded, dependency-free, reproducible. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545F4914F6CDD1D) + } + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } +} + +fn random_params(r: &mut Rng) -> Params { + Params { + base: 2 + r.below(15) as u32, + unit_cents: 1 + r.below(100_000), + floor_cents: if r.below(2) == 0 { 0 } else { r.below(50_000) }, + per_identity_cap: if r.below(2) == 0 { None } else { Some(MICRO + r.below(8 * MICRO)) }, + min_amount_per_identity_cents: if r.below(2) == 0 { 0 } else { r.below(20_000) }, + } +} + +/// Log-uniform amounts in cents, so every magnitude is exercised. +fn random_amount(r: &mut Rng) -> i64 { + let bits = 1 + r.below(40); + (r.next() >> (64 - bits)) as i64 +} + +#[test] +fn meta_example_unit_one_euro_log10() { + let p = Params::meta_example(); + assert_eq!(weight(&p, 100_00).unwrap(), 3 * MICRO); // 100 EUR + assert_eq!(weight(&p, 10_000_00).unwrap(), 5 * MICRO); // 10,000 EUR + assert_eq!(weight(&p, 100).unwrap(), MICRO); // 1 EUR = the unit = weight 1 + assert_eq!(weight(&p, 10_00).unwrap(), 2 * MICRO); // 10 EUR +} + +#[test] +fn base_two_and_other_units_hit_exact_powers() { + let p = Params { base: 2, unit_cents: 250, ..Params::meta_example() }; + assert_eq!(weight(&p, 250 * 8).unwrap(), 4 * MICRO); // 1 + log2(8) + let q = Params { base: 7, unit_cents: 1, ..Params::meta_example() }; + assert_eq!(weight(&q, 7u64.pow(9) as i64).unwrap(), 10 * MICRO); +} + +#[test] +fn integer_log_agrees_with_floating_point_to_two_micro() { + let mut r = Rng(0xC0FFEE); + for _ in 0..20_000 { + let p = Params { base: 2 + r.below(15) as u32, unit_cents: 1 + r.below(1_000_000), floor_cents: 0, per_identity_cap: None, min_amount_per_identity_cents: 0 }; + let a = 1 + random_amount(&mut r).abs(); + let expect = 1.0 + (a as f64 / p.unit_cents as f64).ln() / (p.base as f64).ln(); + let expect = (expect.max(0.0) * MICRO as f64).round(); + let got = weight(&p, a).unwrap() as f64; + assert!((got - expect).abs() <= 2.0, "base {} unit {} amount {a}: {got} vs {expect}", p.base, p.unit_cents); + } +} + +#[test] +fn monotone_in_amount_for_any_parameters() { + let mut r = Rng(1); + for _ in 0..300 { + let p = random_params(&mut r); + let mut amounts: Vec = (0..60).map(|_| random_amount(&mut r)).collect(); + amounts.sort(); + let ws: Vec = amounts.iter().map(|&a| weight(&p, a).unwrap()).collect(); + assert!(ws.windows(2).all(|w| w[0] <= w[1]), "{p:?} {amounts:?} {ws:?}"); + } +} + +#[test] +fn concave_above_the_unit_when_no_floor_or_minimum_intrudes() { + // Second differences of 1 + log_b(a/u) are non-positive; rounding to a micro-weight allows 2. + let mut r = Rng(2); + for _ in 0..300 { + let mut p = random_params(&mut r); + p.floor_cents = 0; + p.min_amount_per_identity_cents = 0; + let a = p.unit_cents as i64 + random_amount(&mut r) % 1_000_000_000; + let d = 1 + (r.below(1_000_000) as i64); + let (w0, w1, w2) = (weight(&p, a).unwrap() as i128, weight(&p, a + d).unwrap() as i128, weight(&p, a + 2 * d).unwrap() as i128); + assert!((w2 - w1) <= (w1 - w0) + 2, "{p:?} a {a} d {d}: {w0} {w1} {w2}"); + } +} + +#[test] +fn zero_at_and_below_the_floor_and_positive_just_above() { + let mut r = Rng(3); + for _ in 0..300 { + let mut p = random_params(&mut r); + p.min_amount_per_identity_cents = 0; + p.per_identity_cap = None; + p.floor_cents = 1 + r.below(1_000_000); + for a in [p.floor_cents as i64, p.floor_cents as i64 - 1, 1] { + assert_eq!(weight(&p, a).unwrap(), 0, "{p:?} amount {a}"); + } + // Far enough above both floor and unit that the formula is positive. + let above = (p.floor_cents.max(p.unit_cents) * 2) as i64; + assert!(weight(&p, above).unwrap() > 0, "{p:?} amount {above}"); + } +} + +#[test] +fn cap_is_never_exceeded_and_binds_when_the_formula_passes_it() { + let mut r = Rng(4); + for _ in 0..300 { + let mut p = random_params(&mut r); + let cap = MICRO + r.below(8 * MICRO); + p.per_identity_cap = Some(cap); + for _ in 0..40 { + assert!(weight(&p, random_amount(&mut r)).unwrap() <= cap); + } + let uncapped = Params { per_identity_cap: None, ..p }; + let big = i64::MAX; + if weight(&uncapped, big).unwrap() > cap && p.min_amount_per_identity_cents <= big as u64 { + assert_eq!(weight(&p, big).unwrap(), cap); + } + } +} + +#[test] +fn deterministic_and_total_over_every_input() { + let mut r = Rng(5); + for _ in 0..200 { + let p = random_params(&mut r); + for a in [i64::MIN, -1, 0, 1, i64::MAX, random_amount(&mut r)] { + assert_eq!(weight(&p, a), weight(&p, a)); + } + let budget = random_amount(&mut r); + assert_eq!(splitting_gain(&p, budget, 7), splitting_gain(&p, budget, 7)); + assert_eq!(splitting_curve(&p, budget, 5), splitting_curve(&p, budget, 5)); + } + let p = Params::meta_example(); + for a in [i64::MIN, -5, -1, 0] { + assert_eq!(weight(&p, a).unwrap(), 0); + } + assert!(weight(&p, i64::MAX).unwrap() > 0); +} + +#[test] +fn bad_parameters_are_errors_not_panics() { + let mut p = Params::meta_example(); + p.base = 1; + assert_eq!(weight(&p, 100), Err(ParamError::BaseBelowTwo)); + p.base = 0; + assert_eq!(total_weight(&p, &[1]), Err(ParamError::BaseBelowTwo)); + p = Params { unit_cents: 0, ..Params::meta_example() }; + assert_eq!(splitting_gain(&p, 100, 2), Err(ParamError::UnitZero)); +} + +#[test] +fn splitting_report_matches_summing_the_shares_by_hand() { + let mut r = Rng(6); + for _ in 0..200 { + let p = random_params(&mut r); + let budget = 1 + random_amount(&mut r); + let k = 1 + r.below(40); + let rep = splitting_gain(&p, budget, k).unwrap(); + let shares: Vec = (0..k).map(|i| (budget as u64 / k + u64::from(i < budget as u64 % k)) as i64).collect(); + assert_eq!(shares.iter().sum::(), budget); + assert_eq!(rep.split_total, total_weight(&p, &shares).unwrap()); + assert_eq!(rep.one_identity, weight(&p, budget).unwrap()); + assert_eq!(rep.gain, rep.split_total as i128 - rep.one_identity as i128); + assert_eq!(rep.smallest_share_cents, *shares.iter().min().unwrap()); + assert_eq!(rep.eligible_identities, shares.iter().filter(|&&s| weight(&p, s).unwrap() > 0).count() as u64); + } +} + +#[test] +fn without_a_minimum_splitting_pays_and_with_one_it_stops_paying() { + // 100 EUR, unit 1 EUR, log10: one identity weighs 3. Ten identities of 10 EUR weigh 2 each = 20. + let free = Params::meta_example(); + let rep = splitting_gain(&free, 100_00, 10).unwrap(); + assert_eq!((rep.one_identity, rep.split_total), (3 * MICRO, 20 * MICRO)); + assert!(rep.gain > 0); + assert_eq!(most_profitable_split(&free, 100_00, 50).unwrap().k, rep_best_k(&free, 100_00, 50)); + + // Each identity must hold 50 EUR: two shares of 50 EUR are eligible, three of 33.33 are not. + let costly = Params { min_amount_per_identity_cents: 50_00, ..free }; + assert_eq!(splitting_gain(&costly, 100_00, 2).unwrap().eligible_identities, 2); + let three = splitting_gain(&costly, 100_00, 3).unwrap(); + assert_eq!((three.split_total, three.eligible_identities), (0, 0)); + assert_eq!(three.gain, -(3 * MICRO as i128)); + // Past the minimum the curve is dead flat at zero, so the best split is never past budget/min. + let best = most_profitable_split(&costly, 100_00, 200).unwrap(); + assert!(best.k <= 2 && best.split_total > 0); + for rep in splitting_curve(&costly, 100_00, 200).unwrap().iter().filter(|r| r.k > 2) { + assert_eq!(rep.split_total, 0); + } +} + +fn rep_best_k(p: &Params, budget: i64, max_k: u64) -> u64 { + splitting_curve(p, budget, max_k).unwrap().iter().max_by_key(|r| (r.split_total, std::cmp::Reverse(r.k))).unwrap().k +} diff --git a/packages/heimdall-stake/tests/vectors/stake_golden.csv b/packages/heimdall-stake/tests/vectors/stake_golden.csv new file mode 100644 index 00000000..26f8978a --- /dev/null +++ b/packages/heimdall-stake/tests/vectors/stake_golden.csv @@ -0,0 +1,29 @@ +# kind,base,unit_cents,amount_cents,k,expected_micro +# single: weight of one identity holding amount. split: budget (amount) split evenly over k identities (first amount%k hold one extra cent), summed. +# expected values computed independently with 60-digit decimal ln, rounded half up. No floor, cap or minimum. +single,10,100,100,1,1000000 +single,10,100,1000,1,2000000 +single,10,100,10000,1,3000000 +single,10,100,1000000,1,5000000 +single,10,100,50,1,698970 +single,10,100,10,1,0 +single,10,100,1,1,0 +single,2,100,400,1,3000000 +single,2,100,100,1,1000000 +single,2,1,1,1,1000000 +single,3,500,123456,1,6014537 +single,7,1,987654321,1,11643268 +single,10,1,1,1,1000000 +single,16,250,99999,1,3160960 +split,10,100,10000,1,3000000 +split,10,100,10000,2,5397940 +split,10,100,10000,10,20000000 +split,10,100,10000,100,100000000 +split,2,100,10000,1,7643856 +split,2,100,10000,2,13287712 +split,2,100,10000,10,43219280 +split,2,100,10000,100,100000000 +split,10,100,100000,1,4000000 +split,10,100,100000,2,7397940 +split,10,100,100000,10,30000000 +split,10,100,100000,100,200000000 diff --git a/research/cultmesh-garden-path/browser-sim-runtime-2026-10-27.md b/research/cultmesh-garden-path/browser-sim-runtime-2026-10-27.md new file mode 100644 index 00000000..53044b31 --- /dev/null +++ b/research/cultmesh-garden-path/browser-sim-runtime-2026-10-27.md @@ -0,0 +1,143 @@ +# Where simulation code runs in the browser: prior art (C# wasm, dual implementation, other arrangements) + +Date: 2026-10-27. Read-only Eyes pass. Facts with evidence pointers; no recommendation. + +**Serves** open fork 1 of `F:\Projects\CultLib\docs\cultmesh-garden-path-target.md`: "Where simulation code runs in the browser: C# compiled to WebAssembly, C# on the server with TypeScript systems in the browser, or another arrangement." Campaign `cultmesh-garden-path`, session label `self-2026-10-27-eureka`. Companion (not repeated): `research/cultmesh-garden-path/prior-art-2026-10-27.md` (frameworks, scale-out). + +## Tags and method + +- VERIFIED: read in a fetched source this pass (URL given). Fetching went through a summarising tool; quoted figures are as it returned them. +- SECONDARY: search-result summaries, forum posts, third-party blogs, one project's own claims. +- UNVERIFIED: inferred or not found. Gaps are listed, not filled from memory. +- No measurement was run locally (no wasm-tools workload install, no publish). Every size and speed figure below is someone else's. +- Search returned no official .NET 10 hello-world size, startup time, or per-call interop number. Those gaps are real. + +--- + +## A. C# compiled to WebAssembly + +### A1. Runtime options and their status + +- Shipping runtime for `browser-wasm` is Mono (Blazor WebAssembly runtime; usable without Blazor UI via `[JSImport]`/`[JSExport]` since .NET 7). VERIFIED (https://meziantou.net/using-dotnet-code-from-javascript-using-webassembly.htm: ".NET 7 ... run any .NET method from JavaScript without the full Blazor framework"). +- Microsoft's MAUI post says Blazor WebAssembly keeps Mono in .NET 11. SECONDARY (search summary of https://devblogs.microsoft.com/dotnet/dotnet-maui-moves-to-coreclr-in-dotnet-11/; page not fetched). +- CoreCLR-on-wasm migration: tracking issue dotnet/runtime#121511 targets "an early preview in the .NET 11 timeframe" on the CoreCLR interpreter, with R2R and RyuJIT later and .NET 12 for stability. VERIFIED plan text (https://github.com/dotnet/runtime/issues/121511). The same page's fetch says the maintainer called the work "mostly done" on 2026-08-11 and closed it with debugger and tests outstanding; a search summary said "early, experimental". The two conflict on how far along it is. SECONDARY / unreconciled. A July 2026 PR (dotnet/runtime#131420) fixes publishing for an "experimental CoreCLR wasm SDK". SECONDARY. +- NativeAOT-LLVM (dotnet/runtimelab branch `feature/NativeAOT-LLVM`): described as "an experimental fork of the Native AOT toolchain that uses LLVM", WebAssembly the only target, "for experimentation ... may or may not make it into the main dotnet/runtime repo". VERIFIED (https://github.com/dotnet/runtimelab/tree/feature/NativeAOT-LLVM). In dotnet/runtime#113979 a maintainer says the main focus is Blazor, "which is not NativeAOT-compatible, so NativeAOT-LLVM is not the default yet"; another commenter: still experimental with unresolved issues. VERIFIED (https://github.com/dotnet/runtime/issues/113979). SpacetimeDB's C# module build treats NativeAOT-LLVM as its recommended path after the older wasi-experimental workload was removed from .NET 9+. SECONDARY (mirror of a SpacetimeDB PR, dated May 2026). +- Godot: C# web export is not in official releases. Forum thread, last post 2026-04-17, quotes the GodotCon May 2025 post ("cannot commit to a specific timeline yet"), draft PR #106125 by raulsntos. VERIFIED (https://forum.godotengine.org/t/is-there-an-update-on-exporting-c-projects-to-web/128821). Community builds exist and one user reported a "null function" error. VERIFIED (same thread). + +### A2. Download size + +- .NET 7 minimal exported method, trimmed: 2.81 MB uncompressed, about 819 kB Brotli; `dotnet.wasm` 342 kB, `System.Private.CoreLib.dll` 407 kB, `dotnet.js` 54 kB, JS interop assembly 14 kB, app DLL 3 kB. Author calls the baseline "quite large"; other assemblies are pay-for-use through the linker. VERIFIED (meziantou URL above). +- A .NET 10 figure of 1.3 MB runtime (versus 1.8 MB on .NET 8) comes from a vendor tutorial with no method. SECONDARY, weak (https://dev.to/ironsoftware/webassembly-and-net-10-run-c-in-the-browser-tutorial-4ffo). Whether 1.3 MB is compressed is not stated there. +- Official docs: trimming reduces size, AOT increases it, `InvariantGlobalization`/`InvariantTimezone` drop ICU and timezone data, gzip and Brotli assets are published by default. VERIFIED (https://github.com/dotnet/runtime/blob/main/src/mono/wasm/features.md). +- AOT cost in a real C# game port (FlatRedBall2 on KNI/BlazorGL): AOT added a ~40 MB `dotnet.native.*.wasm` and about 37 MB to the payload; frame time about 8-12 ms with the IL interpreter kept versus 57-98 ms JIT baseline (their numbers, their game). VERIFIED as reported (https://github.com/vchelaru/FlatRedBall2/issues/1304). Not a minimal app; sets an order of magnitude for AOT of a framework-heavy game. +- Avalonia deployment guide: Brotli typically shrinks .wasm by 60-70%; trimming on by default in release. SECONDARY (https://docs.avaloniaui.net/docs/deployment/webassembly, via search summary). +- UNVERIFIED: a measured .NET 10 minimal `browser-wasm` publish with Brotli sizes and AOT/non-AOT split. Not found. + +### A3. Startup time + +- Official guidance only: set `EmccInitialHeapSize` to cut memory-growth delay, preload `dotnet.boot.js`, benchmark on real devices; AOT "can slow startup"; the runtime and assemblies are fetched, hashed and cached by the browser (Blazor fingerprints and integrity-checks each file, `blazor.boot.json` removed in .NET 10, manifest inlined in `dotnet.js`). VERIFIED (features.md above; https://learn.microsoft.com/en-us/aspnet/core/blazor/host-and-deploy/webassembly/bundle-caching-and-integrity-check-failures). +- No millisecond startup figure found for any .NET version. UNVERIFIED. +- Wavedash's C# template (net9.0, `browser-wasm`, `InvariantGlobalization`) boots by `await dotnet.create()` then runs exports; no timing or size stated. VERIFIED (https://docs.wavedash.com/engines/csharp). + +### A4. JS interop cost + +- Docs: interop operations are "roughly an order of magnitude slower" than in-.NET calls but "relatively fast"; `JSObject`, `Exception`, `Task`, `ArraySegment` create a `GCHandle` and proxy and "carry significant performance overhead"; `Span` marshals to JS as `MemoryView` with no copy, valid only for the call's duration; JS cannot return `Span` to .NET as an import parameter. Search summary of the .NET 9 page; the .NET 10 page was fetched and the table lines (MemoryView rows, GCHandle remark at lines 241-310 of the saved output) agree. VERIFIED (https://learn.microsoft.com/en-us/aspnet/core/client-side/dotnet-interop/?view=aspnetcore-10.0). The "order of magnitude" sentence itself is SECONDARY (search summary). +- Benchmark in dotnet/runtime#113979, 100,000 calls each: Rust wasm-bindgen 5.91 ms (echo number); .NET 9 Mono AOT 65.49 ms (about 0.65 us/call); NativeAOT-LLVM 18.85 ms to 45.08 ms depending on machine. Echo of a struct via JSON round-trip: Rust 238 ms, Mono AOT 1082 ms, NativeAOT-LLVM 191-375 ms. Closed "not planned" in January 2026 with no Mono AOT work committed. VERIFIED (https://github.com/dotnet/runtime/issues/113979). +- A third-party SDK (NetWasm) measures a synchronous `[JSImport]` at about 1.4 us (ulong return) and 0.9 us (void, three scalars) and about 15 ns for a direct wasm-to-JS import. SECONDARY (https://github.com/zion-sati/NetWasm/issues/115, search summary). +- Marshalling pitfalls in a game port: string-only marshalling forced base64 for byte data; `System.Text.Json` reflection disabled on wasm, source-generated contexts required; AOT with IL stripped breaks reflection paths; module-less `[JSImport]` works only with names starting `globalThis`. VERIFIED as reported (FlatRedBall2#1304 above). +- UNVERIFIED: any official per-call microsecond figure for .NET 10 JSImport/JSExport. + +### A5. Threading + +- Multithreaded wasm is opt-in: `true`, separate runtime build, server must send COOP and COEP headers, `[JSExport]`/`[JSImport]` limited to the main thread, blocking the main thread discouraged. VERIFIED (features.md above). +- Uno Platform's bootstrap pre-creates 4 web workers for its threading mode and needs the same headers. SECONDARY (https://platform.uno/docs/articles/external/uno.wasm.bootstrap/doc/features-threading.html, search summary). +- Celeste browser port (MercuryWorkshop/celeste-wasm) uses "a multithreaded and JIT compiling version of mono-wasm backported from .NET 10 to .NET 9", renders on a "deputy thread", uses about 600 MB memory, "too high for low-end devices". VERIFIED (https://github.com/MercuryWorkshop/celeste-wasm; Hackaday 2025-10-17, https://hackaday.com/tag/net). +- Unity Web: managed C# threads unsupported, reason given "WebAssembly's lack of multithreaded garbage collection". VERIFIED (https://docs.unity3d.com/6000.0/Documentation/Manual/webgl-technical-overview.html). + +### A6. GC under a 60 Hz loop + +- .NET wasm docs say nothing on GC behaviour beyond heap growth: growth takes time and can fail on memory-limited devices (hence `EmccInitialHeapSize`/`EmccMaximumHeapSize`); desktop memory 256-512 MB recommended, over 1 GB needs testing, over 2 GB experimental. VERIFIED (features.md). +- A dotnet/runtime commenter said the .NET GC has a conservative mode that needs no multi-threading support. VERIFIED as relayed (dotnet/runtime#121511). +- Unity Web: "garbage collection runs once at the end of each frame, rather than incrementally". VERIFIED (Unity page above). That is Unity's runtime, not .NET's. +- UNVERIFIED: measured GC pause times for Mono-wasm in a 60 Hz loop with a given allocation rate. No source found. Local-allocation guidance (zero allocation in the step) is general practice, not a finding. + +### A7. Numeric determinism: integer and fixed-point versus float + +- WebAssembly spec design note: nondeterminism is "limited, local"; sources are shared-memory thread races, NaN bit patterns and sign bits from arithmetic, relaxed SIMD, resource exhaustion, and host-call order. Integer operations are not in the list. VERIFIED (https://github.com/WebAssembly/design/blob/main/Nondeterminism.md). IEEE-754 basic float operations (+, -, *, /, sqrt) are specified; only NaN payload is free. +- Rust crate docs (alice-det-math): +, -, *, /, sqrt give the same bits on SSE2, aarch64, wasm32; sin/exp/ln come from the platform libm and differ in the last ulp across macOS, glibc, MSVC and wasm; `mul_add` fuses on FMA hardware. SECONDARY, single crate's claim (https://docs.rs/alice-det-math). +- bevy_ggrs docs: its f32 example is deterministic on the same platform but "may produce different results across different CPU architectures or operating systems". SECONDARY (https://github.com/gschup/bevy_ggrs, search summary). +- .NET-specific: a hydrology vendor (eWater) found `Math.Pow`/`Math.Exp` differing across machines in 0.09% of random calls and replaced them. SECONDARY, 2019, old runtime (https://ewater.atlassian.net/wiki/spaces/SC/pages/51652397). +- UNVERIFIED: whether RyuJIT contracts `a*b+c` into FMA on x64/arm64 and whether Mono-wasm (interpreter, jiterpreter, AOT) does not, so whether plain float arithmetic is bit-identical between .NET native and .NET wasm. Whether `Math.Sin/Cos/Pow` in .NET wasm route to Emscripten's libm versus the host's CRT. No source found for either. +- UNVERIFIED but implied by the above: `int`/`long` arithmetic and shifts are defined by both the CLR and the wasm spec; a fixed-point type built from them has no listed source of divergence between targets. +- Local state: `F:\Projects\CultLib\packages\cultmath` is a C# library with GLSL lowering and mirror tests; a search for `Fix64|Fixed64|struct Fix` found nothing in `src` or `packages` C# (one Rust test file matched). `Fix2` in the target document is illustrative, not existing code. + +### A8. Existence proofs and counter-examples + +- Unity WebGL/Web (C# via IL2CPP to wasm): ships widely. Empty-project build 14.7 MB falling to 12.5 MB Brotli with "Disk Size with LTO" (CrazyGames). VERIFIED (https://docs.crazygames.com/resources/optimization-tips/). FRVR: runtime about 5 MB "empty", about 7 MB "minimal", targets about 8 MB initial. SECONDARY (https://docs.frvr.com/engines/unity/optimization/, search summary). +- Godot C#: no official web export (A1). +- .NET wasm games found: a Celeste port (mono-wasm backport, runs Everest mods; hacky by its own description), an Uno Platform space shooter (hobby), Wavedash's Pong template, FlatRedBall2/KNI BlazorGL, R136.NET text adventure. SECONDARY (search summaries: https://hackaday.com/tag/net, https://platform.uno/?p=9866, https://github.com/rbergen/R136.NET). No commercial .io-class game on .NET wasm found. UNVERIFIED that none exists. +- MonoGame has no official web target; KNI is the community route. SECONDARY (https://docs.wavedash.com/engines/monogame, search summary). + +--- + +## B. Two implementations (C# server, TypeScript client) + +- Colyseus 0.18: prediction needs the local-player `step` to be "the SAME function the server runs"; the docs' shared module is TypeScript imported by both sides. Prediction is documented "across the official SDKs" with examples for TypeScript, C#, Lua, Haxe, GDScript, Dart; `unreliable` input mode only in the JS/TS SDK over WebTransport; some options missing in GDScript and Flutter. Server must advertise a fixed tick via `setFixedTimestep`. Divergence is detected by `warnOnDivergence` and drift telemetry. VERIFIED (https://docs.colyseus.io/netcode/client-prediction). The 0.18 announcement shows prediction APIs only in the JS/TS SDK and says shared code is imported by both sides. VERIFIED (https://colyseus.io/blog/colyseus-018-is-here/). The server is Node/TypeScript, so a non-TS client's `step` is necessarily a second implementation or a port; the pages do not describe how the C# example gets its step. UNVERIFIED which. +- SpacetimeDB TypeScript client: reducers called as promises, subscription cache with `onInsert/onUpdate/onDelete`; the page describes no optimistic update or prediction. VERIFIED (https://spacetimedb.com/docs/clients/typescript). Server modules are Rust or C#, so there is no shared simulation code to predict with. +- Nakama: server runtime Go/Lua/TypeScript; developer owns sync and prediction (see companion §8). VERIFIED there. +- Test-vector mechanism in the field: only drift telemetry (Colyseus) and desync-debug guides (bevy_ggrs) were found; no framework found that ships a cross-language conformance suite for predicted code. UNVERIFIED that none exists. +- Local state (CultLib): runtimes live in `F:\Projects\CultLib\packages\` (`cultcache-ts`, `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser`, `cultcache-rs`, `cultmesh-rs`, `cultmesh-py`, `cultmesh-kotlin`) with wire parity against the C# reference. That parity covers serialisation and protocol, not simulation code; no simulation systems exist in the repo yet. + +--- + +## C. Other arrangements + +### C1. Shared Rust (or C++) core compiled to native and wasm + +- Several hobby and small-studio projects claim one deterministic core on server (native) and browser (wasm) with client prediction (a Rust engine for web/iOS/Android/server with rollback for 20-player sessions; a Rust+wasm Pong on Cloudflare; a C++20/Raylib game via Emscripten). SECONDARY, project pages from one search, none read in depth. The C++ project listed native-versus-wasm determinism as a challenge. +- Ambient (Rust) runs wasm only on the server, server-authoritative with no prediction. SECONDARY. +- bevy_ggrs with matchbox (WebRTC) is the rollback route in Rust; its browser demo is "currently unmaintained". SECONDARY (https://github.com/gschup/bevy_ggrs, search summary). +- Interop cost reference point: Rust wasm-bindgen was the fastest in dotnet/runtime#113979 (A4). + +### C2. C# to JavaScript transpilers + +- Bridge.NET: no maintenance data found. UNVERIFIED. +- h5: fork of Bridge; NuGet `h5-compiler` 26.3.65450 targets net10.0. SECONDARY (https://www.nuget.org/packages/h5-compiler). +- Transpose (Curiosity.ai): "next generation of the h5 compiler, rebuilt around a Roslyn translator and rebranded"; compiler runs on .NET 10; projects target .NET Standard 2.0/2.1; NuGet versions 26.7 to 26.8 in 2026; legacy Bridge/NRefactory pipeline removed; license not stated on the docs page ("All rights reserved" footer). VERIFIED docs text (https://docs.curiosity.ai/transpose/), NuGet versions SECONDARY. The docs page says nothing on integer overflow, `long`, `float`, `decimal` or struct semantics in the JS output. UNVERIFIED. A fetch of the h5 core-concepts page returned 404, so those semantics are unread. +- Not examined: CSharp.lua, SharpKit, Duality, Blazor-less `Uno` WebAssembly (Uno uses the .NET wasm runtime, not transpilation). + +--- + +## D. How current .io games ship client code, and payload tolerance + +- diep.io: its client loads an Emscripten-built WebAssembly module (community reverse-engineering repo has "emscripten" and "asm.js conversion" sections; source language not stated). VERIFIED the repo lists these sections (https://github.com/ABCxFF/diepindepth); the repo is no longer actively worked on. Whether the wasm holds simulation or prediction: not stated. UNVERIFIED. +- agar.io, slither.io, krunker.io: no payload size or client architecture found. UNVERIFIED. DevTools inspection of the live sites was not done. +- Poki (2018 W3C games workshop, 174 Poki games, desktop only): Conversion To Play (players finishing load / players starting) at 10 MB initial download is about 0.8 in the US, about 0.7 in Brazil, below 0.6 in Egypt; "below roughly 10 MB every MB really counts"; above 40 MB there is "lots to gain" from post-loading. VERIFIED (https://www.w3.org/2018/12/games-workshop/papers/size-matters.html). Eight years old. +- Enclave Games recommends a micro-client under 5 MB with remaining assets streamed. VERIFIED, opinion without data (https://enclavegames.com/p/browser-games-lose.html). +- 2026 State of Web Gaming report (2,000 weekly web gamers): 46% had quit a mobile game for slow load, 32% on computers/consoles. SECONDARY (https://www.sentinelassam.com/cities/guwahati-city/web-gamers-quit-slow-apps-as-browser-play-gains-ground-on-social-media-study, search summary). +- Abratabia rule of thumb: about 5 MB initial transfer, JS parse under 500 ms. SECONDARY, unmeasured guidance. +- CrazyGames gives no numeric size threshold on its optimisation page. VERIFIED (https://docs.crazygames.com/resources/optimization-tips/). + +--- + +## E. Gaps worth noting (not recommendations) + +1. No measured .NET 10 minimal `browser-wasm` Brotli size, cold/warm startup, or 60 Hz GC behaviour was found; the closest is .NET 7 at 819 kB Brotli and a vendor claim of 1.3 MB runtime for .NET 10. +2. Native-versus-wasm float bit-identity for .NET (FMA contraction, `Math.*` routing) has no source; integer and fixed-point are unlisted as divergence sources by the wasm spec. +3. Mono-wasm is the shipping .NET browser runtime; CoreCLR-on-wasm and NativeAOT-LLVM are both pre-default, with conflicting reports on how far CoreCLR-wasm has come. +4. Colyseus, the nearest prediction-bearing framework, requires one shared `step` function and documents it in TS with ports in other SDKs. +5. No .io-class game on .NET wasm and no published .io payload sizes were found. + +## Addendum (Self, 2026-10-27): NetWasm, and CultCache's reflection + +Source: https://github.com/zion-sati/NetWasm README, fetched 2026-10-27 (operator pointer from HN). Self-reported, not measured by us. + +- An independent compiler, CoreLib, runtime and SDK: CIL to WebAssembly directly, generics specialised, only reachable runtime support linked, wasm-opt always. Not NativeAOT-LLVM, not Mono. +- Print-42 workload, Compact GC: 38,919 B raw, 13,213 B Brotli-11 (Rust size profile: 16,963 B). Empty Blazor AOT app for context: about 12.3 MB (.NET 10). +- Benchmarks (Wasmtime, M1 Max, 15-run medians): 41-73% less time than Mono AOT on allocation-heavy workloads; parity on n-body and CRC-32. Run in Wasmtime, not a browser. +- Outside the profile, refused at compile time: runtime reflection, `dynamic`, runtime assembly loading, managed threading, named-culture globalisation. Expression trees without general reflection; JSImport scalar calls are direct wasm imports, spans cross as typed-array views. +- Floating-point determinism: not addressed. +- Licence: compiler and tooling under the NetWasm Community License 1.0 (free under 250 employees and USD 10M revenue, paid tiers above); runtime libraries and generated code MIT. +- Maturity: 0.10.3, pre-1.0, contracts may change between minors; 269 stars, 301 commits, single vendor. + +CultLib consequence (source read, corrected same day): the C# path reflects at document registration, not necessarily per record. `CultCache.BuildDescriptor`/`DiscoverMembers` (`src/GameCult.Caching/CultCache.cs` :689, :1087; `GetProperties` :1105, :1138; `MakeGenericType` :1330; `ConstructorArity` :1277) walk members and constructors to mirror MessagePack's member rules for the descriptor and schema catalog, and `CultDocumentResolver` (`src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs` :14-30) builds `CultRecordRef<>` formatters with `MakeGenericType` + `Activator.CreateInstance`. Document formatters come from MessagePack's `StandardResolver` (:61), which in MessagePack 3.x prefers source-generated formatters and falls back to the IL-emitting `DynamicObjectResolver` only for types with none. An earlier version of this paragraph said the codec emits IL at runtime; that rested on a code comment (:1274) and was not established. Whether every [CultDocument] type gets a generated formatter, and what NativeAOT and NetWasm say about the registration path, is for a probe to establish. diff --git a/research/cultmesh-garden-path/cultcache-aot-probe-2026-10-27.md b/research/cultmesh-garden-path/cultcache-aot-probe-2026-10-27.md new file mode 100644 index 00000000..70cc39c2 --- /dev/null +++ b/research/cultmesh-garden-path/cultcache-aot-probe-2026-10-27.md @@ -0,0 +1,60 @@ +# CultCache C# AOT / source-generator probe, 2026-10-27 + +Serves `cultlib-gaps:follow_up:cs-aot-safe-registration`. Probe pass, nothing admitted or committed. Session `self-2026-10-27-eureka`. + +Method: a scratch console (`aot-probe`, kept at `C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\dfe49a4b-e0ab-440c-81c7-7325d2eb7405\scratchpad\aot-probe\`) placed at `tests/aot-probe` in a dangling commit on CultLib main (base `e3fac9c2`, never on a branch), built and run on Yggdrasil with `ygg-verify.sh` in `mcr.microsoft.com/dotnet/sdk:10.0` (net10.0, MessagePack 3.1.7, .NET 10.0.12, `apt install clang zlib1g-dev` works in the image, so real NativeAOT linux-x64 publishes ran). Logs: `job2.log`, `job3.log`, `job4.log` beside the probe. Each claim names its probe. Probe documents: `PlainDoc`, `PlainPartialDoc`, `RefDoc` (CultRecordRef member and ref-keyed dictionary), `UnionMemberDoc` ([Union] interface member), `RootDoc`/`ArmDoc` (document that is a union arm), `MathDoc` (CultMath float3/float4), and a nested `internal` `[MessagePackObject(AllowPrivate = true)]` doc as CultLib's tests declare them. + +## 1. Where the document formatter comes from + +| Claim | Probe | +|---|---| +| A consumer that references only `GameCult.Caching.MessagePack` (as `tests/GameCult.Caching.ConsumerBuild` does) gets **no source generator at all**: 0 generated files. Every document formatter is `MessagePack.Formatters.Probe_*Formatter` in the dynamic assembly `MessagePack.Resolvers.DynamicObjectResolver` (Reflection.Emit). Cause: the library's `MessagePackAnalyzer` reference is `PrivateAssets="all"`, so the generator does not flow to consumers. | job2 "JIT A": `generated files (A): 0`; `Describe(opts.Resolver.GetFormatter())` for all 8 doc types printed `dynamic=True`; `src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj` | +| When the consumer itself has ``, MessagePack's generator runs and emits a formatter per `[MessagePackObject]` type (12 files), into `MessagePack.GeneratedMessagePackResolver`, plus `[assembly: GeneratedAssemblyMessagePackResolverAttribute]`. | job3 "JIT E/F": `generated files: 12` | +| Those generated formatters are used with **no hook**: CultLib's composite options resolve `PlainDoc`, `PlainPartialDoc`, `RefDoc`, `UnionMemberDoc`, `ArmDoc`, `MathDoc`, and the nested internal doc to `MessagePack.GeneratedMessagePackResolver+Probe+*Formatter` (`dynamic=False`). `StandardResolver` finds them itself through `SourceGeneratedFormatterResolver`. `[assembly: CultCacheFormatterResolver(...)]` is not needed for this. | job3 JIT E/F `composite(...)` lines | +| What the type must look like: **not** `partial`, **not** required `public`. A public non-partial sealed class, a partial class, and a nested `internal` class with `[MessagePackObject(AllowPrivate = true)]` and public fields all got generated formatters. `[MessagePackObject]` with `[Key]` is the only requirement. | job3 JIT E/F | +| **Generator breakage: any `CultRecordRef` member, or a `CultRecordRef` dictionary key, makes the generated resolver fail to compile** (`CS0426: The type name 'GameCult' does not exist in the type 'GeneratedMessagePackResolver'`). The generator emits `new global::MessagePack.GeneratedMessagePackResolver.GameCult.Caching.CultRecordRefFormatter()`, a namespace-mangled name for the library's open-generic formatter. Reproduced with Target member only (G), dictionary key only (H), and both (B). The member attribute `[MessagePackFormatter(typeof(CultRecordRefFormatter))]`, which `src/GameCult.Geometry/CultGeometryDocuments.cs:156,231,316` already carries, fixes a member (E) but a dictionary key cannot take one (D: attribute on Target, dictionary kept, still fails). | job2 B, job3 B/D/E/F, job4 G/H; `gen/.../MessagePack.GeneratedMessagePackResolver.g.cs` line 64-65 and `RefDocFormatter` read in job4 G | +| Union interface member, union-root abstract class, document-as-arm: the document formatters are generated; the union type itself resolves to `CultUnionGuard+Formatter` over the generated `IShapeFormatter`/`RootDocFormatter` (the guard wraps whatever the inner chain gives). | job3 JIT E/F | +| CultMath members: `MathDoc` gets a generated formatter whose `float3`/`float4` members resolve at runtime through `CultMathResolver` (`GameCult.Caching.MessagePack.CultMathResolver+ArrayShape`, hand-written, no Emit, no generator). Round trip returned the same values. | job3 JIT F `float3` line, `plain=... math=1,2,3` | +| Byte identity: document payloads from dynamic (A) and generated (F) formatters are identical for PlainDoc, PlainPartialDoc, ArmDoc, MathDoc, UnionMemberDoc, nested doc (`93A1700792A161A162`, `92A27070CB3FF8...`, `92A175920091CA40200000`, ...). | job2 A hex vs job3 FR hex | + +## 2. NativeAOT publish (`PublishAot`, `IsTrimmable`, `EnableTrimAnalyzer`, `TrimmerSingleWarn=false`) + +| Claim | Probe | +|---|---| +| Publish succeeds with warnings (exit 0) in every variant. ~109 `IL` warning lines; 100 are inside MessagePack's `Dynamic*`/Emit machinery (still linked because `StandardResolver` references `DynamicObjectResolver`), 9 distinct CultLib source sites (below). The netstandard2.1 CultLib projects have no source-level trim analyzer (reference assemblies carry no annotations); the warnings come from ILC at publish, with file(line) of the first sequence point. | job3 `--- CultLib-source warnings` | +| Default publish, dynamic-fallback formatters (A): the app fails at runtime in `CultDocumentRegistry.Refresh()` -> `DiscoverMembers` with "has no constructor MessagePack can call": ILC trimmed constructor metadata that `ConstructorArity` reads reflectively. Registration is not AOT-safe even before serialization. | job2 AOT A run | +| With the probe assembly rooted (`TrimmerRootAssembly`) and dynamic fallback (AR): registration passes, **serialization fails** for every doc (`TargetInvocationException`; `IsDynamicCodeSupported=False`; `DynamicObjectResolver.GetFormatter` throws `TypeInitializationException`), flush fails. | job3 AOT AR run | +| With rooted assembly and generated formatters (variants F: no ref members; E: ref member with the attribute workaround): all 7 registrations OK, all payloads, upsert, flush, reopen and read OK. **Payload lines identical to the JIT build** for both variants. Whole-file bytes differ by design (stamps), so identity is on document payloads. | job3 `F vs FR`, `E vs ER`: IDENTICAL | +| So registration needs the assembly rooted today, because of the reflection below; formatters need the consumer to reference MessagePack for the generator and to avoid `CultRecordRef` dictionary keys. | job3 | + +## 3. Runtime-reflection sites on the registration and serialization path (CultLib main) + +Warnings (job3, `CultLib-source warnings`) cover rows marked W. Others come from the source read. + +Registry enumeration +- `ReflectionExtensions.cs:12-23`: `AppDomain.GetAssemblies()` + `Assembly.GetTypes()` + `GetCustomAttribute()` (W IL2026 at :23). Called by `CultDocumentRegistry.Refresh()` (`CultCache.cs:495`), run from the registry constructor. +- `CultCache.cs:511`, `:691`, `:727`: `GetCustomAttribute` / `` in `GetRequired` and `BuildDescriptor` (`BuildDescriptor` is `CultCache.cs:689`). + +`DiscoverMembers` (`CultCache.cs:1087`) +- `:1090` `GetCustomAttribute(true)`. +- `:1098` `GetFields`, `:1105` `GetProperties`, `:1138` non-public `GetFields`/`GetProperties` (W IL2070 x4). +- `:1235`, `:1299`, `:1545`, `:1549`, `:1599`, `:1601`, `:1609`, `:1676` attribute reads on members (`CultReference`, `CultIndex`, `IgnoreMember`, `Key`, `CultDocument` of the target, `CultName`); `:1086` `GetCustomAttributes` up the override chain. +- `:1268` `OverrideChain` `GetProperty` (W IL2075). +- `ConstructorArity` `:1277`, `:1282` `GetConstructors(Public|NonPublic)` + `IsDefined(SerializationConstructor)` (W IL2070; the AOT-A runtime failure). +- `:1254` member getters built as `field.GetValue` / `PropertyInfo.GetValue` method groups; `:1649-1650` `GetValue` on dictionary entries at upsert, i.e. reflection stays on the hot path after registration, not only at registration. +- `:1306-1330` `IsEnumerableOfRecordRef` / `TryGetDictionaryRefTypes`: `GetInterfaces()` (W IL2070 :1310, :1323), `typeof(KeyValuePair<,>).MakeGenericType` at `:1330` (W IL3050); `:1689-1715` `ResolveReferenceTarget`: `GetInterfaces()`, `GetGenericArguments()` (W IL2070 :1702). +- `CultSchemaTypeNames.cs:13-26`: `GetElementType`, `GetGenericTypeDefinition`, `GetGenericArguments` (type-name rendering; shape-only, not flagged). + +MessagePack-side wiring +- `CultDocumentMessagePackSerialization.cs:22-25`: `CultDocumentResolver.GetFormatter` does `typeof(CultRecordRefFormatter<>).MakeGenericType(...)` + `Activator.CreateInstance` for every `CultRecordRef` (W IL3050 :24). This one is not covered by the generator; the generator's own attempt at `CultRecordRef` is the broken one in section 1. +- `:49` `Assembly.GetCustomAttributes()`; `:69-71` `CreateResolver`: `GetField("Instance")`, `GetProperty("Instance")`, `GetConstructor(Type.EmptyTypes)`, `Activator.CreateInstance(resolverType)` (W IL2067 + IL2070 x3 at :69). +- `CultMessagePackSecurity.cs:27-28`: `typeof(RecordRefComparer<>).MakeGenericType` + `Activator.CreateInstance`, for `CultRecordRef` dictionary keys (W IL3050 :27). +- `CultUnionGuard.cs:20`: `typeof(T).GetCustomAttributes(false)` per union type (not flagged; attribute read on a closed `T`). + +Not on the registration path but in the library: `CultInspectorModel.cs` (`Activator.CreateInstance`, `MakeGenericType`, `GetMembers` at :37, :165-168, :377, :424-431, :540, :597, :757-821), reached only by the inspector. + +## Shape of the fix these probes support (advice, not a decision) + +- The reflective registration needs a generated descriptor per `[CultDocument]` type (members, slots, constructor arity, getters, attributes), the same shape the generator already proves possible for formatters. A generator is not free, since MessagePack's generator is the consumer's, not the library's. +- `CultRecordRef` needs a non-reflective formatter path (`CultDocumentResolver` and `CultMessagePackSecurity` both `MakeGenericType`), and the upstream generator bug with the library's open-generic formatter must be avoided, or the dictionary-key shape will stay uncompilable for generator consumers. +- Consumers need to reference MessagePack (generator) directly; the library's `PrivateAssets="all"` analyzer does not reach them. diff --git a/research/cultmesh-garden-path/prior-art-2026-10-27.md b/research/cultmesh-garden-path/prior-art-2026-10-27.md new file mode 100644 index 00000000..034d5b16 --- /dev/null +++ b/research/cultmesh-garden-path/prior-art-2026-10-27.md @@ -0,0 +1,224 @@ +# Garden-path multiplayer frameworks: developer ergonomics and scaling architecture (prior art) + +Date: 2026-10-27. Read-only Eyes pass. Facts with evidence pointers; no recommendation. + +**Serves** the CultMesh product direction, operator 2026-10-27, verbatim: + +> "the value is in solving the multiplayer scaling problem for indie developers in an ergonomic fashion. Having one authoritative server is fine, up to a point, but there's no real open source playbook on how to ship an MMO, and existing solutions like SpacetimeDB have a huge ergonomics gap. I want CultMesh to feel like magic: as a garden path, you annotate your domain POCOs and simulation code and declare your authority structure (I see no reason why we couldn't support server-authoritative and trusted co-op as a configurable subset of the much harder problem we're aiming at ...), and it'll all just work without the developer needing to understand networking deeply." + +Companion (not repeated here): `F:\Projects\CultLib\research\witness-authoritative-networking\prior-art-2026-10-26.md` (consensus, BFT, P2P anti-cheat). + +## Tags and method + +- VERIFIED: read in a fetched source this pass (URL given). Fetching was through a summarising fetch tool; code snippets are as that tool returned them and some are short fragments, not whole listings. +- SECONDARY: from search-result summaries, forum/HN threads, news, Wikipedia, or vendor-adjacent blogs. +- UNVERIFIED: recalled or inferred; no source read this pass. +- Many official pages returned only tables of contents or 404 (Unreal replication pages, Nakama concept pages, FishNet prediction, NGO NetworkVariable, SpatialOS docs, Veloren ECS chapter). Those gaps are listed per system and not filled from memory unless tagged UNVERIFIED. +- HN comment content is opinion by named commenters, relayed as paraphrase. + +--- + +## 1. SpacetimeDB (Clockwork Labs) + +**What the dev writes.** +- State: tables are annotated structs. Rust `#[spacetimedb::table(accessor = person, public)]` on `pub struct Person`, with `#[primary_key]`, `#[index(btree)]`. C# `[SpacetimeDB.Table(Accessor = "Person", Public = true)] public partial struct Person`, `[SpacetimeDB.PrimaryKey]`, `[SpacetimeDB.Index.BTree]`. VERIFIED (https://spacetimedb.com/docs/tables). +- Logic: reducers are the only way to mutate tables; each is a transaction, rolled back on error. Rust: `pub fn create_user(ctx: &ReducerContext, name: String, email: String) -> Result<(), String>`. TypeScript: `spacetimedb.reducer({ name: t.string(), email: t.string() }, (ctx, {name, email}) => {...})`. VERIFIED (https://spacetimedb.com/docs/functions/reducers). +- Client reads: subscriptions; "Query builders are the recommended default across SDKs, with raw SQL available" e.g. `ctx.subscriptionBuilder().subscribe([tables.user, tables.message])`, `tables.shopItems.where(r => r.requiredLevel.lte(5))`. VERIFIED (https://spacetimedb.com/docs/clients/subscriptions). +- Modules in Rust, C#, TypeScript, C++; app logic is uploaded into the database and clients connect to it directly. VERIFIED (https://github.com/clockworklabs/SpacetimeDB). + +**Infers vs requires.** Infers: wire sync of subscribed rows, rollback on reducer error, persistence (commit log). Requires: restructuring game/simulation code into tables + reducers; the dev writes all netcode above that (prediction, interpolation, lag masking). Reducer rules: no network or filesystem, global/static variables are undefined behaviour, "Never use external random number generators"; context supplies `ctx.sender`, a per-reducer-consistent timestamp, and a deterministic RNG. VERIFIED (reducers and reducer-context docs). + +**Authority.** One model: the database/module is the sole authority; clients call reducers. No trusted-co-op or client-authority mode in what was read. `ctx.sender` identity is the access hook ("Access checks can be asserted at the start of a function", BitCraft article). VERIFIED (https://bitcraftonline.com/news/spacetimedb-and-bitcraft). Peer-authority: not offered (UNVERIFIED beyond what was read). + +**Scaling.** +- "each database is a single-threaded actor"; replication is for availability, "replicated databases achieve the same throughput as unreplicated databases." VERIFIED (https://spacetimedb.com/blog/how-does-spacetime-scale). +- Scale-out is by many databases, developer-chosen: "Databases can be created as children of other databases"; "transactions against different databases can execute concurrently without coordination." Same source. +- Inter-database calls: async IDC and tiered storage slated 2026-10-31 (planned, not shipped as of this pass); sync IDC with two-phase commit, read replicas, and intra-database partitions are "planned." Same source. Table data lives in memory on the leader. Same source. +- BitCraft: "many SpacetimeDB databases which handle a spatial partition in the world"; partitioning is built on top of SpacetimeDB, not within it; each database limited to one machine's memory. VERIFIED (BitCraft article above). Peak concurrency under 5,000 players per an HN commenter (Rohansi) — SECONDARY (https://hn.svelte.dev/item/49563772). +- Interest management: the subscription query is the mechanism; docs warn overlapping/non-indexable filters (`ne`) cost extra. VERIFIED (subscriptions doc). No built-in spatial AOI primitive was seen (UNVERIFIED that none exists). +- Authority handoff between databases: none in shipped form (see IDC above). + +**Determinism.** Only within reducers (RNG, timestamp from context). No client-side replay requirement because there is no client prediction. VERIFIED. + +**Prediction.** None built in. Open issue "Client-side prediction" (4 reactions/comments) and "testing reducers" in the issue list. VERIFIED (https://github.com/clockworklabs/SpacetimeDB/issues sorted by reactions; the numbers might be comment counts, the tool could not tell). HN: commenter gafferongames called planning lag-masking netcode as a TODO "a bad sign" and said adoption means "restructuring the game server and simulation to fit the database's programming model"; manas96 said networked physics needs prediction. SECONDARY (https://hn.svelte.dev/item/43631822). + +**Persistence / schema evolution.** Persistence default (WAL, in-memory working set; 2.0 holds subscription updates until durable by default, opt out `withConfirmedReads(false)`). VERIFIED (GitHub README). Auto-migration allows adding tables/indexes/reducers, appending columns with defaults; `spacetime publish --break-clients` for breaking changes; no general schema-modifying migrations: docs recommend adding a new table and lazily migrating rows from the old one. SECONDARY (search summary of https://spacetimedb.com/docs/databases/automatic-migrations and /incremental-migrations; pages themselves not read). + +**License.** BSL 1.1, converts to AGPLv3 with linking exception "after a few years" (4-year period cited; change date not on the page; HN commenter magicmicah85 says each release converts 5 years after publication). Commenters: BSL limits production to one instance (themgt, _huayra_) — SECONDARY. README license text VERIFIED; instance-limit claim SECONDARY. + +**Known complaints (all SECONDARY, HN/forums):** +- 2.0 thread (https://hn.svelte.dev/item/49378933): reducers run under a lock (cmrdporcupine confirms from source); nemothekid "2015-era React Flux in Rust around a mutex"; Devont: vendor lock-in, feature churn, weak migration tooling, no LTS track; toolslive: user code in the DB raises crash/infinite-loop trust problems; benchmark credibility disputed (slopinthebag, Tostino, sabot90260). +- 2025 thread (https://hn.svelte.dev/item/43631822): beders objected to schema defined by struct annotations; hesdeadjim called $0.28/GB egress brutal (gafferongames: non-starter at 100k CCU); LarsDu88/bob1029 on scaling. +- Scaling thread (https://hn.svelte.dev/item/49563772): horizontal story is "separate databases the developer chooses" (search summary); durability disputed by rockwotj, rebutted by cloutiertyler. +- Issue list top entries: Golang support, QUIC transport, Godot SDK, Dart, Elixir (https://github.com/clockworklabs/SpacetimeDB/issues). VERIFIED. +- Which of these the operator means by "ergonomics gap" is not stated in sources; the gap as documented above is: client prediction/interpolation not provided, game code must be re-shaped into reducers, scale-out partitioning is the developer's own design. + +--- + +## 2. Croquet / Multisynq + +**What the dev writes.** Model (replicated, deterministic) and View (local) classes: +```js +class MyModel extends Multisynq.Model { init() { ... } } +MyModel.register("MyModel"); +class MyView extends Multisynq.View { constructor(model){ super(model); } update(time){ ... } } +``` +Events: `publish(scope, event, data)` / `subscribe(scope, event, handler)`; `this.future(100).step()` schedules in simulation time. VERIFIED (https://multisynq.github.io/multisynq-client/). + +**Infers vs requires.** Infers: state replication (no state on the wire; every client runs the Model), snapshots for late joiners, ordering. Requires: Model self-contained, no functions stored (must be snapshot-serialisable), no async, Views never write the Model; `Math.random()` in Model is seeded identically on all replicas. `init()` runs only on first creation; joiners restore from snapshot, so classes must `register()`. VERIFIED (same page; https://github.com/multisynq/multisynq-client). + +**Authority.** Not authority-by-node: the reflector is a clock/sequencer, "timestamps every event and advances simulation time"; heartbeats 20/s by default. VERIFIED (docs page above). All replicas are peers of equal standing; no designated state owner. Cheating: patents/mailing list say replicas stay in sync only if all run identical code (SECONDARY, USPTO filings and squeak-dev). + +**Scaling.** Single session = single replicated island; reflector hosted on a "DePIN network" server near the first user. VERIFIED (README). Large-world / model-size / snapshot-cost limits: not found (UNVERIFIED). Every client must hold and simulate the entire Model (follows from design; VERIFIED as description). + +**Determinism.** The whole Model; enforced by JavaScript VM conventions, not compiler. Origin Croquet used a bit-identical VM (SECONDARY, patents). + +**Prediction.** None needed: input events are sequenced by the reflector, and clients run ahead of the sequence by simulating only known-ordered messages (no rollback; SECONDARY, squeak-dev post says "without need for rollbacks or consensus"). + +**Persistence / schema.** Snapshot is the persistence unit (VERIFIED as join mechanism). Schema evolution: not found. + +**License.** `multisynq-client` repo Apache 2.0 and **archived read-only**. VERIFIED (https://github.com/multisynq/multisynq-client). Why archived: not stated in the page. + +**Complaints.** None found in this pass beyond the archival status. + +--- + +## 3. Photon Fusion 2 and Photon Quantum + +**Fusion what the dev writes.** Attribute-annotated properties on `NetworkBehaviour`; logic in `FixedUpdateNetwork`; input as `INetworkInput` struct: +```csharp +public class PlayerBehaviour : NetworkBehaviour { + [Networked] public float Health { get; set; } = 100; + public override void FixedUpdateNetwork() { Health -= Runner.DeltaTime; } +} +``` +RPC: `[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]`. VERIFIED (https://doc.photonengine.com/fusion/current/manual/data-transfer/networked-properties and /fusion-intro). Rules: leave `[Networked]` as auto-property, change state in `FixedUpdateNetwork`, only supported blittable-ish types, collections need `[Capacity]`. VERIFIED. + +**Fusion authority.** Same API, three topologies ("Modes"): Dedicated Server (server has exclusive State Authority), Client Host (host holds it, "price of the state authority's trustworthiness"), Shared Authority (each client has State Authority over what it spawns; the Photon Room "controls changes of state authority and holds a complete copy"). VERIFIED (https://doc.photonengine.com/fusion/current/manual/network-topologies). The page says Server and Host share one programming approach; Shared has its own tutorial series. VERIFIED. Interest-management differences per mode exist: Behaviour Interest `ReplicateTo()` not available in Shared; one AOI per player in Shared. VERIFIED (https://doc.photonengine.com/fusion/current/manual/interest-management). So: one API, behaviour diverges by mode. + +**Fusion scaling/prediction.** Client-side prediction and lag compensation "out of the box"; configurable area-of-interest ("Scheduling and Interest Management" feature flag; `Runner.AddPlayerAreaOfInterest()`; per-object `ObjectInterest` Area/Global/Explicit). VERIFIED. Scale-out past one room/server: not described in pages read (UNVERIFIED). Handoff of state authority within Shared mode is room-controlled (VERIFIED above). + +**Quantum what the dev writes.** Data in `.qtn` DSL generated into C#: `component Resources { Int32 Mana; FP Health; }`; stateless systems `Update(Frame frame)` with `frame.Filter<...>()`. VERIFIED (https://doc.photonengine.com/quantum/current/quantum-intro). +- Determinism tax: "Be stateless: no mutable fields should be declared in systems"; "Quantum only guarantees determinism if all (mutable) game state data is fully contained in the Frame instance"; events must not drive gameplay; use provided fixed-point math, RNG, physics, pathfinding; no heap allocation at runtime. VERIFIED (https://doc.photonengine.com/quantum/current/manual/quantum-ecs/systems and intro). Explicit "no floats" sentence not found in fetched text. +- Model: input-only exchange, local prediction, rollback by re-simulation, game-agnostic server component syncs clocks; an authoritative "referee" simulation can be added. VERIFIED (intro). Anti-cheat mechanism: not read. + +**License/cost.** Both "free for development", paid beyond a free tier; terms not read. A forum post reports differing free-CCU numbers between web and Asset Store (20 vs 100) — SECONDARY, undated. Source availability of Fusion/Quantum: UNVERIFIED (not stated in pages read). + +**Complaints.** Not researched beyond the above (UNVERIFIED). + +--- + +## 4. Unity Netcode for GameObjects (NGO) and Netcode for Entities (N4E) + +**NGO.** What dev writes: `NetworkBehaviour`, `NetworkVariable`, RPCs (code not retrieved; the NGO pages 301-redirected and returned landing text). The following is VERIFIED from https://mp-docs.dl.it.unity3d.com/netcode/current/basics/ownership/: default is client-server where the server holds authority (`IsServer`); an optional **distributed authority** topology where "authority over NetworkObjects isn't bound to a single server, but distributed across clients", "there's no concept of an authoritative server" and `HasAuthority` is the check; state should live in NetworkVariables with owner-write/everyone-read; ownership flags `None`, `Distributable` (auto-redistributed on join/leave), `Transferable`, `RequestRequired`, `SessionOwner`; `SetOwnershipLock`. So: one component API, two topologies chosen at session creation, where code written for one must use `IsServer` vs `HasAuthority` (VERIFIED statement that `IsServer` works only in client-server). A Unity issue-tracker report (SECONDARY) says NetworkVariable values can be lost when ownership is transferred immediately after a write in distributed authority. License: repo points to LICENSE.md; type not read (UNVERIFIED; commonly MIT). Distributed-authority hosting service pricing: not found. + +**N4E.** "a server authoritive with client prediction framework", built on ECS. VERIFIED (landing page). Ghosts: server-simulated entities; `[GhostField(Quantization=1000)] public float MyFloatField;` only marked fields replicate; Supported/Default Ghost Mode (Interpolated, Predicted, Owner predicted); Importance, Static/Dynamic optimisation. VERIFIED (https://docs.unity3d.com/Packages/com.unity.netcode@1.3/manual/ghost-snapshots.html). Interest: **Relevancy** (`GhostRelevancyMode` Disabled/SetIsRelevant/SetIsIrrelevant; "Use Relevancy to avoid replicating entities that the player can neither see, nor interact with") plus distance-based **importance scaling** per chunk; snapshot size caps `NetworkStreamSnapshotTargetSize`, `MaxSendEntities`, etc. VERIFIED (.../optimizations.html). Prediction: the dev opts per ghost via mode and writes predicted systems in ECS (details of the prediction loop not read; UNVERIFIED). Authority handoff between servers: none seen. + +--- + +## 5. Mirror and FishNet + +**Mirror.** Dev writes `NetworkBehaviour` with `SyncVar`, `[Command]`, `[ClientRpc]`, `[TargetRpc]`; snippet: an `Enemy` with `[SyncVar] int health = 100`, `CmdShoot` reducing it on the server. VERIFIED (https://mirror-networking.gitbook.io/docs/manual/guides/synchronization/syncvars; listing summarised by tool). Authority: server-authoritative by default; client authority via flags (UNVERIFIED on this page). Interest management components added to the NetworkManager: Spatial Hashing, Hex Spatial Hashing, Distance, Scene, Scene Distance, Team, Match, Custom. VERIFIED (https://mirror-networking.gitbook.io/docs/manual/interest-management). Scaling claims (vendor, self-reported, no methodology): Inferna >1,000 CCU across multiple instances, Another Dungeon 5,000 CCU, benchmarks 400–800 CCU. VERIFIED as claims (https://github.com/MirrorNetworking/Mirror). Prediction listed "Researching", Lag compensation/Cheat detection Beta, Bitpacking researching. VERIFIED (same page). License MIT. VERIFIED. Cross-server handoff: none (multiple instances, no migration documented). + +**FishNet.** Dev writes `[ServerRpc]` / `[ObserversRpc]` (benchmark snippet) plus SyncTypes; server-authoritative by design with host mode; its own movement tutorial uses client-authoritative `NetworkTransform` and warns of speed hacks/teleports. VERIFIED (https://fish-networking.gitbook.io/docs/llms-full.txt, first 100k chars of 427k read). Built-in client-side prediction and rigidbody prediction; AOI with multiple conditions; paid Pro tier lists lag compensation, LOD, code stripping, extrapolation, Yak. VERIFIED. "no limit on concurrent users" for free use. VERIFIED. License type: not shown (UNVERIFIED). Prediction V2 details: not in fetched portion. + +--- + +## 6. Unreal replication and Iris + +**What the dev writes.** `UPROPERTY(Replicated)`, `GetLifetimeReplicatedProps`, RPC specifiers Server/Client/NetMulticast, `HasAuthority`, `NetUpdateFrequency`, dormancy (the replication-basics page returned only a table of contents; names VERIFIED as headings). Description: "The server ... holds the one, true, authoritative game state"; replicated, RepNotify properties; actors need `bReplicates`; creation, destruction and basic movement replicate automatically, everything else must be designated. VERIFIED (https://dev.epicgames.com/documentation/en-us/unreal-engine/networking-overview-for-unreal-engine). Authority model: server only; listen-server host is the same model (UNVERIFIED on page). + +**Interest.** Generic system: per-connection **relevancy**, dormancy, priority. Iris: "filtering controls the connections an actor replicates to" plus prioritisation, quantised full copy of replicated state, opt-in, labelled **Experimental**, toggle `-UseIrisReplication=1`; claims larger worlds and higher player counts, built from Fortnite Battle Royale experience (100 players/instance). VERIFIED as claims (https://dev.epicgames.com/documentation/en-us/unreal-engine/introduction-to-iris-in-unreal-engine). Opting in requires game code to use new engine APIs; existing properties/RPCs compatible with minor modifications. VERIFIED. + +**Prediction.** Overview page does not describe it beyond pointing to Character Movement; Network Prediction plugin page returned only headings (UNVERIFIED status). **Scale-out:** no multi-server world in engine; Pax Dei (below) builds zone servers on Kubernetes around it (SECONDARY, vendor FAQ). Licensing: UNVERIFIED this pass (source-available EULA with royalty, from memory). + +--- + +## 7. Colyseus + +Dev writes: schema classes and rooms. +```ts +import { Schema, type } from "@colyseus/schema"; +export class MyState extends Schema { @type("string") currentTurn: string; } +``` +Room has `maxClients`, `state = new MyState()`, `onCreate/onJoin/onLeave/onDispose`, a `messages` handler object. VERIFIED (https://docs.colyseus.io/state/schema and https://docs.colyseus.io/). Sync: assign state to room, changes sent as tick patches, full state on join; a Schema holds at most 63 synchronisable fields (nest to exceed). VERIFIED. Authority: server-authoritative rooms (page says implied, not stated). Scale: "Rooms are equally distributed across the available processes", each room lives in one process, Redis presence/driver required for multi-process, load balancer entry point; per-process max players left to FAQ. VERIFIED (https://docs.colyseus.io/scalability). No cross-room shared state documented (page silent). No prediction, no spatial interest management (room = interest scope; UNVERIFIED that none exists). MIT; "10 to 10,000+ CCU" is a vendor claim; roadmap to 1.0 linked; sustainability via Colyseus Cloud. VERIFIED (https://github.com/colyseus/colyseus). + +--- + +## 8. Nakama (Heroic Labs) + +Dev writes server modules in Go, Lua or TypeScript/JS ("recommends the JavaScript VM") and an authoritative **match handler** with seven functions: Match Init, Join Attempt, Join, Leave, Loop, Terminate, Signal; the loop runs at a developer-set tick rate (e.g. 10/s = 100 ms budget; if loops fall behind the server catches up, then ends the match). VERIFIED (https://heroiclabs.com/docs/nakama/concepts/multiplayer/authoritative/). Dev owns match state and all sync/prediction in code; framework supplies accounts, storage, matchmaking, chat, leaderboards. VERIFIED (README). Scale: "A single Nakama node hosts each match and its state"; clustering "Enterprise only" replicates presences, balances matches, migration needs no code change. VERIFIED (same page). Apache-2.0, Go, requires CockroachDB/Postgres. VERIFIED (https://github.com/heroiclabs/nakama). Interest management, prediction: none documented in what was read. + +--- + +## 9. Roblox + +Dev writes Luau scripts against a replicated DataModel; "The Roblox server is the ultimate authority for maintaining the game's state"; replication covers the data model, physics, chat; client-to-server, server-to-client, server-to-all flows. VERIFIED (https://create.roblox.com/docs/projects/client-server). Interest management: `Workspace.StreamingEnabled` (default for new places): `StreamingMinRadius` 64, `StreamingTargetRadius` 1024, models Nonatomic/Atomic/Persistent/PersistentPerPlayer, replication focus defaults to the character; dev must make client scripts tolerate parts streaming in/out (`WaitForChild`, `PersistentLoaded`); `StreamingIntegrityMode` can pause gameplay in unloaded areas. VERIFIED (https://create.roblox.com/docs/workspace/streaming). Network ownership: unanchored parts auto-assigned to nearby clients; `SetNetworkOwner`; "Roblox cannot verify physics calculations when a client has ownership"; server must validate. VERIFIED (https://create.roblox.com/docs/physics/network-ownership). Prediction: not read. Scale-out: each place instance is one server (UNVERIFIED this pass; not in pages read). Not open source (UNVERIFIED). + +--- + +## 10. Improbable SpatialOS + +- Concepts (workers that each see and update a subset of the world, entities of components with schema-defined data, authority over components moved between workers, platform handles load balancing and persistence; locality of reference between virtual and datacenter proximity). SECONDARY (gamedev.net thread https://gamedev.net/forums/topic/689484-spatialos-single-shard-mmo, 80.lv, Cambridge talk abstract, a patent on handover). The official docs were not reachable; interest/checkout/zoning terms not confirmed. +- Timeline: 2017 open beta, $502m Series B, Worlds Adrift and Lazarus cancelled 2019, Unity ToS dispute 2019 (Improbable said managed services breached Unity's new terms; $25m Epic/Improbable fund), GDK for Unity repo archived 2022-01-18 with existing users pointed to a migration to "SpatialOS running on zeuz". VERIFIED (https://github.com/spatialos/gdk-for-unity) for the archive; timeline VERIFIED from https://en.wikipedia.org/wiki/Improbable_(company) (Wikipedia, so effectively SECONDARY). +- Why it failed commercially: no official postmortem found. Candidate factors in sources, all SECONDARY: the Unity ToS block (Variety, HN https://news.ycombinator.com/item?id=18874400); cloud lock-in to Google and unpublished per-use pricing, a developer (Soulbound Studios) moving to its own engine (HN, GDC coverage); forum judgments "almost unusable" and synchronous cross-entity waits being a "deal killer" for real-time (gamedev.net, informal). Improbable later pivoted to studios, defence, and Web3 (Wikipedia). +- Handoff/determinism/prediction: not documented in reachable sources. + +--- + +## 11. Hadean / MetaGravity + +- Hadean Aether Engine: "world's first distributed simulation engine", each core manages a chunk, neighbouring chunks communicate, runs on Azure; used for EVE: Aether Wars at GDC 2019 (3,852 human players, 14,274 total ships incl. AI clients, 10,412 peak combatants, 88,988 ships destroyed in one hour); partitioning details not given. VERIFIED as claims (https://fenris.com/news/2019/eve-aether-wars-successfully-achieves-groundbreaking-14-000-ship-battle); engine description SECONDARY (search summary of PR). +- MetaGravity: London, target 1M+ concurrent users; CEO Rashid Mansoor (earlier Hadean founder) contrasts "spatial partitioning" with its own "causal partitioning" algorithm. SECONDARY (job-board page and press summaries). Technical documentation: not found. Current status of both: UNVERIFIED. + +--- + +## 12. EVE Online and Dual Universe + +**EVE Online.** Tranquility "is split into a series of nodes"; solar systems are redistributed across nodes at daily downtime; busy systems (Jita) get dedicated capacity; spare nodes held in reserve for planned battles; a node overloaded by one system slows other systems on it. VERIFIED (https://www.eveonline.com/news/view/fleet-fight-notification-tool). Time dilation: per-node, slows "almost all in-space actions", floor 10% speed, timers like skill training unaffected. SECONDARY (EVE University wiki https://wiki.eveuniversity.org/Time_Dilation). The CCP page does not say "single shard"; the Python/SQL stack and node/proxy split were not confirmed (UNVERIFIED). Handoff: a solar system is the indivisible unit; moving between nodes happens only at downtime (VERIFIED), and a ship jumping between systems moves between their nodes (UNVERIFIED mechanics). + +**Dual Universe.** Server tech called **CSSC** (Continuous Single-Shard Cluster), not "CSSA"; "all players in one shared world, no instancing or zoning"; Lua scripting by players in-world; released 2022-09-27; delisted from Steam 2025-07-25, servers shut down 2025-08-27. VERIFIED (https://en.wikipedia.org/wiki/Dual_Universe) / SECONDARY. No internal architecture write-up found; a French report attributes closure to economy/content problems, not tech (SECONDARY). MyDU player-hosted server exists (SECONDARY). + +--- + +## 13. Open-source MMO server playbooks + +| Project | What it is | Evidence | Coherent playbook? | +|---|---|---|---| +| Astron | Distributed object server suite from Disney's OTP (2001–2013): DistributedObjects with fields, shared "DC file" protocol description with keywords (`ram`, `required`, `db`, `broadcast`, `clsend`, `ownsend`...), roles Message Director, Client Agent, State Server, Database Server, Database-State Server; hierarchy of zones with client "interest" requests. Modified BSD. "Development on Astron has been discontinued." VERIFIED (https://github.com/Astron/Astron). | Declarative state + routing keywords + interest by zone + persistence flag is the closest ancestor to the "annotate your POCOs" idea in this survey. | A coherent architecture, but discontinued and not a shipped-game playbook documented end to end. | +| AzerothCore / TrinityCore | WoW 3.3.5a emulators: separate auth server, world server, three MySQL databases (auth, world, characters), DB worker threads, modules. AGPLv3 / GPLv2 mix. SECONDARY (search summaries, https://trinitycore.atlassian.net, localtonet blog). | A forum thread on 15k+ CCU server topology had only speculation. Map-thread design not found. | Architecture of one monolithic realm per world server; reproduces a closed game, not a reusable playbook. | +| Ryzom Core | Open-source MMORPG framework with pre-built "shard framework meant to scale", since 2001, powers the commercial Ryzom. SECONDARY (search summary; wiki page returned empty). | | Possibly; not read. | +| Folia (Paper) | Minecraft server fork: nearby loaded chunks grouped into independent regions with own 20 TPS tick on a thread pool; no cross-region data access, cross-region work via RegionScheduler/EntityScheduler; plugin API broken (`teleportAsync`). VERIFIED (https://github.com/PaperMC/Folia). | Concrete evidence on the cost of regionised multithreading to existing user code. | A single-process technique, not a playbook. | +| Veloren | Open-source Rust voxel RPG; crates `server`, `client`, `common`, `network`. VERIFIED crate list (https://book.veloren.net/contributors/developers/codebase-structure.html); authority and scaling not covered by the page. | | Not shown. | +| Pax Dei (commercial, FAQ only) | Shards as independent world copies in one datacenter; zones simulated by dedicated Unreal servers on Kubernetes; zone transition moves connection to the adjacent zone server; ~150 players per zone server; overflow split into instances; no cross-boundary visibility. VERIFIED as claims (https://playpaxdei.com/en-us/faq/tech; the FAQ states conflicting shard-capacity figures). | Real zone-handoff design disclosed. | Not open source. | +| Others named in search results | ScalaMo (research), Free-MMORPG Unity framework (community). Project Darkstar/RedDwarf, Atavism, Open Match, Agones: searched, no results read. | | UNVERIFIED. | + +No source found that presents an end-to-end open-source recipe for shipping an MMO (state model, authority, interest, handoff, persistence, deployment). Astron is the closest architectural description; none was found that is current and maintained. + +--- + +## 14. Design-question matrix + +"-" = not found in sources read (not a claim of absence). + +| Question | SpacetimeDB | Multisynq | Fusion 2 / Quantum | NGO / N4E | Mirror / FishNet | Unreal / Iris | Colyseus | Nakama | Roblox | SpatialOS | EVE / Astron | +|---|---|---|---|---|---|---|---|---|---|---|---| +| Declaring state | Annotated struct = table (`#[table]`, `[Table]`) | Model class fields, `register()` | `[Networked]` property / `.qtn` DSL component | NetworkVariable / `[GhostField]` | `[SyncVar]` / SyncTypes | `UPROPERTY(Replicated)` | `@type` Schema (max 63 fields) | Dev-defined match state (code) | Instances in DataModel | Schema-defined components (SECONDARY) | Astron: DC file dclass fields with keywords; EVE: - | +| Declaring authority | Implicit: DB module; reducer checks `ctx.sender` | None: all replicas equal, reflector orders | Mode: Server / Host / Shared (State Authority, Input Authority) | NGO: client-server or distributed authority + ownership flags; N4E: server | Server default; client-auth flags/NetworkTransform | Server; `HasAuthority` | Room server | Match handler on server | Server; network ownership of physics | Workers hold authority per component (SECONDARY) | Astron: field keywords `clsend/ownsend/broadcast`; EVE: node owns system | +| Prediction / rollback opt-in | None; open issue | None needed (sequenced input) | Fusion: built in; Quantum: mandatory rollback | N4E: per-ghost mode + predicted systems; NGO: - | FishNet: built in; Mirror: researching | Character movement; Network Prediction plugin status - | None | None | - | - | - | +| Interest management | Subscription queries | None (all state everywhere) | AOI (`ObjectInterest`, `AddPlayerAreaOfInterest`), limited in Shared | N4E: relevancy + importance; NGO: - | Mirror: spatial hash/distance/scene/team; FishNet: observer conditions | Relevancy, Iris filtering | Room = scope | Match presences | StreamingEnabled radii | Worker checkout (not confirmed) | Astron: zone interest | +| Authority handoff between shards | Planned IDC (2026-10-31, async); sync IDC planned | None | Shared-mode room reassigns state authority | NGO distributed authority ownership transfer; N4E: - | - | - (Pax Dei: zone transition on connection) | Rooms don't hand off | Match pinned to one node | - | Authority handover (patent, SECONDARY) | EVE: system fixed to node, rebalanced at downtime | +| Persistence | Built in WAL, in-memory | Snapshot | - | - | - | - | - | Storage + DB backend | Roblox services (-) | Platform persistence (SECONDARY) | Astron: `db` keyword + DB server | +| Schema evolution | Auto-migrate subset, `--break-clients`, new-table pattern | - | - | - | - | - | - | - | - | - | Astron: DC file id order = 16-bit ids (VERIFIED) | +| Determinism tax on dev code | Reducers: no I/O, context RNG/time | Whole Model: no I/O/functions/async | Fusion: tick-based code; Quantum: stateless systems, fixed-point, all state in Frame | N4E: prediction systems rerun | Low | Low | Low | Low | Low | - | - | +| Past one server | Many DBs, developer partitions | One session | Photon Room / server | Not described | Multiple instances | One instance (Pax Dei adds zones externally) | Processes + Redis, one room per process | Enterprise cluster | One server per place (UNVERIFIED) | Workers across machines | EVE nodes; Hadean chunks per core | +| One model or products | One model | One model | Fusion: one API, 3 modes; Quantum separate product | NGO: two topologies in one package; N4E separate stack | Separate libs | Single model | Single | Single | Single | Single | - | + +--- + +## 15. Facts bearing on the "configurable subset" claim (server-auth + trusted co-op as subsets) + +- Photon Fusion is a shipped example of server, client-host and shared-authority modes under one API, with documented divergences (Behaviour Interest absent in Shared; one AOI per player; the Shared tutorial series is separate). VERIFIED. +- NGO ships client-server and distributed authority in one package; the check you write changes (`IsServer` vs `HasAuthority`), and variable write permissions are configured per mode. VERIFIED. +- Multisynq/Croquet has no authority concept; Quantum's authority is input-driven determinism with an optional referee. VERIFIED. +- SpacetimeDB and Colyseus offer only the single-authority model. VERIFIED. diff --git a/research/cultmesh-garden-path/stage1-forks-prior-art-2026-10-27.md b/research/cultmesh-garden-path/stage1-forks-prior-art-2026-10-27.md new file mode 100644 index 00000000..93ad7831 --- /dev/null +++ b/research/cultmesh-garden-path/stage1-forks-prior-art-2026-10-27.md @@ -0,0 +1,127 @@ +# Stage 1 forks: prior art for six open questions (2026-10-27) + +Campaign `cultmesh-garden-path`. Fills the gaps named by six questions: `browser-transport`, `tick-model`, `tick-state-commit`, `room-durability`, `player-identity`, `browser-client-store`. Facts with evidence pointers; no recommendation. Already kept and not repeated: `prior-art-2026-10-27.md`, `browser-sim-runtime-2026-10-27.md`, `witness-authoritative-networking/prior-art-2026-10-26.md`. + +Tags: VERIFIED = read on the primary page in this pass. SECONDARY = third-party, forum, or search-engine summary. UNVERIFIED = looked for, not found, or not readable. + +Method note: WebFetch summarises pages with a small model, so quoted numbers are as that tool returned them. Valve's wiki (403), Unreal docs (403), GDC Vault (maintenance redirect) and archive.org were unreachable; those claims are SECONDARY at best. + +--- + +## 1. `browser-transport` + +### 1.1 WebTransport browser support (today is 2026-10-27) + +| Browser | WebTransport | `datagrams` | Notes | +|---|---|---|---| +| Chrome desktop | 97 | 97 | VERIFIED (MDN browser-compat-data `api/WebTransport.json`) | +| Edge | 98 | follows Chromium | VERIFIED (caniuse.com/webtransport) | +| Firefox desktop | 114 | 114 | VERIFIED (BCD) | +| Safari macOS | 26.4 | 26.4 | VERIFIED (BCD; caniuse; MDN says "Baseline 2026, Newly available", since March 2026) | +| Safari iOS | 26.4 | 26.4 | VERIFIED (BCD, caniuse) | +| Chrome Android | 156 | not separately listed | VERIFIED (caniuse only; BCD lists no Android split) | +| Firefox Android | 158 | not separately listed | VERIFIED (caniuse) | +| Samsung Internet | 18.0 | | VERIFIED (caniuse) | + +- caniuse reports about 91.6 % global usage. VERIFIED (caniuse.com/webtransport). +- Sub-features that matter for a game (BCD, VERIFIED): `serverCertificateHashes` Chrome 100 / Firefox 125 / Safari 26.4. `congestionControl` Chrome none / Firefox 114 / Safari 26.4. `sendOrder` Chrome none / Firefox 119 / Safari 26.4. `reliability` Chrome 157 / Firefox 114 / Safari 26.4. `supportsReliableOnly` Chrome 157 / Firefox 158 / Safari 26.4. `createUnidirectionalStream` Firefox "partial". +- Safari 26.4's WebKit feature post does not mention WebTransport in the page read. VERIFIED (absence on webkit.org/blog/17333). Safari 26.4 shipping is confirmed by BCD and caniuse, so treat the blog omission as an incomplete read, not a contradiction. +- One third-party page claims Safari's wire implementation sits in Apple's Network.framework (so the floor is OS 26.4, not only Safari), and that WKWebView support is unconfirmed; tested on an iOS 27 beta. SECONDARY, provisional (echo.semantic-ui.com/compat via search). +- Runtime feature test: `typeof WebTransport === "undefined"`; `WebTransport.supportsReliableOnly`; instance `reliability` says whether a given session has unreliable datagrams. VERIFIED (MDN WebTransport page). +- Self-signed certificates: Kestrel docs say browsers do not allow self-signed certificates on HTTP/3, including the dev certificate. VERIFIED (learn.microsoft.com Kestrel HTTP/3 page). `serverCertificateHashes` (above) is the browser-side escape, with a short certificate validity limit (the Kestrel experimental doc's test certificate uses 14 days; the W3C rule itself was not read, UNVERIFIED). + +### 1.2 Server side on .NET + +- HTTP/3 in Kestrel: fully supported since .NET 7; not enabled by default (`HttpProtocols.Http1AndHttp2AndHttp3` plus HTTPS); depends on MsQuic; falls back silently if MsQuic is missing. The same text applies to the .NET 8, 9, 10 monikers; a .NET 11 moniker exists. VERIFIED (learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/http3, page updated 2026-10-08). The page does not mention WebTransport at all. +- System.Net.Quic APIs are stable from .NET 9. SECONDARY (search summary of learn.microsoft.com QUIC overview). Windows ships msquic.dll with the runtime; Linux needs `libmsquic` installed. SECONDARY (same). +- Kestrel WebTransport: experimental since .NET 7 RC1 (2022-09). Needs `EnablePreviewFeatures` plus the runtime option `Microsoft.AspNetCore.Server.Kestrel.Experimental.WebTransportAndH3Datagrams`. Implements "most of the WebTransport draft-02 specification, except for datagrams"; datagrams planned later; server cannot initiate sessions. VERIFIED (github.com/dotnet/aspnetcore docs/WebTransport.md). Browsers ship the later WebTransport drafts (draft number the browsers speak was not looked up, UNVERIFIED), so draft-02 interop with current Chrome/Firefox/Safari is not shown by any source read. +- Tracking issue dotnet/aspnetcore#42788 is open, milestone Backlog (moved from ".NET 8 Planning" 2024-02-14), no assignee; datagrams are a separate issue #42784. VERIFIED (issue page). dotnet/runtime#43641 "Add support for WebTransport" is open, milestone Future, `api-suggestion`. VERIFIED (issue page). No .NET 10 or 11 announcement of WebTransport graduating was found. UNVERIFIED that none exists. +- System.Net.Quic datagram surface: a third-party package note says System.Net.Quic does not surface msquic datagrams. SECONDARY (nuget package page via search). No dotnet/runtime issue for it was found. UNVERIFIED. +- Maintained libraries: the only C# WebTransport package found is `Demiurgos.WebTransport.Client` 0.2.1 (2026-06-09), client only, Windows x64 preview, native MsQuic backend, has datagram send/receive; Linux, macOS, WebGL, mobile "not yet". VERIFIED (nuget.org page). No C# WebTransport server library usable with browsers was found. UNVERIFIED that none exists. +- Non-.NET reference point: Colyseus ships WebTransport as `@colyseus/h3-transport` on `@fails-components/webtransport` (Node), marked experimental, "hasn't been battle tested", "not widely available yet". VERIFIED (docs.colyseus.io/server/transport/webtransport). + +### 1.3 What shipped browser games use + +- Colyseus: WebTransport is its only transport with an unreliable channel (QUIC datagrams); `.unreliable()` schema fields and `room.input({mode:"unreliable"})` (redundant recent inputs) use it; on WebSocket those fields stop updating after the initial sync, and unreliable input falls back to the reliable channel (redundancy wasted). Client WebTransport is JS/TS SDK only. VERIFIED (same Colyseus page). +- Rune (mobile/web multiplayer game platform) engineering post compares WebSockets and WebRTC data channels, says TCP head-of-line blocking needs UDP, "in the current web browser, that means WebRTC", and that WebTransport "isn't ready yet". Its own choice is not stated; no loss measurements. VERIFIED (developers.rune.ai/blog/webrtc-vs-websockets-for-multiplayer-games). The post is undated in the fetch. +- Safari 26.0 added WebSocket over HTTP/2 and HTTP/3. VERIFIED (webkit.org features-in-safari-26-4 page, mentioned in its HTTP section). +- No source found documenting which transport named .io games (agar.io, slither.io, krunker) use, nor any measured WebSocket stall under loss for them. UNVERIFIED. General guides recommend WebSocket for server-authoritative games needing wide support, WebRTC data channels for unreliable traffic (signalling and TURN cost), WebTransport as narrower-support newcomer. SECONDARY (search summaries of ably.com, openreplay.com, rune). + +--- + +## 2. `tick-model` + +Question's own framing: every framework already read uses one fixed simulation tick. Below: what varies per system, entity, or client. + +- Unreal: `NetUpdateFrequency` is a per-actor maximum replication rate in Hz; docs give typical values 10 for important player-controlled actors, 5 for slower AI, 2 for background actors. `MinNetUpdateFrequency` is the floor when adaptive update is on (`net.UseAdaptiveNetUpdateFrequency=1`, off by default in the 4.27 docs; a forum thread says it became default-on at 4.16 then regressed latency for some). Ramp-down happens when no replicated property changed. SECONDARY (search summary of docs.unrealengine.com 4.27 Actor Properties and dev.epicgames.com; the pages themselves returned 403 or off-topic). The server simulation still ticks at one rate; only the replication cadence is per actor. Server tick rate (`NetServerMaxTickRate`) relation: UNVERIFIED. +- Netcode for Entities (Unity): snapshots are sent at `NetworkTickRate`, which "can differ from `SimulationTickRate`"; when lower, connections are split into round-robin subsets sent on successive ticks. `MaxSendRate` is per ghost prefab, "only denotes the maximum possible replication frequency, and cannot be enforced in all cases", capped by the tick rate (100 Hz cap on a 60 Hz default stays 60; 45 Hz on 30 Hz rounds down to 30). Importance (per chunk, grows with `ticksSinceLastSent`) picks what fits in bandwidth; `GhostImportance.BatchScaleImportanceFunction` can scale per chunk and per connection. VERIFIED (docs.unity3d.com/Packages/com.unity.netcode@1.8/manual/ghost-snapshots, fetched; plus search summaries of the 1.4 to 1.9 API pages). Fixed: one simulation tick. Varies: per-prefab and per-connection send cadence. +- Photon Fusion 2: tick rate and send rate were locked to 32 and 16 in Fusion 2.0; from 2.1 tick rate is selectable 8 to 256 (Shared mode capped at 32); send rate is an integer interval 1 to 8 ("1 sends every tick, 2 every second tick") and only divisors of the tick rate are offered; server and client send rates are separate settings. VERIFIED (doc.photonengine.com/fusion/current/manual/network-project-config). Replication features: scheduling (default) raises a culled object's priority for the next outgoing tick; optional Area of Interest. VERIFIED (same page). One simulation tick; send is a divisor of it. This is the "fixed-with-divisors" shape for sending, applied to snapshots rather than to systems. +- Valve Source: server simulates at the tick interval (commonly 66 or 100 Hz in community servers; default 66 for older titles is not verified); client sends usercmds at `cl_cmdrate` (a wiki line says usually 30) and requests snapshots at `cl_updaterate` (default 20); the server "will never send more updates than simulated ticks" nor above the client request or `sv_maxupdaterate`. SECONDARY (search summaries of developer.valvesoftware.com Source Multiplayer Networking; page returned 403; defaults differ between community sources). Fixed: server tick. Varies: per-client command and update rates. +- Overwatch GDC 2017 (Timothy Ford): the talk page confirms topic only. A secondary article says the simulation runs on fixed 16 ms command frames, 7 ms in tournament mode; a forum summary says the client runs ahead of the server by about half RTT plus one command frame. SECONDARY (edgegap.com blog; gamedev.net thread; GDC Vault page abstract only). Talk slides not read. UNVERIFIED for any per-system rate. +- Nakama and Colyseus (already kept): one developer-set tick. +- Per-system independent timelines with prediction and reconciliation: none found in this pass. UNVERIFIED that none exists. In every case read above, what is allowed to vary is the send/replication cadence per object, prefab, or client, derived from one simulation clock. + +--- + +## 3. `tick-state-commit` + +- SpacetimeDB commit path: each database is a single-threaded actor; database lock held only for the transaction at the head of the pipeline; table data held in memory on the leader. VERIFIED (spacetimedb.com/blog/how-does-spacetime-scale). It "never exposes any data to users that has not been persisted to disk, unless you specifically enable it", holding responses in a buffer until persisted ("a form of early lock release") and pipelining writes to disk. VERIFIED (spacetimedb.com/blog/benchmarking). Per-transaction fsync vs group commit: not stated in either page. UNVERIFIED. +- SpacetimeDB measured throughput (vendor, single node, Intel 14900K): 303,920 ± 4,712 TPS (TypeScript harness, power-law contention) and 265,541 ± 940 (Rust harness); 279,025 uncontended; latency p50 about 7.4 to 8.1 ms, p99 about 11.7 to 12.9 ms. Transactions are balance transfers sent as individual requests with 40 in flight per client; 5 min runs with 30 s warmup. VERIFIED (spacetimedb.com/blog/benchmarking). The vendor says an earlier March 2026 claim of over 100,000 TPS was misleading because of database bugs. SECONDARY (search summary of the same blog's corrections). Competing backends in the same post: Node plus SQLite about 3,100 to 3,200 TPS; Bun plus Postgres 10,730 (uncontended) and 2,773 (contended). VERIFIED (same). These are end-to-end request throughput, not per-tick batch commit cost. +- Embedded document store or ECS per-tick commit cost: no measurement found for per-tick commits in an embedded document store or an ECS persistence layer. UNVERIFIED. Nearest numbers: a SQLite forum post on a WAL2 branch, NVMe, 6 inserts per transaction: 26,425 TPS with synchronous=OFF, about 13,000 with NORMAL, 9,082 with FULL. SECONDARY (sqlite.org forum via search; non-stock build). SQLite docs: WAL with synchronous=NORMAL is power-safe but a committed transaction can be lost on power failure. SECONDARY (search summary of sqlite.org). +- How game servers persist (never or periodic, none per tick in the sources read): + - Minecraft Bukkit-family servers autosave every 6000 ticks (5 minutes); a hosting guide warns shorter intervals strain the server. SECONDARY (shockbyte.com guide; the same guide says vanilla has no automatic save, conflicting with another page). + - Factorio: optional non-blocking autosave is Linux-only and works by `fork()`, the child writes the save while the parent keeps simulating. SECONDARY (forums.factorio.com threads). + - Colyseus and Nakama: state is in memory; persistence is developer code at dispose or terminate (see section 4). + - Photon Quantum: all mutable state lives in one Frame per tick (already kept); persistence not described. + +--- + +## 4. `room-durability` + +- Colyseus production: rooms are in-memory; `onDispose()` "suggested to persist data such as player data after a match"; graceful shutdown runs `onBeforeShutdown` (default disconnects all clients, disposes rooms), clients get `CloseCode.SERVER_SHUTDOWN`; no production room restore described. VERIFIED (docs.colyseus.io/room/lifecycle). The only restore hooks, `onCacheRoom`/`onRestoreRoom`, apply only in `devMode`, which caches room state and clients' sessionId seat reservations locally and recreates rooms after a restart. The docs say "Do not use devMode in a production environment" and that it is not optimised for many rooms. VERIFIED (docs.colyseus.io/server/devmode). Crash without planned restart: not covered. UNVERIFIED. Forum answers: rooms are "meant to be ephemeral"; `LocalPresence` persists nothing; cache state in `onDispose` and reload in `onCreate`; a two-node RedisPresence user lost all state when one node crashed. SECONDARY (discuss.colyseus.io posts, 2019-2020, search summary). +- Colyseus reconnection after a drop: server holds the seat for `allowReconnection(client, seconds)` (docs suggest about 30 s for fast games, 5 min for turn-based); client persists `room.reconnectionToken` (docs example uses `sessionStorage`) and calls `client.reconnect(token)`; the token only works while the server still holds the seat; page refresh returns a new Room object with a full state. VERIFIED (docs.colyseus.io/room/reconnection). What a process restart does to held seats outside devMode: not stated. UNVERIFIED. +- Nakama authoritative matches: Heroic Labs staff (2019-08-09): "When the server shuts down there is no choice but to stop running matches"; pattern is to save state to a system-owned storage object in the terminate hook within the shutdown grace period and rebuild it in match init; 2022 follow-up says size the grace period for the match count and launch Nakama via `exec` so signals arrive; moving matches to another instance needs a roster of instances and an RPC. VERIFIED (forum.heroiclabs.com/t/preserve-match-states-after-server-restart/133). A hard crash skips the terminate window (inference from that thread; the thread does not discuss crashes explicitly). Per-VM crash isolation differs between the Lua runtime (VM per match) and Go (shared). SECONDARY (forum.heroiclabs.com thread via search). +- Photon Realtime rejoin: needs a stable UserId and `PlayerTtl` at room creation; `EmptyRoomTTL` should be at least `PlayerTTL` if room state is not saved; reconnect with a fresh anonymous UserId after app restart fails with error 32748. SECONDARY (forum.photonengine.com threads via search). A TTL is held by the Photon server process; whether it survives a Photon server restart: UNVERIFIED. +- SpacetimeDB persistence: write-ahead log; table data in memory, restored from log (already kept in `prior-art-2026-10-27.md`). +- .io games (agar.io-likes): no source found on restart or crash behaviour. UNVERIFIED. The open-source Agar.io server clones (Ogar, Cigar) were located by search but not read. +- Pattern across Colyseus, Nakama and Photon: session-scoped game state is not durable by default; the vendor-documented route is application code that saves at a shutdown hook, plus a reconnect window measured in seconds to minutes held in server memory. + +--- + +## 5. `player-identity` + +- Colyseus: each connection gets a `sessionId`; a refreshed `reconnectionToken` is the resume credential, stored by the app in `sessionStorage`; valid only while the seat is held. VERIFIED (docs.colyseus.io/room/reconnection). No account or key-pair concept in these pages; identity beyond that is the developer's, with a separate Auth module not read. UNVERIFIED for the Auth module. +- Nakama device auth: client-generated device id, 10 to 128 bytes alphanumeric with dashes; docs say to generate a random UUID on first launch and keep it in app-private storage, not an OS hardware id; unknown id creates an account by default (`create=false` disables); an account can link several device ids (link of an id owned by another account returns 409; unlinking the last returns 403); docs warn device ids "can be unreliable because device identifiers can sometimes change" and suggest Google or Game Center for games. VERIFIED (heroiclabs.com/docs/nakama/concepts/authentication, first 100k characters read). The id is a bearer secret with no proof of possession beyond knowing it (inference from the page; not stated as such). +- Photon Realtime `UserId`: server assigns one if the client sends none; rejoin requires the same UserId; a client-chosen UserId without Custom Authentication is unverified, so anyone can claim another player's id; Custom Authentication lets an external provider return the UserId, overriding the client's. SECONDARY (search summaries of doc.photonengine.com Realtime v5 userids pages and forum threads; the current docs URL I tried was 404). +- PlayFab `LoginWithCustomID`: the id is chosen by the client; Microsoft's best-practices page limits its published-game use to pairing with a pre-existing backend and recommends moving to another login before launch; "encrypted logins" hardening exists but must be on from the beginning; a community thread asks how to stop clients spamming random ids to create accounts, unanswered in the result. SECONDARY (search summaries of learn.microsoft.com login-basics-best-practices, devdocs.xbox.com encrypted-logins, community.playfab.com). The direct custom-id page returned 404. +- SpacetimeDB: `ctx.sender` is the caller identity hook (already kept); how identities are minted and resumed was not read. UNVERIFIED. +- Key pair in the browser: + - WebCrypto `CryptoKey` has `extractable`; `false` blocks `exportKey`/`wrapKey`. VERIFIED (MDN CryptoKey). CryptoKey is Baseline widely available (since September 2016), secure contexts only, available in workers. VERIFIED (MDN). + - Non-extractable keys are structured-cloneable so IndexedDB can hold them without raw bytes reaching script. SECONDARY (MDN page did not state it; search summaries of W3C WebCrypto list and library docs). Historic bugs: Safari 10 `DataCloneError` for CryptoKey in IndexedDB (fixed, WebKit bug 162554); Safari failed on `CryptoKeyPair` objects while Chrome and Firefox worked (WebKit bug 164868, marked duplicate); Firefox bug 1048931 had private-key cloning limits for ECDH in the 55 era. SECONDARY, 2016-2019, current state UNVERIFIED. ECDSA P-256 sign/verify availability per browser: UNVERIFIED in this pass (MDN SubtleCrypto page not read). + - Not protected against script running on the same origin: such script can call `sign()` with the stored key. This is a property of the API (extractable=false blocks export only), stated in MDN's definition; the XSS consequence is inference. + - Eviction: MDN storage-quotas page: best-effort storage may be evicted under pressure (LRU); `navigator.storage.persist()` upgrades to persistent, which Firefox grants by prompt and Safari and Chromium decide from user interaction history without a prompt; private modes may use different quotas and usually delete data at session end. VERIFIED (MDN Storage quotas and eviction criteria). + - Safari ITP: script-writable storage including IndexedDB, LocalStorage, SessionStorage and service-worker registrations is deleted after 7 days of browser use without user interaction (click, tap, keyboard) with the site; home-screen web apps are exempt. VERIFIED (webkit.org/tracking-prevention; MDN gives the same rule with "when cross-site tracking prevention is on"). Server-set cookies are exempt (MDN). So a browser-held key on Safari is lost after seven days of non-interaction. + - WebAuthn or passkeys as the alternative key store: not researched in this pass. UNVERIFIED. + +--- + +## 6. `browser-client-store` + +- Typed-array views over wasm memory: `Float32Array::view` and similar are zero-copy but "only valid so long as the backing buffer isn't resized"; any allocation in the module (`Box::new`, malloc) can grow memory, which detaches the old `ArrayBuffer` and leaves existing views empty (also when the grow delta is zero); the safe patterns are to create the view immediately before use and not allocate while holding it, or copy into a JS-owned array. VERIFIED (wasm-bindgen discussion #3802 and js-sys docs, via fetch and search). Resizable `ArrayBuffer` (TC39 Stage 4) is said to address this; whether wasm memory uses it today: UNVERIFIED. +- Unity WebGL: the emscripten heap is exposed to JavaScript as typed-array views (`HEAP8`, `HEAPU8`, up to 64-bit floats) usable from `.jslib` plugins; textures are reached through emscripten's `GL.textures` mapping; the build runs in its own scope on the page. SECONDARY (Unity manual page for WebGL browser scripting, several versions, via search summary). Unity renders itself inside wasm into its own canvas; the page-side renderer is not the Unity renderer. A JS renderer would read the heap through those views; no source describing that pattern was found. UNVERIFIED. +- Godot web export: `JavaScriptBridge` wraps JS objects as references; `eval` returns numbers, booleans, strings and binary types (`ArrayBuffer`, `TypedArray`, `DataView`) to GDScript; floats may lose precision; no performance guidance given. VERIFIED (docs.godotengine.org javascript_bridge page). Engine renders itself; the page does not say how. UNVERIFIED. +- Bevy on the web: renders inside wasm through WebGL2 by default or WebGPU opt-in at build time; simulation and renderer are the same wasm module. SECONDARY (bevy.org/news/bevy-webgpu and example pages via search). No JS-side renderer reading Bevy memory was found. UNVERIFIED. +- Photon Quantum (Unity): the simulation exposes `Predicted`, `PredictedPrevious`, `PreviousUpdatePredicted` and `Verified` Frames through `Game.Frames`; `PredictedPrevious` is documented as the second latest predicted copy "meant for visual interpolation between that copy and the Predicted frame"; view components read these through `PredictedFrame`/`PredictedPreviousFrame`/`VerifiedFrame`, updated from Unity's late-update by `QuantumEntityViewUpdater`; the entity view's transform source is switched by `InterpolationMode` between predicted frames and snapshot interpolation (`AUTO` picks by whether the entity is prediction-culled). VERIFIED (doc-api.photonengine.com Quantum 3 `QuantumGame.FramesContainer`, `QuantumEntityView`, `QuantumViewComponent` pages, via fetch and search). So the view reads retained whole-state Frame copies by reference in the same process, not a per-frame serialised copy; whether the Frames are read-only or thread-isolated: UNVERIFIED. Photon blog says Quantum WebGL samples exist and a Quantum game won a web game jam. SECONDARY (blog.photonengine.com/photon-multiplayer-webgl-for-game-jams, via search). +- Wasm to JS boundary guidance: a struct-of-arrays layout with one typed-array view per attribute lets a renderer read positions without copying; copying a JS typed array into Rust via wasm-bindgen slices costs a copy. SECONDARY (abratabia.com, scour.ing/Medium, reintech.io via search; blog-grade, no measurements read). +- Rapier JS (wasm physics) position read-out (view vs copy): not found. UNVERIFIED. +- Local, already kept (not re-verified): .NET `Span` crosses to JS as a `MemoryView` without copy; synchronous `JSImport` about 1 µs (browser-sim-runtime A4). + +--- + +## Gaps left + +- Chrome 97 Datagram interop with Kestrel's draft-02 WebTransport: no source either way. +- Any measured WebSocket under-loss behaviour for .io games; any .io restart behaviour. +- Overwatch talk content, Valve and Unreal primary pages (access blocked). +- ECDSA P-256 WebCrypto per-browser matrix; WebAuthn as key store. +- A concrete example of a JS renderer reading wasm game state from shared memory in a shipped game. diff --git a/research/cultmesh-garden-path/wasm-measure-2026-10-27.md b/research/cultmesh-garden-path/wasm-measure-2026-10-27.md new file mode 100644 index 00000000..1806fc0b --- /dev/null +++ b/research/cultmesh-garden-path/wasm-measure-2026-10-27.md @@ -0,0 +1,88 @@ +# Measuring probe: orbs simulation under Mono browser-wasm and NetWasm + +Date: 2026-10-27. Serves ruling `cultmesh-garden-path:ruling:browser-sim-runtime` (choice `csharp-wasm`: "the first cut is a measuring probe of download size, startup and 60 Hz tick cost on the orbs game under both Mono browser-wasm and NetWasm, and it can overturn the choice"). Campaign `cultmesh-garden-path`, session label `self-2026-10-27-eureka`. Companion: `browser-sim-runtime-2026-10-27.md` (prior art; this probe replaces its "no measurement" gaps 1 and 2 for this workload only). + +Measured, not inferred. Nothing admitted, nothing committed. + +## What was built + +A standalone C# orbs sim with no CultLib dependency: `Fix` (Q16.16 on `int`, products widened to `long`), `Player {Id, Position, Velocity, Mass}`, `Pellet`, `Shot`; `Movement` (integrate, damp, clamp to a 4096-unit arena, scripted LCG steering), `Eating` (orb-pellet overlap, 200 x 1000 tests per tick), `Hits` (ray vs circle over an 8-tick rewind ring, 50 shots x 200 players). 200 players, 1000 pellets, 50 shots. No floating point, no allocation in `Tick`. FNV-1a 64 state hash over all state. Exports: `Init`, `Tick`, `Hash`, `Noop`, `SetInput(int,int,int)`, `GetPositions() -> int[]`, `PushPositions()` (C# calls a JS import with a `Span`). + +Scratch (not in the repo): `C:\Users\Meta\AppData\Local\Temp\claude\F--Projects-CultLib\dfe49a4b-e0ab-440c-81c7-7325d2eb7405\scratchpad\wasm-probe\src\` (a scratch git repo; `shared\` is the sim, `mono\` and `netwasm\` the two hosts, `bench\bench.mjs` the harness, `run.sh` the job). + +## Environment + +- Yggdrasil via `ygg-verify.sh`, image `mcr.microsoft.com/playwright:v1.55.0-noble` (Chromium 140.0.7339.16 headless), container capped at 4 CPUs, niced, sharing the host with other verify jobs. .NET SDK 10.0.401 installed with `dotnet-install` inside the job; Mono runtime pack 10.0.14; `wasm-tools` workload installed for AOT. NetWasm.Sdk/Templates 0.10.3 (restore needed the NuGet index to catch up once; it worked on retry). +- Pages served from a local Node server on 127.0.0.1, files pre-compressed Brotli-11 in memory and sent with `Content-Encoding: br`; no network throttling; so startup excludes download time and shows decode, compile and run. Cross-origin isolated (COOP/COEP) so `performance.now()` resolves to about 5 us. +- Two full runs of the job (run 3, run 5); the tables use run 5 unless stated. Run 3 agrees within noise on every figure. +- Licence: NetWasm's compiler is under its Community License (free under 250 employees / USD 10M); this is internal evaluation use. + +## Native reference + +Native .NET 10 JIT, same `Sim.cs`, `Init(12345)` then 3000 ticks: hash `5de317214d945ca9` (hits 1620, eats 1105), 0.30 ms per tick (Stopwatch, console app, 3000 ticks, JIT warm). + +## Mono browser-wasm (.NET 10, `Microsoft.NET.Sdk.WebAssembly`, trimmed, `InvariantGlobalization`) + +| Measure | Interpreter | AOT (`RunAOTCompilation`) | +|---|---|---| +| Files downloaded | 10 | 10 | +| Raw bytes | 4,476,896 | 7,062,992 | +| Brotli-11 bytes | 1,412,481 | 1,625,283 | +| Largest file (raw / br) | `dotnet.native.wasm` 3,001,422 / 976,306 | `dotnet.native.wasm` 5,519,143 / 1,267,110 | +| `System.Private.CoreLib.wasm` (raw / br) | 1,021,205 / 318,581 | 1,018,133 / 233,418 | +| Cold start to first Tick, median of 10 | 227 ms | 207 ms | +| Warm start (reload, same context), median of 10 | 104 ms | 131 ms | +| Tick median (3,000 ticks, after 600 warm-up) | 1.36 - 1.40 ms | 0.455 ms | +| Tick p90 | 1.48 - 1.49 ms | 0.50 - 0.55 ms | +| Tick p99 | 11.4 ms | 0.74 - 0.89 ms | +| Batch mean (3,000 ticks, one timer) | 1.52 - 1.55 ms | 0.50 - 0.56 ms | +| Ticks over 4 ms (of 3,000) | 47 - 48 | 16 - 17 | +| Max tick | 12.3 - 13.2 ms | 10.7 - 11.0 ms | +| Hash after 3,000 ticks vs native | match | match | + +## NetWasm 0.10.3 (`NetWasm.Sdk`, `JsModule`, page host, Compact GC, `-p:NetWasmPublishTarget=browser`) + +| Measure | Value | +|---|---| +| Files downloaded | 18 (3 `.mjs` runtime modules, `NetProbe.wasm`, adapters and small json) | +| Raw bytes | 302,974 | +| Brotli-11 bytes | 68,959 | +| Largest files (raw / br) | `netwasm.runtime.*.mjs` 177,895 / 35,052; `NetProbe.wasm` 82,481 / 21,953 | +| Cold start to first Tick, median of 10 | 94.5 ms | +| Warm start, median of 10 | 31.6 ms | +| Tick median | 0.535 - 0.555 ms | +| Tick p90 / p99 | 0.56 - 0.96 ms / 0.91 - 1.05 ms | +| Batch mean | 0.62 - 0.64 ms | +| Ticks over 4 ms (of 3,000) | 18 - 21 | +| Max tick | 11.6 - 11.8 ms | +| Hash after 3,000 ticks vs native | match | + +Determinism: all three wasm builds produced `5de317214d945ca9` after 3,000 ticks, both in the per-tick-timed pass and the batch pass, in every repetition, equal to the native JIT hash. No mismatch. Integer and Q16.16 only; this says nothing about floating point. + +## Per-call JS interop (microseconds per call, 20,000 calls after one warm call; range over three repetitions in run 5) + +| Call | Mono interp | Mono AOT | NetWasm | +|---|---|---|---| +| `Noop()` int return | 0.31 - 0.47 | 0.26 - 0.37 | 0.16 - 0.24 | +| `SetInput(int,int,int)` (input struct as three scalars) | 0.36 - 1.15 | 0.28 - 0.99 | 0.31 - 0.46 | +| `GetPositions()` returns `int[400]` (copy), JS reads two elements | 3.8 - 4.9 | 2.3 - 3.3 | 2.6 - 3.2 | +| `PushPositions()`: C# calls a JS import with a `Span` of 400 (no copy), JS sums it | 5.7 - 6.1 | 3.9 - 5.6 | 1.3 - 1.9 | + +A span or array read-out of all 200 positions costs a few microseconds per frame on every toolchain, under 0.1 percent of a 16.7 ms frame. Mono needed `[JSMarshalAs]` on the span parameter and `AllowUnsafeBlocks`; NetWasm accepted the same source unchanged apart from `AllowUnsafeBlocks`. + +## How each number was taken + +- Shipped bytes: the set of URLs the server actually served during the first cold page load (every file the browser requested; the 404 favicon excluded). Raw = file size on disk. Brotli-11 = `zlib.brotliCompress` quality 11, window 24, per file, summed. The SDK-produced `.br`/`.gz` siblings were not requested and are not counted. These are floors: the sim uses almost none of the BCL and no CultCache, MessagePack or CultNet. +- Cold start: a fresh browser context (empty HTTP and compile caches) per run, `page.goto`, wait for `window.ready`; the page records `performance.now()` (from navigation start) right after `Init(12345)` and the first `Tick()` return. Median of 10. Warm: `page.reload()` in one context, 10 times, same metric. +- Tick cost: on the last cold page, `Init(1)` plus 600 ticks of warm-up, `Init(12345)`, then 3,000 `Tick()` calls each wrapped in `performance.now()` (about 5 us resolution), then a second pass of 3,000 ticks under one timer for the batch mean. Repeated three times in the same page. Median, p90 and p99 from the sorted per-tick array. +- Determinism: `Hash()` after each 3,000-tick pass compared with the native JIT hash. +- Interop: 20,000 consecutive calls from a JS loop in the page, mean per call. + +## Caveats + +- Every variant, including the AOT build and NetWasm, shows the same pattern: 16 - 48 ticks of 3,000 take 10.7 - 13.2 ms against a 0.5 - 1.4 ms median, all near 11 ms. The common value across three different runtimes points to the environment (a niced, CPU-capped container on a shared host, or a Chromium main-thread task), not to the runtimes' GC, but this probe did not isolate the cause. For Mono interpreter p99 it lands at 11.4 ms because 1.6 percent of ticks are hit. A run on a quiet host or on a real client machine is needed before any p99 figure is trusted. At a 60 Hz frame budget of 16.7 ms the spikes still fit, with little left for rendering. +- The sim is a probe workload, not CultCache documents; the ruling's prerequisite (AOT-safe CultCache registration) is not tested here. NetWasm refuses reflection and CultCache's registration reflects; whether CultCache can run under NetWasm is a separate cut. +- Sizes will grow with real BCL use (collections, LINQ, MessagePack, CultNet) and fastest-growing on Mono; NetWasm links only reachable code, Mono ships a fixed runtime of about 1.4 MB Brotli before any code. +- Mono AOT here is the default mixed mode; Mono interpreter has the jiterpreter on by default. Network time was not simulated: a 1.4 MB Brotli payload on a 10 Mbit link adds about 1.1 s that this table does not show; NetWasm's 69 KB adds about 0.05 s. +- NetWasm is 0.10.3, pre-1.0, single vendor, compiler under the Community License; the restore failed once because the NuGet index had not yet listed 0.10.3 for the container, then passed. +- Single machine, single browser (headless Chromium 140). No Firefox or Safari, no phone. diff --git a/research/cultmesh-garden-path/webtransport-server-sizing-2026-10-27.md b/research/cultmesh-garden-path/webtransport-server-sizing-2026-10-27.md new file mode 100644 index 00000000..32376b9f --- /dev/null +++ b/research/cultmesh-garden-path/webtransport-server-sizing-2026-10-27.md @@ -0,0 +1,146 @@ +# WebTransport server: protocol stack, .NET gap, reference sizes (2026-10-27) + +Serves ruling `cultmesh-garden-path:ruling:browser-transport` (InForce, Operator, 2026-10-27: WebSocket in stage 1 behind a transport port; a WebTransport server is built in parallel, "sized by how hard it proves"; the size estimate is advice owed to her). Facts with evidence pointers; no recommendation. Browser support table and Kestrel docs status are in `stage1-forks-prior-art-2026-10-27.md` and are not repeated. + +Tags: VERIFIED = read on the primary source in this pass (spec text, source file, issue page, local file). SECONDARY = third-party page or search summary. UNVERIFIED = looked for, not found or not readable. + +Line counts below are `wc -l` over shallow clones taken 2026-10-27 (clones in the session scratchpad, not kept), non-test files unless stated. + +--- + +## 1. Protocol stack + +### 1.1 Layers and what each demands of a server + +| Layer | Spec | What the server must do for WebTransport | Tag | +|---|---|---|---| +| QUIC transport | RFC 9000 | Handshake with ALPN `h3`, streams, flow control, TLS 1.3. | UNVERIFIED (not re-read; standard) | +| QUIC datagrams | RFC 9221 | Advertise a non-zero `max_datagram_frame_size` transport parameter. | VERIFIED (draft-16 s3.1 text, via fetch) | +| QUIC reliable reset | draft-ietf-quic-reliable-stream-reset | Draft-16: servers MUST send an empty `reset_stream_at` transport parameter; stream resets MUST use RESET_STREAM_AT with a reliable size at least the WebTransport stream header. | VERIFIED (draft-16 s3.1, s4.4, via fetch) | +| HTTP/3 | RFC 9114 | Control stream (type 0x00) carrying SETTINGS first; QPACK encoder/decoder uni streams (0x02, 0x03); request streams; GOAWAY. | VERIFIED (type codes from quiche `http_constants.h`) | +| QPACK | RFC 9204 | See 1.2. | VERIFIED (RFC text) | +| Extended CONNECT | RFC 9220 | Send `SETTINGS_ENABLE_CONNECT_PROTOCOL` (0x08) = 1; accept `:method CONNECT` with `:protocol`. | VERIFIED (quiche constants; draft-16) | +| HTTP Datagrams | RFC 9297 | `SETTINGS_H3_DATAGRAM` (0x33) = 1; datagram payload = quarter-stream-ID varint + payload; capsule protocol on the CONNECT stream. | VERIFIED (quiche constants; draft-16 s4.5) | +| WebTransport over H3 | draft-ietf-webtrans-http3 | See 1.3 and 1.4. | VERIFIED | + +### 1.2 QPACK for a server + +- A server's maximum dynamic table capacity is 0 until its encoder processes a peer SETTINGS frame with non-zero `SETTINGS_QPACK_MAX_TABLE_CAPACITY` (default 0). So a server that never advertises capacity leaves the browser unable to use the dynamic table toward it. VERIFIED (RFC 9204 s3.2.3 text lines 560-575; s5 default zero). Browser encoders sending to the server obey the capacity the server advertises (decoder side), so advertising 0 (the default) means requests use only the static table plus literals. VERIFIED by implementation: wtransport's decoder returns `DynamicNotSupported` on any dynamic reference (`wtransport-proto/src/qpack.rs` lines 58-110), and the repo ships against browsers. +- Huffman is not optional for a decoder. The Huffman flag is per string literal and chosen by the peer encoder; the table is RFC 7541 Appendix B unmodified. VERIFIED (RFC 9204 lines 680-690). wtransport uses the `httlib-huffman` crate for it (`qpack.rs:198-204`, `wtransport-proto/Cargo.toml`). The server's own response encoder may avoid Huffman and dynamic table entirely (literals/static references only); wtransport's `qpack.rs` is 583 lines containing both directions. +- Both QPACK uni streams still have to be opened/read by the server (stream-type bytes, instructions ignored when capacity is 0). UNVERIFIED in detail; wtransport has `driver/streams/qpack.rs` (106 lines) for it. + +### 1.3 Current draft and codepoints + +- Current draft: `draft-ietf-webtrans-http3-16`, 2026-07-06, expires 2027-01-07, datatracker state "In WG Last Call", Standards Track. VERIFIED (datatracker). Previous: -15 (2026-03), -14 (2025-10), -13 (2025-07). +- Draft-16 codepoints VERIFIED (datatracker fetch): + - Uni stream type `0x54`; bidi signal value `0x41` (also reserved frame type WT_STREAM). + - `SETTINGS_WT_ENABLED` `0x2c7cf000` (value 1 = "webtransport-h3"; >1 is a connection error); `SETTINGS_WT_INITIAL_MAX_STREAMS_UNI` `0x2b64`; `..._BIDI` `0x2b65`; `SETTINGS_WT_INITIAL_MAX_DATA` `0x2b61`. + - The datatracker page says the draft defines no `SETTINGS_WT_MAX_SESSIONS`; session limits use 429 / `H3_REQUEST_REJECTED` and RateLimit headers. But webtransport-go (draft-16) defines `settingsWebTransportMaxSessions = 0x14e9cd29` and sends it with value 2^62-1, with the comment "Safari requires SETTINGS_WT_MAX_SESSIONS >= 1 (draft-ietf-webtrans-http3-14)" (`webtransport-go/protocol.go`, `server.go:46`). Draft-14 search snippets (SECONDARY) also name `SETTINGS_WT_MAX_SESSIONS` with "must advertise > 0". The -16 page and the -14 text disagree; the codepoint webtransport-go uses was not found in the -16 page fetched. UNVERIFIED which text is canonical for the 0x14e9cd29 setting. + - Capsules: WT_CLOSE_SESSION `0x2843`; WT_DRAIN_SESSION (provisional) `0x78ae`; WT_MAX_STREAMS bidi/uni `0x190B4D3F`/`0x190B4D40`; WT_STREAMS_BLOCKED `0x190B4D43`/`0x190B4D44`; WT_MAX_DATA `0x190B4D3D`; WT_DATA_BLOCKED `0x190B4D41`. WT_MAX_STREAM_DATA and WT_STREAM_DATA_BLOCKED are prohibited. + - Errors: WT_BUFFERED_STREAM_REJECTED `0x3994bd84`, WT_SESSION_GONE `0x170d7b68`, WT_FLOW_CONTROL_ERROR `0x045d4487`, WT_ALPN_ERROR `0x0817b3dd`, WT_REQUIREMENTS_NOT_MET `0x212c0d48`; application error range `0x52e4a40fa8db`..`0x52e5ac983162`, skipping `0x1f*N+0x21`. + - Session: extended CONNECT, `:protocol webtransport-h3`, `Origin` required for browser clients (403 on failure, 405 if resource not WT, 2xx accepts); optional `WT-Available-Protocols` / `WT-Protocol` negotiation. Close capsule = 32-bit error + UTF-8 message (prose says 1024 bytes, diagram says 8192: internal inconsistency in the draft text). Datagram = quarter-stream-ID + payload. Peer streams/datagrams may arrive before the CONNECT is accepted and SHOULD be buffered within limits. Flow control is enabled only when both sides send non-zero for one of the three `WT_INITIAL_MAX_*` settings; otherwise a client must not open more than one session. +- Draft-02 / draft-07 codepoints (what Chromium's stack uses), from quiche `http_constants.h` and `quic_spdy_session.cc` (VERIFIED): `SETTINGS_WEBTRANS_DRAFT00` = `0x2b603742` (draft-02, "ENABLE_WEBTRANSPORT"); `SETTINGS_WEBTRANS_MAX_SESSIONS_DRAFT07` = `0xc671706a`; `SETTINGS_H3_DATAGRAM_DRAFT04` = `0xffd277`; `SETTINGS_H3_DATAGRAM` = `0x33`. +- Draft-02 vs -07 incompatibilities (draft-07 Appendix A.1, VERIFIED): -07 requires `SETTINGS_WEBTRANSPORT_MAX_SESSIONS` and uses it for version negotiation; explicitly requires `SETTINGS_ENABLE_CONNECT_PROTOCOL`; explicitly requires `SETTINGS_H3_DATAGRAM`; WEBTRANSPORT_STREAM only at stream start. Safe back-ports into -02: 32-bit reset codes, SESSION_GONE, GOAWAY handling, DRAIN capsule, no auto-redirects. + +### 1.4 Which draft each browser speaks, and negotiation + +- Negotiation mechanism: each draft version owns a `SETTINGS_WT_ENABLED`-style codepoint; endpoints send one per supported version and the highest common one is used. VERIFIED (draft-16 text). In quiche this is implemented as a bit-set of versions: server sends `0x2b603742` for draft-02 and `0xc671706a` (value 16) for draft-07; it records which peer settings it received (`quic_spdy_session.cc:642-654, 1350-1380`). VERIFIED. +- Chromium's shared stack (quiche `main`) supports exactly draft-02 and draft-07 (`kDraft02`, `kDraft07` in `quic_spdy_session.h:137-154`); the header calls draft-02 "the first version to be ever publicly shipped in Chrome". VERIFIED for quiche; that Chrome stable ships quiche's `main` set is SECONDARY (inference; no Chromium release note read). +- Firefox: IETF 124 webtrans minutes state "implemented to draft 4" for WebTransport over H3 with draft 14 in progress, flow control and RESET_STREAM_AT in progress. VERIFIED (minutes text as returned by fetch; "draft 4" is as written). +- Safari: minutes say its prototype supports "the latest H2 and H3 drafts"; the number is not given. VERIFIED (minutes). Safari 26.4 shipped it (see prior note). Safari "requires SETTINGS_WT_MAX_SESSIONS >= 1" per webtransport-go comment citing draft-14. VERIFIED (source comment). A third-party page says Safari refuses a session when `WT_MAX_SESSIONS` > 1 enables flow control while the three `WT_INITIAL_MAX_*` default to 0, while other engines tolerate it; and that different quic-go generations with identical-looking settings got different Safari results (SECONDARY, echo.semantic-ui.com/compat, low-provenance domain). +- `:protocol` token: draft-16 is `webtransport-h3`; webtransport-go accepts both `webtransport-h3` and legacy `webtransport` (`protocol.go:26-38`: "pre-draft-15 value ... for peers that have not yet migrated"). VERIFIED. SECONDARY that Chrome and Safari still send legacy `webtransport` (same low-provenance page). +- Server-side draft header: for the draft-02 world, aioquic's example server adds response header `sec-webtransport-http3-draft: draft02` (`aioquic/examples/http3_server.py:291`); a fails-components issue records "Opening handshake failed" in Chrome resolved elsewhere (wrong URL path), citing that header from an aioquic issue. VERIFIED for the aioquic line; the Chrome necessity of the header is SECONDARY. +- webtransport-go (draft-16) sends three settings for compatibility: draft-06-era `0x2b603742`, `SETTINGS_WT_ENABLED` `0x2c7cf000`, and `0x14e9cd29` max sessions. It does not send `0xc671706a`. It ships a headless-Chrome interop harness (`interop_chrome/`). VERIFIED (source). Net: a draft-16 server in this repo reaches Chrome through the draft-02 setting. + +### 1.5 Browser requirements the RFCs leave optional or the drafts leave soft + +- `SETTINGS_ENABLE_CONNECT_PROTOCOL` and `SETTINGS_H3_DATAGRAM` are explicit requirements from draft-07 up, and in quiche a draft-07 negotiation with only the draft-04 datagram setting is rejected (`quic_spdy_session.cc:1145-1172`). VERIFIED. +- A third-party page reports: if the max-sessions setting is absent `ready` never resolves or rejects; Chrome `maxDatagramSize` fixed at 1024; Safari can silently fall back to HTTP/2 over TCP (datagrams vanish); Safari flow-control credit not released on FIN/RESET (WebKit bug 319818). SECONDARY (single page). +- Origin header: required from browsers by draft-16 s3.2. VERIFIED. + +--- + +## 2. The .NET gap + +### System.Net.Quic +- No datagram API in the shipped surface. The CultLib managed transport itself says so: "System.Net.Quic exposes streams but not QUIC datagrams" (`src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs:399-401`). VERIFIED (local file). That the file targets net10.0 is VERIFIED (`GameCult.Mesh.Quic.csproj`). +- dotnet/runtime #53533 "QUIC Datagram API": OPEN, labels `api-suggestion`, `area-System.Net.Quic`, milestone **12.0.0** (as returned 2026-10-27), last activity 2026-07-25. Maintainer ManickaP, 2026-02-25: "we're re-evaluating this for the next .NET. It's mandatory for WebTransport over H/3 AFAIK." nibanks (MsQuic) 2026-01-21 recommends streams over datagrams where one controls both ends. VERIFIED (gh). A duplicate community proposal #123418 (a contributor reports a working fork `wes-sleeman/runtime branch quic-datagrams`) was closed as dupe 2026-01-21 with "we will look into this internally and align the API shape ... No need to put more effort into this atm". VERIFIED (gh). +- Proposed API shape in #53533: `QuicConnectionOptions.DatagramReceiveEnabled`, `QuicConnection.DatagramReceived` event, `SendDatagram`, `DatagramMaxSendLength`, send-state callbacks. VERIFIED (issue body). Not shipped in any .NET 8/9/10 API read. +- System.Net.Quic is stable from .NET 9. SECONDARY (prior note). + +### Kestrel experimental WebTransport +- Implemented: draft-02 only. `docs/WebTransport.md`: "most of the WebTransport draft-02 specification, except for datagrams"; needs `EnablePreviewFeatures` + runtime option `...Experimental.WebTransportAndH3Datagrams`; sample apps and a 14-day ECDSA P-256 test cert generator in the doc. VERIFIED (dotnet/aspnetcore `docs/WebTransport.md`). +- Settings it speaks: `Http3SettingType.EnableWebTransport = 0x2b603742` and `H3Datagram = 0xffd277` (the draft-04 value, not RFC 9297's `0x33`); no max-sessions setting. VERIFIED (`Http3SettingType.cs`). So it is draft-02 with draft-04 H3 datagrams: by quiche's rules (1.3) that cannot negotiate draft-07. +- Code size: `Internal/WebTransport/` = `WebTransportSession.cs` 6,618 bytes, `WebTransportStream.cs` 4,104, `WebTransportStreamType.cs` 674, plus hooks in `Http3Connection.cs` (39,423 bytes; WebTransport at lines ~43-85, 406-452, 650-667), `Http3Stream.cs` (59,394), `Http3StreamContext.cs`. Public surface: `IHttpWebTransportFeature`, `IWebTransportSession` in `Microsoft.AspNetCore.Http.Features`. VERIFIED (gh api listing/search; sizes in bytes as returned, lines not counted). +- Kestrel already owns the whole H3 server stack under it: `Http3/` (control stream, frame reader/writer, SETTINGS, GOAWAY), `Http3/QPack/` (decoder/encoder stream readers, dynamic table), Huffman. VERIFIED (gh listing). dotnet/runtime holds a second copy of QPack for the H3 client at `src/libraries/Common/src/System/Net/Http/aspnetcore/Http3/QPack/` (shared source). VERIFIED (gh search path). +- Open issues: aspnetcore #42788 "WebTransport PR follow-ups" (open, updated 2026-09-14); #42784 "Adding datagrams support to WebTransport" (open, updated 2026-02-03); #42785 WT over HTTP/2 (open); #43171 streams may arrive out of order (open; this is the buffered-before-CONNECT case); #65406 "Quic + Datagrams" (open, Backlog; focused by danroth27 2026-05-04 to just QUIC+datagrams). VERIFIED (gh). +- Team position, BrennanConroy 2026-02-12 on #65406: QUIC datagrams "would need to be added in dotnet/runtime"; WebTransport "still in draft 4 years later. We aren't likely to put much investment into it until it matures"; he "could definitely see us pointing AI at the current codebase if it's not working with browsers c[urrently]" (truncated in fetch). VERIFIED (gh, quoted portions). Whether an external PR would be accepted: UNVERIFIED (no PR or maintainer statement either way found; `contributing` rules not read). +- dotnet/runtime #43641 "Add support for WebTransport": open, `api-suggestion`, `area-System.Net.Http`, no activity since 2023-01. VERIFIED (gh). + +### MsQuic +- Latest release v2.6.3 (2026-10-13). VERIFIED (GitHub API). CultLib pins 2.5.9 (`scripts/build-quic-native.ps1:16`; Linux CMake message says "msquic v2.5.9 headers"). VERIFIED (local). +- Datagram API in `msquic.h` v2.5.9: `QUIC_SETTINGS.DatagramReceiveEnabled` (line 757/811), params `QUIC_PARAM_CONN_DATAGRAM_RECEIVE_ENABLED` 0x0500000D and `..._SEND_ENABLED` 0x0500000E, `DatagramSend(Connection, Buffers, BufferCount, Flags, ClientSendContext)` (must fit one QUIC packet), events `DATAGRAM_STATE_CHANGED {SendEnabled, MaxSendLength}`, `DATAGRAM_RECEIVED`, `DATAGRAM_SEND_STATE_CHANGED` with states UNKNOWN/SENT/LOST_SUSPECT/LOST_DISCARDED/ACKNOWLEDGED/ACKNOWLEDGED_SPURIOUS/CANCELED, flag `QUIC_SEND_FLAG_DGRAM_PRIORITY`. VERIFIED (header read). +- Reliable reset: `QUIC_SETTINGS.ReliableResetEnabled`, `QUIC_PARAM_STREAM_RELIABLE_OFFSET`, event `RELIABLE_RESET_NEGOTIATED`. VERIFIED (header). That its transport-parameter codepoint matches the one browsers send for draft-16's `reset_stream_at`: UNVERIFIED. +- Windows ships `msquic.dll` with the runtime; Linux needs `libmsquic`. SECONDARY (prior note). + +--- + +## 3. Reference implementations (sizes) + +All sizes are non-test lines. "Under it" names the layer the project does NOT contain. + +| Project | Draft | WT-specific size | Under it | Notes | Tag | +|---|---|---|---|---|---| +| quic-go/webtransport-go (Go, last commit 2026-10-26) | -16 (README), accepts legacy `webtransport` token, sends draft-06/-16/-14 settings | 3,397 lines in repo root (4,517 incl. interop/example/testdata); tests 4,408. Largest: `server.go` 533, `session.go` 458, `streams_map_outgoing.go` 357, `send_stream.go` 322, `client_conn.go` 289, `capsule.go` 261, `session_manager.go` 190, `receive_stream.go` 185, `streams_map_incoming.go` 179, `flow_control.go` 107, `transport.go` 125 | quic-go v0.62.0 `http3` pkg 5,946 lines + quic-go core ~15.2k + internal ~10.9k; `qpack` v0.6.0 separate module | Client+server. Uses quic-go hooks: additional SETTINGS map, conn context, stream hijack, datagram enable. Own per-session stream maps and flow control. Used by Caddy, Centrifugo, libp2p, MediaMTX. | VERIFIED | +| BiagioFesta/wtransport (Rust, last commit 2026-09-22) | README: "still a draft"; settings `0x2b603742` + `0xc671706a` in `wtransport-proto/src/settings.rs:247-248` (draft-02/-07 pair) | `wtransport-proto` 6,072 lines (incl. inline unit tests; `bytes.rs` 1,008, `stream.rs` 1,233, `frame.rs` 718, `session.rs` 485, `stream_header.rs` 509, `qpack.rs` 583, `ids.rs` 455); `wtransport` crate 6,787 lines (`tls.rs` 1,373, `config.rs` 1,287, `endpoint.rs` 855, `driver/mod.rs` 682, `driver/streams/mod.rs` 588) | quinn 0.11 (QUIC + datagrams), rustls, `httlib-huffman`; pure-Rust, QPACK static-only | The closest "H3 from scratch over a QUIC library that exposes only streams+datagrams" model. Sans-io protocol crate separate from async driver. Config/TLS are about 40% of the driver crate. | VERIFIED (counts); draft-pair inferred from codepoints | +| aioquic (Python, last commit 2026-10-16) | draft-02 only: `Setting.ENABLE_WEBTRANSPORT = 0x2B603742`, WT stream types `0x41`/`0x54`, requires H3_DATAGRAM | `h3/connection.py` 1,591 lines for all of HTTP/3 + QPACK glue (via `pylsqpack` C extension) + WT hooks; WT hooks are about 15 mentions | aioquic QUIC: `quic/connection.py` 4,266, `packet.py` 665, `packet_builder.py` 413 | Smallest complete reference; no capsule handling, no flow control, no session limits, no draft-07. Example server adds `sec-webtransport-http3-draft: draft02`. | VERIFIED | +| Kestrel WebTransport (C#, experimental) | draft-02 + draft-04 H3 datagram setting | ~11 KB in `Internal/WebTransport/`, plus WT branches in `Http3Connection`/`Http3Stream` | Kestrel H3 + QPack + MsQuic via System.Net.Quic | No datagrams, no server-initiated sessions, no capsules. | VERIFIED (bytes, not lines) | +| Chromium / quiche (C++) | -02 and -07 | `web_transport_http3.cc` 509 lines (18 KB; header 8.7 KB), `web_transport_only_server_session.cc` 7.8 KB, `web_transport_stream_adapter.cc` 8.3 KB, `web_transport_priority_scheduler.cc` 6 KB, `quic/web_transport` headers (`web_transport.h` 17.6 KB); `quic_spdy_session.cc` 2,075 lines holds the settings/negotiation | Full quiche QUIC (not sized) and `qpack` dir (~201 KB incl. tests) | Test server `web_transport_only_*` exists; Chrome's reference server. Draft support is a runtime bit-set. | VERIFIED (sizes in bytes/lines as shown) | +| libwebtransport / other C and C++ libraries on MsQuic, ngtcp2, lsquic | | | | A search for a C/C++ WebTransport server over msquic, ngtcp2 or lsquic returned nothing relevant. | UNVERIFIED that none exists | +| Demiurgos.WebTransport.Client (C#) | | client only, Windows x64, preview, native MsQuic backend with datagram send/receive (prior note) | | The only C# WebTransport package found; no server. | VERIFIED (prior note) | + +--- + +## 4. CultLib's own starting point + +### `native/GameCult.Mesh.Quic.Native/` +- Size: `cultmesh_quic_native.cpp` 1,513 lines, `include/cultmesh_quic_native.h` 371, `tests/cultmesh_quic_native_tests.cpp` 1,305, `CMakeLists.txt` 118. VERIFIED (`wc -l`). +- Datagrams: no mention of "datagram" in the .cpp, header, README or CMake (`grep -i datagram` empty). It sets `IdleTimeoutMs`, `KeepAliveIntervalMs`, `PeerUnidiStreamCount`, `HandshakeIdleTimeoutMs` only (`cultmesh_quic_native.cpp:769-777`); no `DatagramReceiveEnabled`, no `DatagramSend`, no `ReliableResetEnabled`. VERIFIED. +- Design: runtime-neutral queue ABI v2, no host callbacks, 64-byte event struct, 13 exports; owns registration, configuration, listener, connection and stream handles, TLS credential loading, ALPN `cultmesh-state-v1`, and a 1-byte-kind + 4-byte-LE-length stream framing; "never decodes a CultMesh frame". Two platforms (Windows x64 OpenSSL-flavoured MsQuic NuGet 2.5.9, Linux libmsquic). VERIFIED (README, header). Windows build uses the OpenSSL MsQuic because the Schannel build "refuses" something (build script line 17; not read further). +- Consumers: TS via koffi (`packages/cultmesh-ts/src/realtime-quic-native.ts`, native binaries committed under `packages/cultmesh-ts/native/{linux-x64,win32-x64}/`), and C# via P/Invoke (`src/GameCult.Mesh.Quic.Native/CultMeshNativeQuicRealtimeTransport.cs`, 278 lines). VERIFIED (git ls-files). + +### Managed C# QUIC today +- `src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs` (700 lines) uses `QuicListener.ListenAsync`, `QuicConnection.ConnectAsync`, `OpenOutboundStreamAsync`, one ALPN list; sending `CultMeshRealtimeDelivery.Unreliable` throws `NotSupportedException("... unreliable delivery requires a native MsQuic connector")` (lines 397-401). VERIFIED (local). +- Rust CultMesh (`packages/cultmesh-rs`: `lib.rs` 1,902 lines, `rudp_document_server.rs` 1,082) and cultnet-rs have no QUIC dependency (`Cargo.toml` grep empty). VERIFIED. Existing server transports: RUDP, WebSocket (`GameCult.Networking.WebSockets`), QUIC (above). +- `docs/cultmesh-garden-path-target.md:146` lists the transport as an open item; `docs/typescript-quic-realtime-target.md:112` says "Browser QUIC (WebTransport). Browsers keep the verified WebSocket path." VERIFIED (local). + +### Which layer can host H3 + WebTransport +Facts bearing on it, not a choice: + +| Layer | Datagrams | H3/QPACK present | Needs written | +|---|---|---|---| +| Managed C# over System.Net.Quic (net10) | No public API (runtime #53533, milestone 12.0.0) | No public H3 server API; QPack source exists inside runtime/Kestrel as internal | Datagrams impossible without a runtime change or reflection/private MsQuic access; H3 control + QPACK + capsules + WT layer | +| Native bridge (MsQuic 2.5.9 C++) | MsQuic has full API; bridge does not enable it | No | Datagram + reliable-reset plumbing in the bridge ABI (new events/exports), H3 + QPACK + WT either in C++ or in C#/TS above a bridge that exposes raw streams, per-stream FIN/reset and datagrams | +| Upstream Kestrel | No (needs runtime #53533 first) | Yes: complete H3, QPack, GOAWAY | Datagrams, draft-07+ settings, capsules, server-initiated sessions, flow control; team says low priority | +| Rust (wtransport over quinn) | Yes via quinn | Yes, own static-only QPACK | No dependency on the .NET/MsQuic stack; CultLib Rust crates currently use RUDP only | + +--- + +## 5. Interop pain reported + +- Draft churn: Chromium stack on -02/-07 while the IETF text is at -16 with different codepoints; Firefox on a "draft 4"/14-in-progress; Safari "latest" (1.4). VERIFIED/SECONDARY as tagged there. webtransport-go keeps three settings plus a legacy `:protocol` token to span them (1.4). +- Draft-16 itself marks "draft-only" version rules (client sends draft codepoint, server defers processing until client SETTINGS) as to be removed before publication. VERIFIED. +- Certificates: `serverCertificateHashes` rules, W3C WebTransport CR Snapshot, VERIFIED: validity period MUST NOT exceed two weeks; key must be ECDSA secp256r1 (P-256); the 14-day figure matches Kestrel's doc ("14 days is the max duration"). Let's Encrypt community thread confirms public-CA certs (90 days) cannot be pinned this way (SECONDARY). Default Kestrel dev cert does not work with WebTransport (docs), aspnetcore #41762 "Default dev certificate not working with Http3" open, updated 2026-01-26 (VERIFIED, gh). Other pinning gotchas (hash of leaf DER not SPKI, lowercase `"sha-256"`, `allowPooling` incompatible, Chrome requires a public root otherwise `ERR_QUIC_CERT_ROOT_NOT_KNOWN`, Safari ignores user-installed roots): SECONDARY (single low-provenance page). Feisty Duck newsletter and an IPFS issue exist on the same topic, not read. The W3C group discussed removing `serverCertificateHashes` (issue #623) and marked it at-risk: SECONDARY. +- Headless Chrome testing: webtransport-go's `interop_chrome/interop.py` drives Chrome through selenium/chromedriver with `--no-sandbox --enable-quic --headless --origin-to-force-quic-on=localhost:12345 --host-resolver-rules='MAP localhost:12345 127.0.0.1:12345'`. VERIFIED (file read). `--webtransport-developer-mode` as the private-CA escape: SECONDARY. +- Handshake failures that look like draft mismatch can be a wrong URL path (fails-components issue #358, 2025-04-04): VERIFIED (fetch). +- Kestrel sample app issue "ERR_CONNECTION_RESET running WebTransportInteractiveSampleApp" (aspnetcore #52663, open) shows the shipped experimental path failing for at least one user: VERIFIED title/state only; cause not read. +- Not found: an engineering blog post from someone who wrote a WebTransport H3 server from scratch describing effort. UNVERIFIED that none exists. + +--- + +## Evidence index + +- Spec: datatracker `draft-ietf-webtrans-http3` (-16), `ietf.org/archive/id/draft-ietf-webtrans-http3-16.html`, `...-07.txt`, RFC 9204 text, W3C `webtransport` TR snapshot, IETF 124 webtrans minutes. +- Code: `google/quiche` (`http_constants.h`, `quic_spdy_session.{h,cc}`, `web_transport_http3.{h,cc}`), `quic-go/webtransport-go`, `quic-go/quic-go`, `BiagioFesta/wtransport`, `aiortc/aioquic`, `dotnet/aspnetcore` (`docs/WebTransport.md`, Kestrel `Internal/Http3`, `Internal/WebTransport`), `microsoft/msquic` v2.5.9 `src/inc/msquic.h`. +- Issues: dotnet/runtime #53533, #123418, #43641; dotnet/aspnetcore #42788, #42784, #42785, #43171, #65406, #41762, #52663. +- Local: `F:\Projects\CultLib\native\GameCult.Mesh.Quic.Native\{cultmesh_quic_native.cpp,README.md,include\cultmesh_quic_native.h,CMakeLists.txt}`, `F:\Projects\CultLib\src\GameCult.Mesh.Quic\CultMeshQuicRealtimeTransport.cs`, `F:\Projects\CultLib\scripts\build-quic-native.ps1`. diff --git a/research/paged-store/flush-measure-2026-10-27.md b/research/paged-store/flush-measure-2026-10-27.md new file mode 100644 index 00000000..2d6588fb --- /dev/null +++ b/research/paged-store/flush-measure-2026-10-27.md @@ -0,0 +1,140 @@ +# CultCache whole-view single-file flush: measured cost on Aetheria's catalog + +Serves ruling `variants:ruling:page-tree-store-research` and follow-up `variants:follow_up:paged-single-file-store` (the eureka-state `view` calls refused the bare ids, `missing field id` / `expected struct PipelineRef`, so the brief's text was the spec). Session `self-2026-10-27-eureka`. Measuring probe: nothing admitted, nothing committed, no repo changed. Date 2026-10-27. + +## Headline + +- Aetheria's real catalog is small: `GameData/Aetheria.cc` is 214,588 bytes and **394 records** in 18 schemas; `aetheria.nakovalnya/ship.cc` is 40,059 bytes and **3 records**. Neither is a store where a flush is slow. +- A flush rewrites the whole view whatever changed. 1, 10, 1% and 10% of records changed cost the same (Aetheria.cc on the container's disk: 5.0 to 6.0 ms for 1 to 39 records changed, 214.6 KB written each time, 2.56 MB allocated each time). +- Cost per flush on the real catalog: about 5.5 ms on the container disk (serialise 1.8 ms; the other 3.7 ms is record assembly, about 1.1 ms of it CPU, plus the write and fsync), 2.5 ms with disk free. Sustained rate: about 180 one-record flushes per second on disk, about 300 in memory. +- The cost is linear in record count. On a synthetic catalog of the same record shape: 3,940 records flush in about 31 ms (about 33 per second), 39,400 records in about 320 ms (3 per second). A 30 Hz writer fits the real catalog (5.5 ms of a 33.3 ms frame) and stops fitting between roughly 4,000 and 5,000 records, whatever the disk. +- CPU, not I/O, owns the cost once the store passes a few thousand records: at 39,400 records the in-memory (tmpfs) flush is 300 ms against 320 ms on disk. +- The existing `DirectoryMessagePackBackingStore` does not fix this. A 1-record flush rewrites the whole manifest: 113 KB per flush on Aetheria.cc (0.53 of the file), 700 KB at 3,940 records (0.31), and takes as long as the single-file flush (5.8 ms against 5.4 ms; 39 ms against 32 ms at 3,940 records). It accepted all 394 records (no variants in the catalog, so nothing refused). +- C2a (`7c7cc799`) cannot open Aetheria's current types at all: its registry throws `Faction.Places: list element type FactionPlace has no [CultElementId] member`. Its serialise step is cheaper (per-record bytes are kept: 0.16 ms against 1.1 ms at 394 records, 74 ms against 128 ms at 39,400), but totals move little (-11 percent at 39,400 records, 5.6 to 3.7 ms at 394) because record assembly and the whole-file write remain O(records). C2a loads slower (2x at 394 records, 2.3x at 39,400). Its numbers are synthetic-only. + +## Method + +Where: Yggdrasil via `ygg-verify.sh` (image `mcr.microsoft.com/dotnet/sdk:10.0`, 4 CPUs by cgroup `cpu.max 400000 100000`, 6 GiB, niced, up to 5 jobs share the host). **Every absolute number is from that shared, capped, niced container** and carries its noise (run-to-run max values show it); the shape (flat in changed records, linear in total records) is the finding, not the milliseconds. Starfire ran nothing but git, file copies and text editing. Container disk is `/dev/vda4` (the host's disk); "shm" is `/dev/shm` tmpfs in the same container, where fsync and write cost almost nothing, so shm separates CPU from I/O. + +Inputs: +- Aetheria `GameData/Aetheria.cc` and `GameData/Mods/aetheria.nakovalnya/ship.cc` smudged from the `origin/master` blobs (Aetheria `b375cfc8`) by `git cat-file -p | git lfs smudge` into a scratch directory; the LFS content reached the container as part of the bundled job repo, so no LFS cache question arose. The operator checkout was not touched. +- Aetheria's own types: the bundle carries `Aetheria.Shared` (the headless build of `Assets/Scripts/ServerShared`, UniRx, Ink) from `origin/master`, so the real `SingleFileMessagePackBackingStore` hydrates real `ItemData`, `Faction`, `ShipAuthoring` and the rest. Wiring as in `AetheriaStores.Open`: `new CultCache()`, `AddBackingStore(new SingleFileMessagePackBackingStore(path), AetheriaStores.CatalogTypes)`; `Open`'s post-load validations are not timed. +- CultLib: `origin/main` `6a20309d`; `origin/hands/variants-c2a` `7c7cc799` (the branch tip when the job ran; it was `0c19d080` when the session began, a commit was added since); Aetheria's pinned CultLib `b8fc5eb` as a third column ("pin"), because that, not main, is what the game compiles today. Aetheria master compiles unchanged against main and against the pin (the `Directory.Build.targets` pin check was left out of the bundle). It does not run against C2a. + +Measurements (harness `Program.cs`, scratchpad `flush-measure\bundle\harness\`): +- Load: `new store + new CultCache + AddBackingStore` (attaching reads the store). "Process-cold" = first load in a fresh process, JIT and type discovery included, median of 5 processes; the OS page cache could not be dropped in the container, so it is cold for the process, warm for the file. "Warm" = median of 15 further loads in one process. +- Flush: mutate N records through `cache.UpsertAsync(type, doc, key)` (first writable non-Name float, int or string member, deterministic spread over the record list), assert `cache.IsDirty`, then time only `cache.FlushAllBackingStores()` with a `Stopwatch`; 3 discarded warm-up flushes, then 15 timed. N = 1, 10, 1 percent, 10 percent of records. Medians reported; min and max are in the raw log. +- Serialise vs rest: the harness subclasses the real store and overrides only its two serialise hooks (`SerializePayload`, `SerializeSnapshot`; on C2a `SerializePayload/Record/CatalogEntry/Store`) to accumulate `Stopwatch` ticks, then calls `base`. "Rest" = total minus serialise: lock sidecar, record assembly and ordering, schema catalog build, and write + `FlushToDisk` + `File.Replace`. It is not pure I/O: the in-memory column shows what remains with I/O free. A separate floor row times the store's exact atomic write (temp file, `WriteThrough`, fsync, `File.Replace`) of a same-size random payload. +- Bytes written: the store replaces the file whole, so bytes written = resulting file size (measured). For the directory store, the files whose length or mtime changed or that are new, summed. +- Allocation: `GC.GetAllocatedBytesForCurrentThread()` delta across the flush. This is bytes allocated, an upper bound on peak transient managed memory, not a measured high-water mark. +- Throughput: loop of (mutate one record + upsert + flush) for 4 s (3 s synthetic); cycles per second, plus the flush-only median. +- Directory store: `DirectoryMessagePackBackingStore(manifestPath)` filled by upserting the same documents under the same keys, then the same flush measurements. It refuses variants (`NotSupportedException`); the catalog holds none (single-file format v1), so it refused 0 of 394 and 0 of 3. +- Synthetic sweep: a standalone `SynthItem` document (name, description, 4 numbers, a string list, a 30-float list, a nested object; about 570 bytes per record, the real catalog's mean is 545) at 394, 3,940 and 39,400 records, because real `[CultGlobal]` records cannot be cloned to scale and C2a refuses the real types. Same harness code on main and C2a. + +Raw logs: `...\scratchpad\flush-measure\run1.log` (real data, main and pin), `run3.log` (synthetic main and C2a). `run2.log` is the failed first C2a attempt and is evidence of the registry refusal. Re-run: `tables.py` regenerates the tables below from the logs. + +## Tables + +## A. Real Aetheria stores (Aetheria.Shared types, CultLib main 6a20309d and Aetheria's pinned CultLib b8fc5eb) + +### A1. Load +| store | rev | process-cold load, ms (median of 5 fresh processes, JIT included) | first load after warm-up, ms | warm load, ms median (min-max, n=15) | dir-store warm load, ms | +|---|---|---|---|---|---| +| Aetheria.cc | main | 688 (659-716) | 695.56 | 5.42 (4.71-17.23) | 6.22 | +| Aetheria.cc | pin | 722 (676-781) | 634.87 | 6.35 (4.36-20.42) | 5.43 | +| ship.cc | main | 18 (17-33) | 28.11 | 0.18 (0.16-0.40) | 0.38 | +| ship.cc | pin | 17 (16-29) | 17.84 | 0.19 (0.17-0.37) | 0.36 | + +### A2. Single-file flush (disk = Yggdrasil container work dir on host disk; shm = /dev/shm tmpfs, fsync is free) +| store | rev | medium | changed | total ms | serialise ms | rest ms (lock, order, write, fsync, replace) | allocated MB | file bytes written | +|---|---|---|---|---|---|---|---|---| +| Aetheria.cc | main | disk | 1rec (n=1) | 5.97 | 1.85 | 3.72 | 2.56 | 214599 | +| Aetheria.cc | main | disk | 1pct (n=3) | 5.16 | 1.79 | 3.26 | 2.56 | 214750 | +| Aetheria.cc | main | disk | 10rec (n=10) | 5.01 | 1.75 | 2.98 | 2.56 | 214681 | +| Aetheria.cc | main | disk | 10pct (n=39) | 8.46 | 2.41 | 4.80 | 2.56 | 214757 | +| Aetheria.cc | main | shm | 1rec (n=1) | 2.65 | 1.06 | 1.14 | 2.54 | 214599 | +| Aetheria.cc | main | shm | 1pct (n=3) | 2.27 | 1.09 | 1.13 | 2.54 | 214750 | +| Aetheria.cc | main | shm | 10rec (n=10) | 2.83 | 1.11 | 1.17 | 2.54 | 214681 | +| Aetheria.cc | main | shm | 10pct (n=39) | 2.60 | 1.21 | 1.18 | 2.54 | 214757 | +| Aetheria.cc | pin | disk | 1rec (n=1) | 5.25 | 1.78 | 3.35 | 2.56 | 214599 | +| Aetheria.cc | pin | disk | 1pct (n=3) | 6.51 | 2.38 | 3.94 | 2.56 | 214750 | +| Aetheria.cc | pin | disk | 10rec (n=10) | 8.07 | 2.53 | 4.02 | 2.56 | 214681 | +| Aetheria.cc | pin | disk | 10pct (n=39) | 8.82 | 4.26 | 5.01 | 2.56 | 214757 | +| Aetheria.cc | pin | shm | 1rec (n=1) | 2.13 | 1.01 | 1.06 | 2.54 | 214599 | +| Aetheria.cc | pin | shm | 1pct (n=3) | 2.18 | 1.03 | 1.07 | 2.54 | 214750 | +| Aetheria.cc | pin | shm | 10rec (n=10) | 2.15 | 1.02 | 1.04 | 2.54 | 214681 | +| Aetheria.cc | pin | shm | 10pct (n=39) | 2.27 | 1.08 | 1.11 | 2.54 | 214757 | +| ship.cc | main | disk | 1pct (n=1) | 2.29 | 0.19 | 2.11 | 0.21 | 28642 | +| ship.cc | main | disk | 1rec (n=1) | 1.68 | 0.08 | 1.59 | 0.21 | 28642 | +| ship.cc | main | disk | 10pct (n=1) | 1.76 | 0.10 | 1.66 | 0.21 | 28642 | +| ship.cc | main | disk | 10rec (n=10) | 1.76 | 0.11 | 1.66 | 0.21 | 28641 | +| ship.cc | main | shm | 1pct (n=1) | 0.23 | 0.09 | 0.14 | 0.21 | 28642 | +| ship.cc | main | shm | 1rec (n=1) | 0.23 | 0.09 | 0.14 | 0.21 | 28642 | +| ship.cc | main | shm | 10pct (n=1) | 0.23 | 0.09 | 0.14 | 0.21 | 28642 | +| ship.cc | main | shm | 10rec (n=10) | 0.23 | 0.09 | 0.14 | 0.21 | 28641 | +| ship.cc | pin | disk | 1pct (n=1) | 1.96 | 0.18 | 1.78 | 0.21 | 28642 | +| ship.cc | pin | disk | 1rec (n=1) | 1.75 | 0.08 | 1.67 | 0.21 | 28642 | +| ship.cc | pin | disk | 10pct (n=1) | 1.77 | 0.10 | 1.62 | 0.21 | 28642 | +| ship.cc | pin | disk | 10rec (n=10) | 1.72 | 0.08 | 1.60 | 0.21 | 28641 | +| ship.cc | pin | shm | 1pct (n=1) | 0.17 | 0.06 | 0.11 | 0.21 | 28642 | +| ship.cc | pin | shm | 1rec (n=1) | 0.18 | 0.06 | 0.12 | 0.21 | 28642 | +| ship.cc | pin | shm | 10pct (n=1) | 0.18 | 0.06 | 0.12 | 0.21 | 28642 | +| ship.cc | pin | shm | 10rec (n=10) | 0.17 | 0.06 | 0.11 | 0.21 | 28641 | + +### A3. Throughput of 1-record change + flush (4 s loop) +| store | rev | medium | single-file cycles/s | single-file flush ms median | directory-store cycles/s | raw atomic write of same size, ms median | +|---|---|---|---|---|---|---| +| Aetheria.cc | main | disk | 180 | 5.41 | 129 | 1.795 | +| Aetheria.cc | main | shm | 297 | 2.36 | 264 | 0.115 | +| Aetheria.cc | pin | disk | 167 | 5.37 | 126 | 1.722 | +| Aetheria.cc | pin | shm | 275 | 2.55 | 263 | 0.119 | +| ship.cc | main | disk | 468 | 1.93 | 281 | 1.427 | +| ship.cc | main | shm | 4809 | 0.17 | 3393 | 0.043 | +| ship.cc | pin | disk | 497 | 1.71 | 355 | 1.458 | +| ship.cc | pin | shm | 4607 | 0.17 | 3347 | 0.053 | + +### A4. DirectoryMessagePackBackingStore, same records +| store | rev | medium | initial full write ms | files | bytes on disk | 1-record flush ms | 1-record bytes written (new+changed files) | files touched | 10-record flush ms | 10-record bytes | +|---|---|---|---|---|---|---|---|---|---|---| +| Aetheria.cc | main | disk | 564 | 396 | 284792 | 5.83 | 113484 | 2 | 20.98 | 117092 | +| Aetheria.cc | main | shm | 32 | 396 | 284791 | 3.24 | 113484 | 2 | 4.67 | 117094 | +| Aetheria.cc | pin | disk | 618 | 396 | 284788 | 8.18 | 113484 | 2 | 25.18 | 117093 | +| Aetheria.cc | pin | shm | 36 | 396 | 284781 | 2.63 | 113483 | 2 | 3.71 | 117092 | +| ship.cc | main | disk | 5 | 5 | 29235 | 3.58 | 11168 | 2 | 6.73 | 29235 | +| ship.cc | main | shm | 0 | 5 | 29234 | 0.25 | 1782 | 1 | 0.42 | 19849 | +| ship.cc | pin | disk | 5 | 5 | 29234 | 3.24 | 11167 | 2 | 5.40 | 29234 | +| ship.cc | pin | shm | 0 | 5 | 29235 | 0.23 | 1783 | 1 | 0.43 | 19850 | + +## B. Synthetic catalog scale sweep (SynthItem, about 570 B per record; main vs c2a; 1/10/10% of records changed) + +| records | medium | rev | file MB | load warm ms | 1-rec flush total / serialise ms | 10-rec flush total / serialise ms | 10% flush total / serialise ms | alloc MB per 1-rec flush | 1-rec cycles/s | dir 1-rec flush ms | dir 1-rec bytes written | dir 10-rec flush ms | dir cycles/s | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 394 | disk | main | 0.23 | 4.67 | 5.63 / 1.12 | 5.81 / 1.45 | 5.49 / 1.58 | 2.68 | 196 | 7.94 | 72655 | 20.30 | 148 | +| 394 | disk | c2a | 0.23 | 10.63 | 3.69 / 0.16 | 3.79 / 0.49 | 4.24 / 0.56 | 2.11 | 206 | 7.08 | 72655 | 24.59 | 141 | +| 394 | shm | main | 0.23 | 1.48 | 2.81 / 1.31 | 1.67 / 0.59 | 1.78 / 0.65 | 2.65 | 469 | 2.91 | 72655 | 3.13 | 339 | +| 394 | shm | c2a | 0.23 | 4.77 | 1.51 / 0.19 | 0.81 / 0.09 | 0.97 / 0.12 | 2.11 | 693 | 2.70 | 72655 | 3.32 | 321 | +| 3940 | disk | main | 2.25 | 27.34 | 31.84 / 13.48 | 30.11 / 12.92 | 34.20 / 12.55 | 29.43 | 35 | 38.83 | 700297 | 44.11 | 26 | +| 3940 | disk | c2a | 2.25 | 53.70 | 30.15 / 7.50 | 27.31 / 7.51 | 38.03 / 8.24 | 28.25 | 33 | 34.48 | 700297 | 46.68 | 25 | +| 3940 | shm | main | 2.25 | 16.78 | 27.11 / 11.22 | 28.50 / 11.86 | 29.08 / 11.50 | 29.43 | 34 | 36.00 | 700297 | 34.33 | 29 | +| 3940 | shm | c2a | 2.25 | 58.77 | 15.13 / 1.02 | 16.09 / 1.16 | 20.43 / 1.74 | 28.25 | 56 | 28.02 | 700297 | 37.51 | 30 | +| 39400 | disk | main | 22.67 | 251.42 | 319.89 / 127.74 | 317.56 / 130.91 | 373.04 / 154.01 | 268.25 | 3 | - | - | - | - | +| 39400 | disk | c2a | 22.67 | 591.61 | 283.51 / 74.10 | 335.84 / 79.71 | 383.73 / 85.22 | 252.89 | 3 | - | - | - | - | +| 39400 | shm | main | 22.67 | 248.32 | 299.82 / 131.95 | 326.44 / 137.70 | 369.78 / 167.95 | 268.25 | 3 | - | - | - | - | +| 39400 | shm | c2a | 22.67 | 653.54 | 281.16 / 70.06 | 280.78 / 72.06 | 363.50 / 84.19 | 252.89 | 3 | - | - | - | - | + +## What the numbers say + +- Flat in changed records, linear in total records. One record changed costs what ten percent changed costs, because `PushAll` serialises every entry and writes the whole file (`SingleFileBackingStore.PushAll` in `CultCache.cs`). On the real catalog the work per flush is 394 payload serialisations and a 214 KB write for a 600-byte edit. +- At the real catalog's size the cost is small: 5.5 ms on a congested container disk, 2.5 ms in memory, 1.7 ms for the 3-record mod store (0.2 ms in memory). An editor saving one record never notices it. The case for paging is not this catalog today. +- A 30 Hz writer (33.3 ms frame) spends about 16 percent of the frame on the real catalog on this disk, 7 percent with I/O free. The knee is the record count: 3,940 records already take 30 to 32 ms (a whole frame, 33 per second sustained), 39,400 take 300 ms. The same holds in memory (27 and 300 ms), so a faster disk does not move the knee; record assembly and serialisation do. +- The existing directory store is not the paged store. It is not cheaper per flush (manifest rewritten whole, 0.3 to 0.5 of the single-file bytes, same milliseconds), costs 396 files and 285 KB for the catalog, and its first full write took 560 to 620 ms on disk against about 70 ms for the single file. +- C2a's per-record byte reuse removes most of the serialise cost, not the O(records) flush. Whole-file writes and per-flush record assembly remain; at 39,400 records the total moved from 320 to 284 ms on disk (shm 300 to 281). Load got slower (394 records 4.7 to 10.6 ms; 39,400 records 251 to 592 ms). That load cost is the price of the format that C2a introduces and is worth a look separately. +- C2a is a breaking change for the game's catalog: every list-of-objects member needs an element id member, and the existing stores have none. That is a migration question for the C2a cut, not a flush number. + +## Caveats + +- Container noise: one shared, niced, CPU-capped job among up to five; medians of 15 (8 at 39,400 records) with wide max values. Treat differences under about 20 percent as noise (main against pin at the same size are the control: they agree within that). +- Process-cold load of Aetheria.cc is about 690 ms, almost all JIT and registry/type discovery; the file part is the 5 ms warm figure. +- C2a figures are synthetic, one record shape, one run per medium. Main and C2a synthetic runs came from one job, one after the other. +- Allocation is bytes allocated per flush, not peak working set. +- A real-type scale sweep failed (`[CultGlobal]` records cannot be duplicated: "aetheria.verse_grammar is a global and already has record"); the synthetic sweep replaces it. diff --git a/research/paged-store/prior-art-2026-10-27.md b/research/paged-store/prior-art-2026-10-27.md new file mode 100644 index 00000000..03cc860e --- /dev/null +++ b/research/paged-store/prior-art-2026-10-27.md @@ -0,0 +1,146 @@ +# Prior art: paged / log-structured single-file stores for CultCache + +Serves ruling `variants:ruling:page-tree-store-research` (Operator 2026-10-27: prior-art pass and flush measurement; "the page-tree preference stands"; whether and when to build is decided on the research, after C2a and the AOT cuts) and follow-up `variants:follow_up:paged-single-file-store`. Read with ruling `cultlib-gaps:ruling:database-shaped-built-for-load` (database-shaped things are built for unreasonable load). +Facts with evidence pointers; no recommendation. Tags: VERIFIED (read in the fetched source or code this session), SECONDARY (search-result summary or third party), UNVERIFIED (from memory or inference, not read). +Gathered 2026-10-27 by Eyes. Tool note: the fetch tool summarises through a small model, so numbers are VERIFIED only where I also read the text (PDFs were converted with pdftotext and grepped). + +## 0. CultLib today (code, not research) + +- Single-file write path: `SingleFileBackingStore` ... `WriteSnapshotAtomically(FileInfo.FullName, SerializeStore(format, catalog, fileRecords))` at `src\GameCult.Caching\CultCache.cs` :4522 on branch `hands/variants-c2a`; the helper at :4831 writes `.{name}.{guid}.tmp` with `FileOptions.WriteThrough`, `Flush(true)` (one fsync of the new file), then `File.Replace` or `File.Move`. VERIFIED (read). Write cost per flush is the whole file plus one fsync; no directory fsync appears in that helper. VERIFIED. +- cultcache-rs already ships a redb-backed store: `RedbMessagePackBackingStore` and `OwnedRedbMessagePackBackingStore`, `packages\cultcache-rs\src\lib.rs` :1596-1660, :1975, :2348, :2483; `redb = "4.1.0"` in its `Cargo.toml` :21. Each polymorphic `(type, key)` is one redb row whose value is the MessagePack of its `CultCacheEnvelope`; one table `cultcache_envelopes`; opens a fresh redb handle per operation under the CultCache external lock because redb permits one writable handle per path. VERIFIED (read). Other runtimes have no reader for that file (no redb in C#/TS/Python): UNVERIFIED by search, but nothing in `src\GameCult.Caching` references redb. +- Kept research to cite, not repeat: `docs\research\2026-10-23-store-lock-user-code-prior-art.md` (user code under a store lock; names LMDB one-txn-per-thread and SQLite btmutex); `docs\research\cross-process-file-locking-prior-art.md` (sidecar `.lock`, atomic replace per runtime); `docs\research\windows-store-replace-prior-art.md` (Windows `ReplaceFile` lets lock-free readers see an empty store: 19% of opens on .NET 10, 0 with `MoveFileExW`); `docs\research\2026-10-23-schema-drift-write-back-prior-art.md`; `research\store-writes-behind-cache\prior-art-2026-10-27.md` (redb `DatabaseAlreadyOpen`, second handle refusal, section B). The trigger (a whole-view flush re-asserting records a writer does not own) is not covered by any of them. +- Size check: the ruling text says Aetheria.cc is 12.7 MB. The file at `F:\Projects\Aetheria\GameData\Aetheria.cc` in the operator checkout is 195,939 bytes. VERIFIED (ls). Not explained; the 12.7 MB figure may be another file or an LFS-resolved copy. Flush measurement not done here. + +## 1. Page-tree single-file stores + +### 1.1 SQLite (pager + B-tree) + +- Page size: power of two 512..65536, one size per file, 2-byte field at offset 16; header integers big-endian; magic `"SQLite format 3\000"`. VERIFIED (sqlite.org/fileformat2.html). +- Table b-trees: 64-bit rowid key, data in leaves; index b-trees: arbitrary keys. Overflow: payload over threshold X (table leaf X = U-35) spills into a linked list of overflow pages, each starting with a 4-byte next-page number; local bytes M = ((U-12)*32/255)-23 minimum. Freelist: trunk pages listing leaf page numbers. VERIFIED (fileformat2). +- Rollback journal commit (synchronous=FULL): original page images to `-journal`, fsync; page count written into journal header, fsync again; changed pages written to the db file, fsync; delete (or zero/truncate) the journal = commit. Three flush points, plus directory sync on Unix for journal creation. NORMAL: one journal flush, protected by a per-page 32-bit checksum. Hot-journal recovery rolls back under an exclusive lock. VERIFIED (sqlite.org/atomiccommit.html). Assumptions listed there: 512-byte sector writes not atomic, linear writes, powersafe overwrite, atomic file delete, fsync honoured. +- WAL: commits append to `-wal`; FULL syncs the WAL on every commit, NORMAL only at checkpoint; auto-checkpoint at 1000 pages (about 4 MB at default page size) copies pages back, syncing WAL then db; one writer; readers see an end-mark snapshot; checkpoint starvation lets the WAL grow without bound; read cost grows with WAL size; the wal-index is an mmap'd `-shm` file, so all processes must be on one host and no network FS; page size cannot change in WAL mode; WAL transactions beyond about 100 MB are often faster in rollback mode (statement predates 3.11). VERIFIED (sqlite.org/wal.html). +- One-record update cost: root-to-leaf path pages (each a full page) written once to journal and once to db (rollback) or once to WAL plus once at checkpoint (WAL). Inferred from the above; UNVERIFIED as a measurement. +- Blob storage vs files: at about 10 KB blobs, 4 KiB pages, SQLite read about 35% faster than separate files and used about 20% less space; blob sizes 100 KB and 1 MB not measured on that page; the page cites Gray et al. (SQL Server) that database blob reads win below roughly 250 KiB to 1 MiB. VERIFIED (sqlite.org/fasterthanfs.html). +- Format stability: every release reads and writes files from 3.0.0 (2004); files portable across 32/64-bit and endianness; support planned to 2050; Library of Congress lists it as a preservation format. VERIFIED (sqlite.org/onefile.html, lts.html). +- Size: library about 155.8 KSLOC of C (3.42.0); test code about 590 times that; crash testing by a VFS that reorders and corrupts unsynced writes and by whole-filesystem snapshot replay. VERIFIED (sqlite.org/testing.html). +- Multi-process: file locks, one writer at a time in either journal mode. VERIFIED (wal.html, atomiccommit.html). + +### 1.2 LMDB (copy-on-write B+tree, mmap) + +- Design statement: copy-on-write pages, no WAL, no crash-recovery procedure, no compaction; one write transaction at a time, readers lock-free MVCC; freed pages tracked and reused; file bounded in normal use. VERIFIED (lmdb.h top-of-file). +- Two meta pages (`NUM_METAS 2`); commit writes dirty pages, fdatasync (`mdb_env_sync0`), then pwrite of the meta page body through a separately opened O_DSYNC handle (`me_mfd`), so the meta write syncs itself. Two sync points per commit; `MDB_NOMETASYNC` / `MDB_NOSYNC` drop them. VERIFIED (`mdb.c` :4205-4225, :4385-4470, from raw.githubusercontent.com/LMDB/lmdb/mdb.master; 11,552 lines in `mdb.c`). +- Large values: `P_OVERFLOW` pages are contiguous runs recorded by `mp_pages`; comment at :658-664 about the threshold at which items go into overflow pages. VERIFIED (mdb.c). Key limit 511 bytes (`MDB_MAXKEYSIZE`). VERIFIED (mdb.c :701). +- Caveats from the header: long-lived read transactions block page reuse and the file grows; stale readers left by aborted processes need `mdb_reader_check`; do not use on network filesystems; do not use an env after `fork()`; do not open the same db twice in one process (fcntl locks); one transaction per thread; readers need write access to the lock file. VERIFIED (lmdb.h). +- Portability: structs are native layout; the magic number "will reflect any byte order mismatches" (`mdb.c` :671); data version constant `MDB_DATA_VERSION 1`; TensorFlow docs say files differ between big- and little-endian hosts and an LMDB developer says 32- and 64-bit files are incompatible by default. VERIFIED for the magic comment, SECONDARY for the endianness and bitness statements (search snippets). +- Concurrency across processes: shared lock file with a reader table; single writer via lock-file mutex. VERIFIED (header caveats). + +### 1.3 redb (Rust, copy-on-write B+tree) + +- Layout: 512-byte super-header (64-byte db header plus two 128-byte commit slots), regions with buddy allocator pages (`PAGE_SIZE` constant 4096 in `header.rs` :85), little-endian integers; XXH3-128 checksums in a Merkle arrangement (each branch stores child checksums). VERIFIED (docs/design.md; header.rs). +- Commit: the "god byte" flips the primary slot. Default 1-phase-plus-checksum: write data and checksums under a new higher txn id, flip, one fsync; recovery keeps the primary if txn id is newer and checksums validate, else falls back to the secondary slot. 2-phase: fsync data, flip, fsync again (no reliance on checksums; design.md warns XXH3 is not collision resistant against a deliberate attacker). Non-durable commits kept in memory. VERIFIED (design.md). +- Free pages: pending-free until no reader can reference them; format v3 stores them in system tables; quick-repair saves allocator state each commit, otherwise full repair walks the trees. VERIFIED (design.md). +- Large values: `MAX_VALUE_LENGTH = 3 GiB` (`page_store/base.rs` :17); pages of higher order are power-of-two multiples of the base page; design.md does not say how an oversized value is stored. VERIFIED for the constant, UNVERIFIED for the mechanism (I did not read btree_base.rs). +- Multi-process: three modes (exclusive writer, single writer with read-only readers, multi-writer) using byte-range file locks; non-durable commits unavailable in multi-process modes; second writable handle refused by default. VERIFIED (design.md; README features "Multi-process access to one database, with a single or several writer processes"). +- Format stability: README "file format is stable, and a reasonable effort will be made to provide an upgrade path"; versions 1 to 3 exist. VERIFIED. Pure Rust only (`no_std` build supported with a `StorageBackend` trait). VERIFIED (README). +- Size: `src` is 41,882 lines of Rust at `16c7107` (2026-10-25), of which `tree_store/page_store` is 12,606. VERIFIED (counted from a shallow clone; includes comments and in-file tests). + +### 1.4 bbolt (Go, derived from Bolt) + +- B+tree, mmap, copy-on-write, one read-write transaction at a time, MVCC readers; README says the code base is "<5KLOC". VERIFIED (README). Non-test Go under the repo excluding `cmd` and `tests` is 8,232 lines at `a4ef197` (2026-10-16). VERIFIED (counted). +- Commit: dirty pages written and fsynced, then a new meta page with incremented txn id written and fsynced (two fsyncs); meta pages carry checksums. VERIFIED (README). +- Caveats: files cannot shrink (free list reuse only); long read transactions block reclamation; a single exclusive file lock so no multi-process writers; endian-specific files because structures are memory-mapped; random writes slow, `Batch()` or a WAL suggested; mmap problematic on 32-bit. VERIFIED (README). +- Overflow: `Page.Overflow()` / `SetOverflow` in `internal/common/page.go` :206-210, large values span contiguous pages. VERIFIED that the field exists; the mechanism is UNVERIFIED beyond that. + +## 2. B-epsilon trees and write-optimised designs + +- Structure: B-tree whose internal nodes use B^e slots for pivots and the rest for a message buffer; messages (insert, delete tombstone, upsert) sorted by key then timestamp; flushing a full buffer moves messages one level down. e=1 is a B-tree; e=0 is a buffered repository tree. VERIFIED (Bender et al., ";login:" Oct 2015, usenix.org/system/files/login/articles/login_oct15_05_bender.pdf, read via pdftotext). +- Cost: batching divides insert cost by B^(1-e); with B=1024 and e=1/2 the article gives 16 times faster inserts than a B-tree; point-query cost has the same asymptotics as a B-tree (the article says queries "are no faster than in a B-tree"); nodes of a few hundred KB to a few MB are practical because insert bandwidth cost grows slowly with B. VERIFIED (same). +- Upserts: a message encoding "read-modify-write without the read"; applied at flush or on a query that finds the message in transit. LSM upserts defeat Bloom filters because a pending message may sit in every level. VERIFIED (same). +- Versus LSM: article claims Bloom-filter LSMs match B^e-trees for some workloads, B^e-trees asymptotically dominate for small range queries and upsert-heavy point queries. VERIFIED (same; it is an authors' claim). +- Measured: BetrFS (TokuDB underneath), 3 million 200-byte file creates: steady-state an order of magnitude faster than ext4/XFS/Btrfs/ZFS, 4 to 10 times fewer bytes written. Small random writes into a large file: "almost two orders of magnitude" better; TokuDB node typically 4 MB. VERIFIED (same article; BetrFS FAST'15 text cs.unc.edu/~porter/pubs/fast15-final.pdf, grep hits at lines 416, 643). Sequential large-file I/O is a place BetrFS is not ahead; the paper lists "few slower operations". SECONDARY (journal abstract snippet: "requires additional data-structure tuning to match ... on some operations"). +- PerconaFT/TokuDB: node-size setting default 4 MiB (in-memory size; compressed on disk), leaves split into 128 KB default basement nodes (read block) so a point query reads a basement node rather than a 4 MB leaf; buffers serialised to disk with per-message sequence numbers. SECONDARY (Percona blog, slides, docs mirror; the PerconaFT source not read). TokuDB is deprecated in Percona Server (UNVERIFIED; not checked). +- The regime where the sources place B-trees ahead: queries (no asymptotic gain for the B^e-tree), small data that fits in cache, and read-modify-write patterns bound by the query. VERIFIED as the article's statements ("Avoid query-before-update"); no source read gives a data-size threshold below which the gain vanishes. + +## 3. Log-structured alternatives + +### 3.1 LSM (LevelDB, RocksDB, WiscKey) + +- LevelDB: writes go to a log, then memtable, then a level-0 table at about 4 MB; level L limit 10^L MB; level-0 compacts at 4 files; output files about 2 MB; CURRENT names the latest MANIFEST (a log of file-set edits); recovery converts the leftover log into a level-0 table. The page does not state fsync behaviour. VERIFIED (github.com/google/leveldb doc/impl.md). +- Amplification (WiscKey paper, read via pdftotext): LevelDB write amplification 3.1 at 1 GB and 14 at 100 GB (16-byte keys, 1 KB values); worst case over 50; read amplification 8.2 at 1 GB and 327 at 100 GB; "I/O amplification in typical LSM-trees can reach a factor of 50x or higher". VERIFIED (Lu et al., FAST'16). +- RocksDB tuning guide: leveled multiplier 10; example write amplification about 33; space amplification about 1.14 for leveled; universal compaction trades lower write amplification for higher read and always higher space amplification (default 200% max size amplification), advised against above about 100 GB. VERIFIED (RocksDB wiki). +- WiscKey (key-value separation): keys in the LSM, values in an append-only value log with (key size, value size, key, value) tuples; effective write amplification worked example (10*16+1024)/(16+1024) = 1.14; GC reads from the tail and rewrites live values at the head, fsyncs the vLog, then updates the tree; crash consistency relies on appended bytes surviving as a prefix; range scans pay random reads and lose to LevelDB for small random-order values read sequentially. VERIFIED (same paper). +- Multi-process: LevelDB takes a LOCK file for one process (UNVERIFIED, impl.md lists LOCK only as a file name). + +### 3.2 Bitcask + +- A directory; one OS process writes; one active append-only data file rolled at a size threshold; records of CRC, timestamp, key size, value size, key, value (the field list is in a diagram I could not read; VERIFIED only that entries are a "linear sequence"). Tombstone value for delete. VERIFIED (Basho 2010 intro, riak.com/assets/bitcask-intro.pdf). +- In-memory keydir: hash map from every key to file id, offset, size; whole keydir must fit in RAM ("well under a GB" for many millions of keys); one disk seek per read. VERIFIED. +- Merge rewrites the immutable files keeping latest versions and writes hint files (key plus position and size, no values) so startup scans hints instead of data. Sync-on-put is an option. VERIFIED. +- Space reclaim only at merge; no compression. VERIFIED. + +### 3.3 Append-only B-tree files (CouchDB / couchstore) + +- Updates appended, never overwritten; commit is two steps: data and index flushed, then the header written as two consecutive identical copies in the first 4 KB and flushed (two fsync points). VERIFIED for the docs.couchdb.org description of the two steps. A Couchbase bug report (MB-10280) describes extending the file before the first fsync so the second can be an `fdatasync`. SECONDARY. +- couchstore format notes: header re-written at the tail on each change; 4096-byte blocks so the header can be located; headers larger than 4090 bytes split across blocks with a prefix byte. SECONDARY (search snippets of couchstore file_format.md and a couch_file commit message). +- Reclaim: compaction copies live data into a new file and swaps. VERIFIED (docs.couchdb.org). + +### 3.4 Single-file log with periodic compaction + +- No separate system read. The Bitcask hint-file idea and WiscKey's vLog/GC are the closest sources above. SQLite WAL is a bounded log with checkpoint (section 1.1). rkv "SafeMode" (Mozilla) keeps the whole database in memory and writes transactions synchronously to disk on commit; the sources do not say the file is rewritten whole. SECONDARY (rkv README, docs.rs snippets). + +## 4. Per-family summary + +Crash mechanism and syncs per commit: + +| Family | Mechanism | Sync points per commit | +|---|---|---| +| Whole-file replace (CultLib today) | temp file + rename/replace | 1 data fsync (Flush(true)); no dir fsync in the C# helper. VERIFIED (code) | +| SQLite rollback journal | journal + hot-journal rollback | 3 (FULL), 2 (NORMAL). VERIFIED | +| SQLite WAL | append, checkpoint later | 1 per commit on FULL, 0 on NORMAL, plus checkpoint syncs. VERIFIED | +| LMDB | CoW + alternating meta pages | 2 (data fdatasync, then meta via O_DSYNC fd). VERIFIED | +| bbolt | CoW + checksummed meta | 2. VERIFIED | +| redb | CoW + god-byte slot flip | 1 (1PC+checksum, default) or 2 (2PC). VERIFIED | +| LSM | WAL + memtable + sorted tables + manifest | WAL sync optional per write; manifest edits on compaction. VERIFIED for structure; WAL sync policy not stated in impl.md | +| Bitcask | append + CRC | optional sync-on-put. VERIFIED | +| CouchDB | append + dual header | 2. VERIFIED | + +Bytes written for a one-record update (all inferred from the page/tree structure; none measured here, UNVERIFIED as numbers): +- Whole-file replace: the whole file, every flush. +- CoW B-tree (LMDB, bbolt, redb): one new page per tree level from leaf to root (at 4 KiB pages, the tree depth times 4 KiB) plus the meta slot; larger record adds its overflow pages. +- SQLite: same path of pages, written twice (journal then db, or WAL then checkpoint). +- B^e-tree: one message into the root buffer, amortised flush cost. +- LSM: one WAL record, with 3 to 14 write amplification for the compaction later (measured on LevelDB, 1 GB and 100 GB respectively). VERIFIED (WiscKey). +- Bitcask/CouchDB-style append: the record (CouchDB adds tree path nodes plus the header). + +Read path: B+tree families root-to-leaf, with mmap giving pointers into the file (LMDB, bbolt) or an internal page cache (redb, SQLite). LSM checks memtable then up to 14 files worst case (LevelDB, WiscKey §2.3). Bitcask is one seek via the in-memory keydir. VERIFIED per family above. + +Space reclaim: LMDB and redb reuse freed pages once no reader holds them; bbolt reuses but cannot truncate; SQLite has a freelist plus VACUUM (VACUUM not read); LSM and Bitcask and CouchDB reclaim only by compaction/merge rewriting data; LevelDB leveled space amplification about 1.14 by RocksDB's example and universal compaction can need up to three times the data temporarily. VERIFIED except VACUUM (UNVERIFIED). + +Multi-process / multi-writer: LMDB shared lock file, single writer, caveats around stale readers; bbolt exclusive file lock, no multi-process writers; redb three modes via byte-range locks; SQLite file locks, one writer, WAL needs same-host shared memory; Bitcask one writer process per directory; CouchDB serialises updates in one server. None of these give a real multi-writer on one file except redb's multi-writer mode, which requires quick-repair on each write. VERIFIED (design.md). + +Variable-size values: SQLite overflow page chains (4-byte next pointer); LMDB contiguous overflow runs; bbolt contiguous overflow count on the page header; redb higher-order (power-of-two-sized) pages up to a 3 GiB value cap; B^e-trees use nodes of 0.25 to 4 MB with 128 KB basement nodes; LSM puts values in sorted tables (WiscKey moves values above roughly 1 KB to a vLog); Bitcask and CouchDB write values inline in the log. VERIFIED for SQLite, LMDB, redb cap, WiscKey; UNVERIFIED for bbolt and redb mechanism beyond the field names. + +Mechanism size, small faithful implementations: bbolt about 5.6K lines root non-test Go (8.2K with internal packages); redb 41.9K lines total, 12.6K in `page_store`; LMDB `mdb.c` 11.6K lines of C; SQLite 155.8 KSLOC; LevelDB and RocksDB not counted. VERIFIED for counts stated; each counts differ in whether comments, tests and CLI are included. + +## 5. Cross-runtime, one on-disk format + +- SQLite: parity achieved by one C implementation embedded or wrapped in other languages, with a documented, big-endian, frozen format; the lts.html page does not itself name the C implementation as reference. VERIFIED (fileformat2, lts.html). Independent re-implementations I found: SQLJet (pure Java, reads and writes SQLite files, low-level API, no SQL execution beyond the schema, GPL with a commercial licence; Debian/Fedora packages at 1.1.10, upstream activity unclear) and Turso/Limbo (Rust rewrite; announcement says compatible at the language and file-format level; status labels conflict between sources). SECONDARY (search snippets). Their completeness was not tested. +- LMDB: bindings wrap the C library (lmdb-js, heed, Lightning.NET, lmdb-go and others named in the search; the search returned no pure re-implementation). SECONDARY/UNVERIFIED. Native-endian, pointer-sized structures make files non-portable across architectures. VERIFIED (mdb.c comment) and SECONDARY (TensorFlow docs, PowerDNS thread). +- bbolt: endian-specific because structures are mmap'd (README). VERIFIED. Pure Go only. +- redb: little-endian explicit; Rust only; `no_std` backend trait. VERIFIED. No other-language reader found. UNVERIFIED (negative search). +- LevelDB: its table format has a short spec (`doc/table_format.md`: fixed footer with magic `0xdb4775248b80fb57`, varint block handles, index and metaindex blocks) sufficient, in the fetch summariser's words, to implement a reader from the spec. Independent implementations named by search: syndtr/goleveldb (pure Go), kezhuw/leveldb (claims format conformance, "not production", last pushed 2020). On-disk compatibility of goleveldb with LevelDB was not confirmed by the search. VERIFIED for the spec content; SECONDARY for the ports. +- CouchDB/couchstore: Couchbase notes say its format is similar to but not identical to CouchDB 1.2's; i.e. two implementations diverged. SECONDARY. +- Reported hardness of parity (from the sources, not from any parity post-mortem I found): native struct layout and endianness (LMDB, bbolt); a format defined by one implementation's code with the spec secondary; checksums and crash recovery logic that must match, not just the layout (redb god byte plus txn id plus checksum rules; SQLite sector and hot-journal rules); platform persistence properties (Pillai et al. OSDI'14 find 60 crash vulnerabilities across 11 applications including LevelDB, LMDB, SQLite, PostgreSQL and ordering/atomicity assumptions that differ by file system; VERIFIED, read via pdftotext). I found no document on porting a page-tree engine across four runtimes with a parity test suite. UNVERIFIED (negative search). +- mmap: CMU CIDR 2022 "Are You Sure You Want to Use MMAP in Your DBMS?" (db.cs.cmu.edu/mmap-cidr2022/): authors argue mmap raises correctness and performance problems and several systems moved away from it after "significant engineering costs"; the page excerpt names no systems or four-way list. VERIFIED for those statements only. Python, TypeScript and C# each have different mmap availability (UNVERIFIED, not researched). + +## 6. Published comparisons of whole-file rewrite vs paged/log stores at 10-100 MB + +- None found. Searches for a benchmark of atomic whole-file replacement against SQLite or an LSM at 1, 10 and 100 MB returned nothing relevant; the closest are a SQLite forum thread where a maintainer says changing part of a JSON value means rewriting the whole row (SECONDARY), a VLDB paper on write amplification of small random SQLite updates on mobile flash (SECONDARY, vldb.org/pvldb/vol8/p1454-oh.pdf, not read), and the SQLite 10 KB blob benchmark (section 1.1). Mozilla's rkv SafeMode is a deployed in-memory store with synchronous writes on commit (section 3.4) but no published rewrite-vs-paged measurement was found. +- Measurement is therefore ours to take: whole-view flush cost on a real store (see the follow-up's own measurement item and section 0 size discrepancy). + +## 7. Not done + +- WiscKey, BetrFS, Bender, Bitcask and Pillai were read via pdftotext; BetrFS only grepped. SQLite pages were read through the fetch summariser. +- redb `btree_base.rs` large-value path, bbolt `tx.go` commit, PerconaFT source, RocksDB WAL sync documentation, SQLite VACUUM and incremental autovacuum, Windows-specific fsync and mmap behaviour for LMDB and bbolt, and the VLDB mobile-SQLite paper were not read. +- No code was run and nothing was benchmarked. +- Shallow clones used for line counts are in the session scratchpad (`...\scratchpad\repos\redb`, `...\bbolt`), not in the repo. diff --git a/research/store-writes-behind-cache/prior-art-2026-10-27.md b/research/store-writes-behind-cache/prior-art-2026-10-27.md new file mode 100644 index 00000000..b0ea2598 --- /dev/null +++ b/research/store-writes-behind-cache/prior-art-2026-10-27.md @@ -0,0 +1,72 @@ +# Prior art: direct writes to a store that a cache sits in front of + +Serves fork `variants:follow_up:public-store-writes-behind-cache` (campaign `variants`, session `self-2026-10-27-eureka`). +Facts with evidence pointers; no recommendation. Status tags: VERIFIED (read in the fetched source this session), SECONDARY (search-result summary or third-party), UNVERIFIED (not read). + +## A. Code today (worktree `C:\cl-wt\c2a-land`, branch hands/variants-c2a, HEAD 193cf1c3). File: `src\GameCult.Caching\CultCache.cs` unless noted + +1. Public abstract surface of `CacheBackingStore`: `Push` :3717, `Delete` :3718, `PushAll` :3719, `CommitBatch` :3732 (all `public abstract`). `PullAll` :3716. `IsDirty` public getter, protected setter :3576. +2. Cache-routed writes reach the store through the same methods: upsert/variant `home?.Push(docs[0])` :2387, :2401, :2666; `Remove` `home?.Delete(existing)` :2425; batch `home.CommitBatch(request, wait)` :2726; flush `store.PushAll()` :2347 (`FlushAllBackingStores`). All admissions go through `Admit(...)` (~:2750), which validates, then calls the `land` lambda. +3. A direct store call is not refused when attached. `Gate` :3656 / `Held` :3687 only chooses which lock to take: attached, the call runs under the cache's gate (comment :3647-3655); detached, under the store's gate. Only the cacheless writes refuse: `WriteRecord` :4354 and `WriteDocument` :4378 throw `AttachedRefusal` (const :3600, text "The store is attached to a cache, write through the cache.") via `Gate(..., cacheless:true)`. `Push/Delete/PushAll/CommitBatch` pass `cacheless:false`. +4. What a direct `Push` does (:4224-4232): under the gate, `Entries[key] = entry` (the store's own dictionary, :3571, separate from the cache's `_entries`), `_staged.Add(key)`, `IsDirty = true`. `Delete` :4235 mirrors it. Nothing is handed to the cache: the cache's `_entries` and change feed are untouched. (c2a-land-map.md:284-290: Soul s4 probe `other-thread-push`, file `a,b`, cache `a`.) +5. Effects on later operations: + - `PushAll` :4251 / `CommitBatch` :4276 write the store's `_staged` keys read from the store's `Entries` (`StagedWrites`, ~:4300), so a directly staged record is written to disk with the cache's next flush or batch; then store `Wrote` :4661 updates store entries and calls `Cache?.Wrote` (:2081), which only updates versions of keys the cache already holds (skips others). + - `PullAllCore` :4170-4175 returns early `if (IsDirty)` (comment: pulling with staged mutations would let an older snapshot erase them). While a direct stage is pending, a cache pull of that store is a no-op. + - Conditional batch: :2724 throws if `home.IsDirty` ("has staged writes; flush before a conditional commit"). + - Attach: :2273 refuses a dirty store ("attach it clean"). +6. A direct `PushAll`/`CommitBatch` writes the file under the file lock with the store's staged set plus the batch (:4276-4296); `CommitBatch` sets store `Entries` from `request.Upserts/Deletes` (:4290-4293) without telling the cache. +7. Lock order comment: a cache's gate, then a store's (:3706). `InStoreHold` :2830 refuses a second store's gate inside a hold. + +## B. Overlap with kept research (cite, do not repeat) + +- `F:\Projects\CultLib\docs\research\2026-10-23-store-lock-user-code-prior-art.md` (commit 7b1cd00c): user code under a lock (JCIP ch.10, Sutter, lockdep, ConcurrentHashMap re-check, LMDB one txn per thread, SQLite btmutex). Bears on why the store gate refuses re-entry; says nothing about cache/store write routing. +- `F:\Projects\CultLib\docs\research\2026-10-23-schema-drift-write-back-prior-art.md` (commit 217a9480): old-schema write-back (Avro, protobuf, Schema Registry, Mongo/Realm/Core Data/EF Core stored versions). Not about identity-map coherence. +- `F:\Projects\CultLib\docs\research\windows-store-replace-prior-art.md`, `cross-process-file-locking-prior-art.md`: file-level lock/replace; not read in full. A grep for "identity map|write-through|ExecuteUpdate|stale cache" over docs\research and research\ found no hits. +- `F:\Projects\CultLib\research\cultnet-distributed-database\summary.md` (+ dynamo, firebase offline, rethinkdb changefeeds) and `research\witness-authoritative-networking\prior-art-2026-10-26.md`: multi-writer/replication; only listed, not read this session. + +## C. Established systems + +### C1. ORMs / identity maps + +- **Hibernate / JPA bulk HQL and native SQL.** Bulk UPDATE/DELETE runs in the database and does not touch loaded entities; per the JPA spec text relayed by in.relation.to, bulk operations "may result in inconsistencies between the database and the entities in the active persistence context"; advice: run them first or in a separate transaction. Hibernate invalidates the affected second-level cache regions after a bulk op (in.relation.to). Manual escape hatches exist (refresh, evict/clear: user guide TOC sections 6.11, 6.14, 14.7.1; section bodies not read, page truncated). Known issues: HHH-15247 (2nd-level collection cache not invalidated after HQL delete); Red Hat KB 5589111 (native update invalidated unrelated query-cache results); empty query space means whole-cache invalidation (blog, SECONDARY). Policy: hazard documented, manual refresh/clear provided, L2 cache invalidated automatically. + Sources: https://in.relation.to/2005/07/20/bulk-operations/ (VERIFIED, short), https://hibernate.atlassian.net/browse/HHH-15247 (SECONDARY), https://access.redhat.com/solutions/5589111 (SECONDARY). +- **EF Core `ExecuteUpdate`/`ExecuteDelete`.** Documented as "completely unaware of EF's change tracker, and have no interaction with it whatsoever"; example: a tracked entity with a stale value is later saved by `SaveChanges` and "the change performed by ExecuteUpdate is overwritten". Guidance: "usually a good idea to avoid mixing" them; no implicit transaction; no automatic concurrency check, rows-affected returned for manual checks. Policy: separate API surface, hazard documented, no coherence. VERIFIED: https://learn.microsoft.com/en-us/ef/core/saving/execute-insert-update-delete +- **SQLAlchemy 2.0 ORM bulk UPDATE/DELETE with WHERE.** `synchronize_session`: default `'auto'` (`'fetch'` where RETURNING works, else `'evaluate'`); `'fetch'` finds affected rows and refreshes or expunges matching in-session objects; `'evaluate'` evaluates the WHERE in Python (can fail on complex criteria); `False` leaves session state unchanged. Legacy session bulk methods lack session synchronization. Policy: the write path itself takes a sync mode, default synchronizes. VERIFIED: https://docs.sqlalchemy.org/en/20/orm/queryguide/dml.html (page did not state how newer bulk insert/update-by-PK treat the identity map). + +### C2. Embedded stores + +- **SQLite.** Cache is per connection; coherence by a file change counter at header offset 24 that other processes monitor ("a process that sees the counter change should flush its page cache"); in WAL mode detection is via the wal-index instead. Automatic, not caller-managed. Shared-cache mode (one cache across a process's connections) is called "an obsolete feature" on the project's page; WAL recommended. VERIFIED: https://sqlite.org/fileformat2.html, https://sqlite.org/sharedcache.html +- **redb.** Default `ConcurrencyMode::ExclusiveWriter`: a writer "excludes every other process" by whole-file lock; a second open for write fails with `DatabaseError::DatabaseAlreadyOpen`; `ReadOnlyDatabase` may not coexist with a `Database` in that mode; one write transaction at a time (`begin_write` blocks); page cache internal (`set_cache_size`, `cache_stats`). Policy: forbid by refusing the second handle. VERIFIED (first 100k of 190k chars of src/db.rs): https://raw.githubusercontent.com/cberner/redb/master/src/db.rs +- **LMDB.** No user-space cache (mmap). Header warns against opening one environment twice in a process ("close()ing it breaks fcntl() advisory locking"); do not mix MDB_WRITEMAP and non-WRITEMAP processes; with MDB_NOLOCK "the caller must manage all concurrency itself"; another process growing the map surfaces as MDB_MAP_RESIZED at txn begin. Policy: document the hazard, forbid some mixes. VERIFIED: https://raw.githubusercontent.com/LMDB/lmdb/mdb.master/libraries/liblmdb/lmdb.h +- **RocksDB.** A secondary instance sees primary writes only by manual `TryCatchUpWithPrimary()` (applies MANIFEST changes, rebuilds memtables from WAL); "lags until the next manual catch-up call"; writes with WAL disabled are invisible; read-only instances never catch up; page does not address caches or the LOCK file. Policy: second reader is explicit, read-only, stale by design. VERIFIED: https://github.com/facebook/rocksdb/wiki/Read-only-and-Secondary-instances + +### C3. Write-through / write-behind caches (the cache is the front door) + +- **Caffeine.** `CacheWriter` deprecated in 2.9, removed in 3.0 in favor of `Map` compute methods; eviction handled by `evictionListener`. Wiki lists limits of the writer: loads/refreshes/computes not reported to it; writer failure leaves the mapping unchanged; write-back can leave invalid cache state on failure. No stated reason for removal. Policy: store writes happen inside the cache's per-key atomic compute. VERIFIED: https://github.com/ben-manes/caffeine/wiki/Writer; deprecation notice SECONDARY: javadoc.io Caffeine 2.9.2 deprecated list. +- **Ehcache 3 `CacheLoaderWriter`.** Write-through is synchronous (put calls `write`); write-behind queues, batches, coalesces (only the last mutation per key reaches the writer); a read-through-only cache "risks making it inconsistent with the underlying system of record" if mutated, so no-op or throw in `write*/delete*`; page does not address other writers to the SoR. VERIFIED: https://www.ehcache.org/documentation/3.1/writers.html +- **Apache Ignite.** Write-through persists updates to the external store; write-behind: "some updates could be lost due to node failures", only latest value per key written; page does not address external changes by others. VERIFIED: https://ignite.apache.org/docs/latest/persistence/external-storage +- **Hazelcast MapStore.** Write-through is `write-delay-seconds` 0; MapLoader fires on a miss only, so loaded entries persist until changed or evicted (search-result summary of 5.x docs; no doc page found addressing database-side edits; the stale consequence is inferred). SECONDARY/UNVERIFIED: https://docs.hazelcast.com/hazelcast/5.5/mapstore/implement-a-mapstore +- **Memcache at Facebook (Nishtala et al., NSDI 2013).** Writes go to the database, then the cache entry is deleted (invalidation, via mcsqueal); leases stop a stale `set` after a newer write; MIT 6.824 FAQ: a window of stale reads exists by design. SECONDARY (paper not read): https://pdos.csail.mit.edu/6.824/papers/memcache-fb.pdf, https://pdos.csail.mit.edu/6.824/papers/memcache-faq.txt + +### C4. Paper/design note on single-writer embedded-store cache coherence + +- None found this session (searches covered Memcache only; none targeted embedded stores directly). Not exhaustive. + +## D. Pattern table (facts only) + +| System | Direct write path next to the cache | What the API does | +|---|---|---| +| Hibernate | bulk HQL/native SQL | exists; L2 invalidated; L1 not; hazard documented; refresh/clear provided | +| EF Core | ExecuteUpdate/Delete | exists; tracker unaware; hazard documented | +| SQLAlchemy | bulk UPDATE/DELETE | exists; `synchronize_session` default `auto` refreshes/expunges | +| SQLite | second connection | allowed; change counter invalidates the other cache | +| redb | second handle | refused (`DatabaseAlreadyOpen`) | +| LMDB | second env in process | documented as prohibited | +| RocksDB | secondary instance | allowed read-only, manual catch-up | +| Caffeine / Ehcache / Ignite | writer inside the cache | store written only via cache operations; direct SoR writes not addressed in pages read | +| Hazelcast | direct DB edits | not addressed in docs found | +| CultCache today | public Push/Delete/PushAll/CommitBatch | allowed attached; cache not told; refused only for WriteRecord/WriteDocument | + +## E. Not done + +Hibernate user-guide sections 13.3 / 14.7 bodies not read; Hazelcast official page empty on fetch; Nishtala paper and the Dynamo/Firebase/RethinkDB files not read; no GitHub-issues search for redb/RocksDB/SQLite incident reports; Soul s4 probe not run. diff --git a/research/witness-authoritative-networking/prior-art-2026-10-26.md b/research/witness-authoritative-networking/prior-art-2026-10-26.md new file mode 100644 index 00000000..859e8409 --- /dev/null +++ b/research/witness-authoritative-networking/prior-art-2026-10-26.md @@ -0,0 +1,304 @@ +# Witness-authoritative networking: prior art (2026-10-26) + +## Framing (operator, 2026-10-26) + +The concept began as witness-authoritative networking; the consensus literature is the closest existing body of work, not the origin. The goal: no centralized authoritative server should be needed to resolve local disputes such as whether Alice shot Bob first or Bob shot Alice first. A quorum of nearby clients reaches agreement on the ordering, and whoever is determined to be wrong is corrected exactly as in a server-authoritative model with client-side prediction, except that the correction can happen before the server hears about it. + +**Why the current implementation looks as it does (operator, 2026-10-26).** CultMesh was deliberately built for trusted co-op first, for Aetheria: Starbridge, with the witness-authority problem explicitly deferred. Starbridge itself collapsed under the weight of building Eve at the same time. The trusted-primary, unsigned-witness shape described below is therefore the intended first stage, not a failed attempt at Byzantine tolerance; hardening against adversarial peers is the deferred second stage this document prepares. + +The report below was produced by a read-only Eyes pass. It describes the CultMesh implementation as read from source on 2026-10-26 and compares it with prior art; it does not recommend a design. Items marked as from memory were not re-fetched. + +--- + +# CultMesh "witness consensus": source reading and prior art + +Eyes pass, 2026-10-26. Read-only. Eureka mind (eureka-state) not used. No design recommendation is made here. + +Evidence labels: [SRC] read from the CultLib source this pass. [WEB] confirmed by a web search result this pass (title/venue/authors seen in a result). [MEM] from model memory, NOT re-fetched this pass; verify before citing. + +--------------------------------------------------------------------------- + +## STEP 1. What CultMesh witness consensus is today (from source) + +All paths under `F:\Projects\CultLib\`. + +### 1.1 The machine, as implemented [SRC] + +Three pieces plus a facade: + +1. **Observation** `CultNetSimulationObservation`, `src\GameCult.Networking\CultNetSimulationConsensus.cs:15-69`. One witness report: `witnessRuntimeId`, `shardId`, `shardEpoch`, `frame`, `subjectId`, `claimKind` ("hit, input, collision, or ownership", line 39), `claimHash` (SHA-256 of caller-chosen parts joined by U+001F, lines 61-68), optional `claimSummary`, `weight` (default 1, "Normal clients should use 1", lines 50-52), `observedAt`. There is no signature field, no nonce, no reference to the witness's own state hash or prior observations. +2. **Aggregator** `CultNetSimulationConsensus.BuildCandidates`, same file lines 177-293. + - Drops invalid observations (empty ids/hash or weight <= 0; lines 257-266). + - De-duplicates per (witness, shard, epoch, frame, subject, kind), keeping the highest weight (lines 196-203, 273-282). So a witness that reports two different claim hashes for the same subject/frame keeps only one (heaviest, then ordinal-smallest witness id), not both. + - Groups by (shard, epoch, frame, subject, kind) (lines 205-206, 284-292). Per group: total weight = sum of all weights; best claim = the claimHash with greatest summed weight, ties broken by smallest hash ordinal (lines 219-230). + - `HasQuorum = distinctWitnesses >= MinimumWitnesses && bestWeight >= MinimumWeight && bestWeight/totalObservedWeight >= QuorumRatio` (lines 238-241). Defaults: MinimumWitnesses 1, MinimumWeight 1.0, QuorumRatio 0.5 (lines 79-87). + - The denominator is weight actually OBSERVED, not weight of an enrolled witness set. +3. **Hub** `CultNetSimulationObservationHub`, lines 298-398. In-memory `List<>` of every observation; each `Submit` appends, re-runs `BuildCandidates` over the whole list (line 347) and emits candidates for that subject on an R3 `Subject`. No eviction, no persistence, not thread-safe (plain List). +4. **Wire bridge** `src\GameCult.Networking\CultNetSimulationObservationServer.cs`: receives `cultnet.simulation_observation.v0` (`CultNetSchemaMessages.cs:133, 1255-1268`), submits to the hub, replies to the sending peer with candidate messages. In the lines read (61-70) the handler submits without comparing `WitnessRuntimeId` to the authenticated peer identity [SRC, partial read; verify the rest of the handler]. +5. **Commit** `src\GameCult.Mesh\CultMeshSimulationFacts.cs:12-188`. `CultMeshSimulationFact` is a CultCache document (`gamecult.mesh.simulation_fact.v1`). `FactId` = SHA-256 over shard|epoch|frame|subject|kind, deliberately NOT over claimHash (lines 114-126). `CultMeshSimulationFactCommitter.CommitAsync` throws unless `HasQuorum` (lines 179-182) then `PutAsync` into `CultNetDatabase`, which enforces the shard's primary and epoch (`CultNetDatabase.cs:1034, 1054` call `EnsurePrimary(shard, schemaId, key, message.ShardEpoch)`; stale-epoch errors at 829-834, 1086-1091). +6. **Session facade** `src\GameCult.Mesh\CultMeshGameSession.cs:60-224`. `SubmitAndCommitAsync` submits, then `CommitQuorumCandidatesAsync` commits each quorum candidate once per session (`_committedFactIds` HashSet, then a DB `GetAsync` existence check, lines 190-221). The session constructs an `AuthorityResolver` (line 77) but nothing in the witness path calls it (grep of the file: only lines 35, 77, 128). + +### 1.2 What the design says it is for [SRC] + +- `src\GameCult.Mesh\README.md:3-4`: "distributed realtime database and simulation-consensus layer". README "Witness Consensus" section lines 360-378: observations are "the fast mesh opinion layer that can feed an authoritative shard commit path"; candidates "are not committed world state by themselves"; "The central server does not need to decide what every witness saw. It only needs to accept the quorum result through the shard log." Example "who shot first" (`contracts\cultnet\cultnet-distributed-database.md` ~line 175). +- `src\GameCult.Mesh\docs\research.md:21-41`: live model is primary-shard authority plus replica catch-up; steps 4-6: witnesses publish immutable observations, nodes aggregate to candidates, "Authoritative world state is still committed through shard logs". Next research cut includes "add observation fanout across peers", "simulation rollback/resimulation helpers", then "revisit membership, failover, and consensus protocol depth". +- `src\GameCult.Mesh\docs\public-api.md:528-531, 574-583`: "Each shard has one primary writer for now. Non-primary writes are rejected or explicitly forwarded. Stale epochs fail loudly." Candidates are "opinions". +- Verse authority models enum, `src\GameCult.Mesh\CultMeshVerse.cs:14-32`: OperatorCluster, FederatedCluster, PeerToPeer ("Peers participate directly in authority decisions"), SubscribedOverlay. Only descriptors; no peer-to-peer authority code found. +- `src\GameCult.Mesh\docs\cross-runtime-primitives-roadmap.md:80`: "Authority scope: Runtime claim, lease, witness, or delegated simulation ownership expressed as typed policy state. First consumers: Trusted co-op, future quorum/witness modes". The words "trusted co-op" mark the current trust model. +- `research\cultnet-distributed-database\summary.md` (193 lines): borrowed Raft (primary per shard first, election deferred), Dynamo, SWIM (later), CRDTs (only with declared merge law), Hashgraph. Hashgraph was explicitly REJECTED for now: "CultNet does not currently need asynchronous Byzantine total ordering"; keep only gossip-about-gossip as a provenance idea (summary lines on Hashgraph; also `docs\research.md:15-16`). Stored PDFs: hashgraph-swirlds-tr-2016-01.pdf, raft-extended.pdf, dynamo-sosp2007.pdf, swim.pdf, crdt-arxiv-1805.06358.html, plus changefeed/Firebase pages. + +### 1.3 Who simulates, who witnesses, what is agreed + +- **Who simulates:** the code does not say. Nothing in CultLib implements a simulation, input exchange, rollback or resimulation. The only simulation-shaped objects are the observation fields. `docs\research.md` lists "simulation rollback/resimulation helpers" as NOT yet built; `contracts\cultnet\cultnet-distributed-database.md` lists cross-runtime rollback/resimulation helpers as "standardize ... beyond Python's local pending-prediction rollback surface" (Python only). +- **Who witnesses:** any runtime that submits a `CultNetSimulationObservation`. No witness registry, selection rule, enrollment, stake or lease check exists in the witness path. +- **What is agreed:** a per-(shard, epoch, frame, subject, claimKind) claim HASH; not inputs, not state hashes of a whole tick, not an ordered log. The summary text is carried but not part of the vote. Agreement is a weighted plurality over whatever observations arrived, evaluated incrementally. +- **Ordering / time:** `frame` is a number the witness asserts; no clock, no tick schedule, no ordering across subjects, no finality rule. Commit fires the first time a candidate has quorum (session-local `_committedFactIds` plus DB existence check). Because FactId excludes claimHash, a second, different quorum-winning claim for the same subject/frame/kind later would be silently skipped (line 205-215 `GetAsync != null -> continue`). +- **Disagreement:** the minority claim is only reflected in `Confidence` (= support/total) and `TotalWeight`. No dispute, challenge, slashing, re-simulation or escalation path. + +### 1.4 Fault model (as built) [SRC, inferred from code] + +- Not Byzantine-tolerant. Reasons visible in code: (a) observations are unsigned and the witness id is a self-asserted string; (b) `weight` is supplied by the observer (line 52); (c) quorum denominator is observed weight, so n observers out of an unknown population can reach "quorum"; with defaults, one witness alone commits (MinimumWitnesses=1, ratio 0.5); (d) no Sybil bound; (e) the commit authority is the single shard primary, so a crashed or malicious primary is outside the model (no election, no failover: contract doc "Not implemented yet: leader election or automatic shard failover"). +- The effective model is "trusted co-op / operator-cluster": the shard primary is trusted; witnesses are an advisory vote the primary chooses to commit. Cheating resistance is whatever the primary's trust gives. +- Authority leases (separate subsystem) are the only signature-backed element: `CultMeshAuthorityLease` (`CultMeshAuthority.cs:8-60`) binds verse, peer, roles, shard ids, issuer, validity window, optional epoch, signature; decisions belong to `CultMeshAuthorityResolver` with signature verifier and revocation source (`docs\reliability-migration-plan.md:120-175`: "Forbidden writers: peer catalogs, transport success, caches, and UI state must never confer authority"). `CultMeshAuthorityProof.cs` verifies P-256 (IEEE P1363, 64-byte) Odin route certificates and provider session proofs (lines 111-143, 305-316). Leases cover roles like `ShardPrimary` and `ReadReplica`; no "Witness" role was found in the witness path [SRC grep, verify]. + +### 1.5 Implemented vs planned + +Implemented [SRC]: observation/candidate types, deterministic aggregation, schema-v0 messages, in-memory hub, server bridge that replies with candidates, fact document and committer through primary shard with epoch check, session facade, durable shard logs and snapshot fallback, primary-shard authority, client prediction scopes (`Predicted`/`Reconciled`), authority lease/resolver, Python parity for consensus types, committer and session. +Planned / not implemented [SRC docs]: observation fanout across peers ("standardize cross-runtime scheduled peer-to-peer fanout for observation gossip and candidate propagation", `cultnet-distributed-database.md` Next Coherent Slice), rollback/resimulation helpers (C#), SWIM membership, leader election/failover, witness selection, signatures on observations, "future quorum/witness modes" (roadmap line 80). + +### 1.6 Tests [SRC] + +`tests\GameCult.Networking.Tests\NetworkingTests.cs`: `CultNetSimulationConsensus_Builds_QuorumCandidate_FromWitnesses` (6647), `..._DeduplicatesWitness_AndBreaksTiesDeterministically` (6702), `CultNetSimulationObservationHub_Emits_ReactiveCandidates` (6760), `CultNetSimulationObservationServer_Creates_CandidateMessages` (6806), `CultMeshSimulationFactCommitter_Commits_QuorumCandidate_ToShardLog` (6856), `CultMeshGameSession_SubmitsWitnesses_AndCommitsQuorumFact` (6902), `CultMeshSimulationFactCommitter_Rejects_CandidateWithoutQuorum` (6970); message round-trips at 3908-3955. Python: `packages\cultmesh-py\tests\test_cultmesh.py` ~1866-2019 (committer, game session commits quorum once). TS interop test mentions simulation messages (`packages\cultnet-ts\test\interop\cultnet-interop.test.ts`, not read in detail). No test found for: Byzantine/lying witnesses, forged witness ids, weight inflation, conflicting claims for one FactId, network partition, latency, or a real simulation. All tests are in-process, happy-path or arithmetic. + +### 1.7 Name collision + +`CultWitnessArtifactBundle` (`src\GameCult.Networking\CultWitnessArtifactBundle.cs`, schema `cultnet.witness_artifact_bundle`) is a build/pipeline evidence manifest (subject pin, contract pins, artifact URIs, timing "witnesses"), unrelated to simulation witnesses. "Witness" in the postmortem docs (cold-release witness) means the same pipeline-evidence sense. Do not conflate. + +--------------------------------------------------------------------------- + +## STEP 2. Prior art + +### A. Crash-fault consensus + +| Item | Core idea | Fault model | Agreed | Latency / throughput | Group size | Implementations | Relation to CultMesh | +|---|---|---|---|---|---|---|---| +| Paxos: Lamport, "The Part-Time Parliament", ACM TOCS 1998; "Paxos Made Simple", 2001 [MEM] | Quorum-based single-value agreement with proposal numbers | Crash, message loss, asynchronous; needs majority (n >= 2f+1) | One value per instance | 2 round trips for a fresh proposer, 1 with stable leader | 3-7 | Chubby, Spanner (Multi-Paxos) [MEM] | Different: agrees on an ordered log, no observed-weight plurality; majority is of a fixed membership | +| Multi-Paxos [MEM] | Stable leader skips phase 1 per slot | same | Ordered log | ~1 RTT to a majority, high throughput | 3-7 | Spanner, many | Similar shape to CultMesh's primary-shard log; CultMesh has no replication quorum on the log yet | +| Raft: Ongaro & Ousterhout, "In Search of an Understandable Consensus Algorithm", USENIX ATC 2014 [MEM; PDF is in `research\...\raft-extended.pdf`] | Leader election by term, log replication, majority commit | Crash (non-Byzantine), partitions | Replicated log | 1 RTT commit, ms-level in a LAN, 10^4-10^5 ops/s | 3-7 | etcd, Consul, TiKV, CockroachDB [MEM] | Similar: CultMesh docs explicitly call primary-per-shard "Raft-shaped" (`docs\research.md:11`); epoch ~ term (`stale_epoch` errors). Different: no election, no majority ack of writes, witnesses are not voters on the log | +| Viewstamped Replication: Oki & Liskov PODC 1988; Liskov & Cowling, "VR Revisited", MIT TR 2012 [MEM] | Primary + view changes, state-machine replication | Crash | Ordered ops | like Raft | 3-5 | few production | Same family as Raft; view number ~ shard epoch | +| EPaxos: Moraru, Andersen, Kaminsky, SOSP 2013 [MEM] | Leaderless, commit non-conflicting commands in 1 RTT fast path | Crash | Partial order by dependency | Fast path 1 WAN RTT; conflicts slow | 3-5 | research/few prod | Different; relevant only to "avoid a single primary" | +| ZooKeeper/ZAB: Junqueira, Reed, Serafini, DSN 2011 [MEM] | Primary-order atomic broadcast | Crash | Ordered broadcast | ms | 3-7 | ZooKeeper | Same family; coordination service for leases | +| Leases: Gray & Cheriton, SOSP 1989 [MEM] | Time-bounded grants | Crash plus bounded clock drift | Exclusive holder | low | any | Chubby, etcd leases | Same idea as `CultMeshAuthorityLease` (validFrom/expiresAt, `CultMeshAuthority.cs:14-60`); CultMesh adds signed issuer and epoch | + +Group sizes in A are small (3-7) because every write waits on a majority. Throughput characteristics of etcd/ZooKeeper are written for control-plane use, not 20-60 Hz game state; no frame-rate use of Raft quorum was surveyed this pass. + +### B. Convergence without consensus + +| Item | Core idea | Fault model | Notes | +|---|---|---|---| +| CRDTs: Shapiro, Preguica, Baquero, Zawirski, "Conflict-free Replicated Data Types", SSS 2011 (INRIA RR-7687 [MEM]); survey arXiv 1805.06358 (local copy in research dir) | Merge function is commutative, associative, idempotent so replicas converge with no coordination | Crash/partition, NOT Byzantine; every replica trusted | Cannot enforce cross-object invariants (e.g. "hp cannot go below 0 and only one player gets the last item", exclusive ownership) without coordination. Related: Bailis et al., "Coordination Avoidance in Database Systems" (invariant confluence), VLDB 2015 [MEM] | +| Operational Transformation: Ellis & Gibbs, SIGMOD 1989 [MEM] | Transform concurrent ops against each other | Trusted peers, usually central server in practice | Text editing; correctness proofs notoriously hard | +| Automerge, Yjs [MEM] | Op-based JSON/text CRDTs; Yjs derives from YATA (Nicolaescu et al. 2016) | Trusted peers | Document sync, not game rules; Automerge has no authority/permission model | +| Local-first software: Kleppmann, Wiggins, van Hardenberg, McGranaghan, Onward! 2019 [MEM] | Seven ideals; CRDT sync, user-owned data | Trusted collaborators | Explicitly collaboration documents | + +Relation to CultMesh: the research summary already says CRDTs only for documents with declared merge laws and "generic automatic merge ... is a language cop with a nicer hat" (`research\...\summary.md`). CultMesh's witness layer is NOT a CRDT: it is a vote over claim hashes, not a merge. Game rules (hit validity, ownership transfer) are invariant-bearing, which is the standard limit of CRDTs. + +### C. Byzantine fault tolerance and witness-style constructs + +| Item | Core idea | Fault model | What is agreed | Latency / throughput | Group size | Implementations | Relation to CultMesh | +|---|---|---|---|---|---|---|---| +| PBFT: Castro & Liskov, OSDI 1999 [MEM] | 3-phase (pre-prepare, prepare, commit) with signed/MAC'd messages and view change | Byzantine, n >= 3f+1, partial synchrony | Total order of requests | O(n^2) messages; ms in LAN; throughput falls with n | 4-~20 | BFT-SMaRt, Hyperledger Fabric (older) [MEM] | Different: quorum is of a FIXED membership (2f+1 of 3f+1) with authenticated messages; CultMesh's quorum is of observed weight and unauthenticated | +| Tendermint: Buchman, Kwon, Milosevic, arXiv 1807.04938 (2018) [MEM] | PBFT-like rounds with proposer rotation, locked blocks | Byzantine, partial sync | Block per height | seconds per block | tens-~150 validators | Cosmos | Different; shows round time ~ seconds | +| HotStuff: Yin, Malkhi, Reiter, Gueta, Abraham, PODC 2019 [MEM] | Linear view change via chained quorum certificates | Byzantine, partial sync | Ordered blocks | O(n) per view | tens-100s | Libra/Diem, Aptos lineage | Different; its "quorum certificate" (n-f signatures on a value) is the formal object CultMesh's `HasQuorum` resembles without signatures | +| Hashgraph: Baird, "The Swirlds Hashgraph Consensus Algorithm", SWIRLDS-TR-2016-01 (local PDF in CultLib research dir) | Gossip-about-gossip builds a DAG; virtual voting; "witnesses" are the first events of each round per member, "famous witnesses" decide order | Asynchronous BFT, n >= 3f+1 | Total order + consensus timestamp | seconds | ~tens-100s; Hedera runs 30-ish councils [MEM] | Hedera | NAME ONLY: a Hashgraph witness is a DAG vertex, not an attester of a simulation fact. CultLib already evaluated and rejected adopting it | +| Narwhal and Tusk: Danezis, Kokoris-Kogias, Sonnino, Spiegelman, EuroSys 2022 [WEB confirmed: EuroSys '22 pp 34-50; ~160k tx/s at ~3 s latency, 600k tx/s with workers] | Separate reliable dissemination (DAG mempool) from ordering | Async BFT | Ordered txs | seconds | 10-50 | Sui/Mysten lineage [MEM] | Different latency class | +| Bullshark: Spiegelman, Giridharan, Sonnino, Kokoris-Kogias, ACM CCS 2022, arXiv 2201.05677 [WEB confirmed; DOI 10.1145/3548606.3559361; abstract: partially-synchronous variant ~125k tx/s at ~2 s latency, 50 parties] | DAG BFT with fast path, no extra messages beyond the DAG | Async liveness, partial sync fast path | Ordered vertices | ~2 s | ~50 | Sui | Different latency class | +| DAG-Rider: Keidar, Kokoris-Kogias, Naor, Spiegelman, "All You Need is DAG", PODC 2021 [MEM, not confirmed this pass] | First DAG-based async BFT with optimal amortized communication | Async BFT | Ordered | seconds | tens | foundation of Narwhal/Tusk | Different | +| Certificate Transparency / transparency-log witnesses: C2SP `tlog-cosignature` and `tlog-witness`; Sigsum; litewitness [WEB: spec names and purpose confirmed via search; spec pages not opened] | Independent witnesses verify a log's new checkpoint is consistent with the previous one (Merkle consistency proof) and cosign it; clients require a quorum of cosignatures to defeat split-view attacks | Byzantine LOG, but witnesses only check append-only consistency; they do not execute the application | A signed checkpoint (tree head) | Seconds to minutes; sync problems reported when logs update faster than witnesses | handful to dozens of witnesses | Sigsum, Go checksum DB witnesses, Let's Encrypt/Boulder adoption in progress [WEB, partial] | CLOSEST use of the word "witness": independent parties attest a statement (checkpoint hash) with a signature and a client-side quorum policy. Similar to a candidate with signed support; different in that CT witnesses verify a cryptographic proof, not an independent re-execution | +| Ethereum sync committee / light-client attestations (Altair, 2021) [MEM] | 512-validator committee signs headers; light clients accept with 2/3 signatures | Honest-majority of a randomly sampled committee | Block header | ~12 s slots | 512 | Ethereum | Relates to witness SELECTION by random sampling | +| Ouroboros: Kiayias, Russell, David, Oliynykov, CRYPTO 2017 [MEM] | Stake-weighted leader/committee selection by verifiable randomness | Honest-majority stake, Byzantine | Chain | slot-based | thousands | Cardano | Relates to weight = stake and Sybil resistance via a scarce resource | +| Sybil attack: Douceur, IPTPS 2002 [MEM] | Without a trusted identity authority, cheap identities defeat voting | n/a | n/a | n/a | n/a | n/a | Defines the open problem for CultMesh's self-assigned `weight` and `witnessRuntimeId` | + +Group-size/latency pattern in C: all classical BFT agrees on an ordered log, authenticated, with quorum 2f+1 of n >= 3f+1 over a fixed or sampled membership. Latencies from ms (small PBFT in LAN) to seconds (WAN, large n). I found no BFT protocol in this group specialised to a 20-60 Hz tick budget. + +### D. Game networking + +| Item | Core idea | Fault model | What is agreed/authoritative | Latency | Scale | Notes / relation | +|---|---|---|---|---|---|---| +| Deterministic lockstep (RTS): Bettner & Terrano, "1500 Archers on a 28.8", GDC 2001 [MEM]; Fiedler, "Deterministic Lockstep", gafferongames.com [MEM] | All peers exchange only inputs and run a bit-identical deterministic simulation | Honest peers; desync = bug (state hash compare detects it) | Inputs per turn | Input delay of a turn (~100-200 ms), stalls on slowest peer | 2-8 (RTS), hundreds of units | Related: agreement on INPUTS, detect divergence by state hash. A cheater who modifies local state is detected only by hash comparison; map-hack cannot be prevented because every peer has full state. CultMesh agrees on claim hashes about facts, not inputs | +| Rollback netcode / GGPO: Tony Cannon, 2009 [MEM] | Predict remote inputs, run ahead, rewind and resimulate on late inputs | Honest peers | Inputs; resimulate | Hides latency up to ~8 frames | 2-4 (fighting games) | Requires fast deterministic save/load and resimulate; CultMesh docs list "rollback/resimulation helpers" as planned, not built | +| Photon Quantum (Exit Games) [MEM; docs not fetched] | Deterministic ECS with fixed-point math, predict/rollback, a server-side input-relay and optional authoritative verification | Server-trusted; can run a replay/verification on the server | Inputs and checkpoints | Predict/rollback hides latency | Up to ~hundreds in some modes [unverified] | A commercial instance of "peers simulate, server verifies" | +| Client prediction with server authority (Quake/Source): Gambetta, "Fast-Paced Multiplayer" series, gabrielgambetta.com [MEM]; Valve Source networking / lag compensation doc [MEM]; Bernier, "Latency Compensating Methods", GDC 2001 [MEM] | Server simulates authoritatively; client predicts own input, reconciles to server snapshots; server rewinds for hit validation (lag compensation) | Cheating clients; server trusted | Server state | Tick 20-128 Hz, 50-100 ms perceived budget met via prediction | tens to ~100 per server | Direct match to CultMesh's current LIVE model (primary shard + `PutPredictedAsync`/`Reconciled`); the witness layer is an addition to it | +| Fiedler networked physics: "Snapshot Interpolation", "State Synchronization", "Deterministic Lockstep" [MEM] | Compare interpolation, delta compression, priority accumulators | n/a | n/a | n/a | n/a | Engineering reference for bandwidth/ticks | +| Interest management / AOI: Colyseus (Bharambe et al., NSDI 2006) [MEM]; Donnybrook (Bharambe et al., SIGCOMM 2008) [MEM]; Boulanger, Kienzle, Verbrugge survey (NetGames 2006) [MEM] | Send each client only entities near it; distribute zones/objects over servers or peers | Mostly trusted servers/peers | Authority per object/zone | Same as client-server | 100s-10,000s | Relates to CultMesh `shardId` per zone; shards are the authority unit | +| SpatialOS (Improbable) [partly WEB] | Cloud runtime that shards a world across worker servers by entity authority and AOI | Trusted operator | Per-entity authority delegated to workers | n/a | thousands of entities/clients per world claimed [MEM] | WEB: Jan 2019 Unity license dispute threatened SpatialOS-on-Unity games; Improbable sold Inflexion (Tencent, Feb 2022) and Midwinter (Behaviour, 2022) and sold its gaming unit in Dec 2023; pivoted to "Morpheus". The WEB results did NOT confirm an official SpatialOS end-of-life date or GDK deprecation (UNVERIFIED) | + +Pattern in D: realtime games meet 50-100 ms either by a trusted authoritative server with prediction, or by lockstep/rollback with an input delay among trusted peers. None of the mainstream techniques use a voting quorum among untrusted witnesses on the tick path. + +### E. Decentralised / P2P anti-cheat and verification + +| Item | Core idea | Fault model | Known limits | +|---|---|---|---| +| Lockstep with commitments: Baughman & Levine, "Cheat-Proof Playout for Centralized and Distributed Online Games", IEEE INFOCOM 2001, pp 104-113; revised as Baughman, Liberatore, Levine, "Cheat-Proof Playout for Centralized and Peer-to-Peer Gaming", IEEE/ACM ToN 2007 [WEB confirmed] | Each round, players first commit (hash) their move, then reveal; prevents look-ahead cheating. "Asynchronous synchronization" variant removes the slowest-player penalty | Cheating peers (protocol-level cheats: fixed delay, timestamp, suppressed update, inconsistency) | Round time bounded by the slowest player; needs all players; no defence against collusion or against a compromised client reading state (information exposure) [MEM for the limits] | +| NEO: GauthierDickey, Zappala, Lo, Marr, "Low latency and cheat-proof event ordering for peer-to-peer games", NOSSDAV 2004, pp 134-139 [WEB confirmed]; also NetGames 2004 paper (distributed games) | Rounds whose duration bounds latency to a MAJORITY of players, not the slowest; cheat-proof event ordering | Protocol-level cheats | WEB: a 2008 paper reports Corman et al. showed NEO cannot prevent all cheats as claimed and proposed Secure Event Agreement (SEA); full details UNVERIFIED (not opened) | +| Watchmen: Yahyavi, Huguenin, Gascon-Samson, Kienzle, Kemme, "Watchmen: Scalable Cheat-Resistant Support for Distributed Multi-player Online Games", IEEE ICDCS 2013, pp 134-144 [WEB confirmed] | Randomized dynamic proxy scheme for dissemination AND verification of actions; players verify each other's actions via proxies | Cheating players, including collusion (per abstract) | WEB abstract: prototype on Quake III, scales to hundreds of players, "significantly reducing" cheating "even under collusion" (not eliminating). Full evaluation numbers not read | +| RACS: Webb, Soh, Lau, "RACS: A Referee Anti-Cheat Scheme for P2P Gaming", NOSSDAV 2007 [MEM, search hit "Referee-based architectures for MMOGs" Curtin repo suggests Webb/Soh thesis; not confirmed] | A referee node (trusted/semitrusted) validates state updates; peers do the rest | Cheating players, trusted referee | Referee is single point of trust | +| Kabus, Terpstra, Cilia, Buchmann, "Addressing Cheating in Distributed MMOGs", NetGames 2005 [MEM] | Peers replicate objects and a subset acts as witnesses/validators checking each other's updates | Cheating peers | Reliant on honest majority in the object's replica set | +| Mutual checking / peer verification, Mönch et al., "Protecting online games against cheating", NetGames 2006 [MEM]; Corman et al. SEA, 2006 [MEM] | See above | | | +| Trusted execution (Intel SGX etc.) for cheat prevention [MEM] | Run authoritative logic in an enclave | Hardware-rooted trust | Repeated side-channel breaks of SGX; dependence on vendor attestation | +| Replay/verification servers (server re-simulates client input stream) [MEM] | Server accepts client-reported results only if re-simulation matches | Cheating clients | Costs server CPU equal to the simulation | +| Server-side authoritative simulation | The common industrial answer | Server trusted | Operator pays CPU/bandwidth | + +Known limitations across E (recurring in the literature) [MEM unless noted]: collusion (witnesses/peers that are friends), Sybil identities with no costly identity, latency of verification vs 50-100 ms budgets, information exposure (every peer holding full state can map-hack), network-level DoS on proxies/referees, and the need for deterministic simulation across heterogeneous clients (floating point, JS engines). WEB-confirmed for Watchmen: collusion is explicitly in scope and results are "significantly reduced", not eliminated. + +### F. Other close matches to "peers simulate, witnesses attest" + +| Item | Core idea | Relation | +|---|---|---| +| Optimistic rollups, fraud proofs: Arbitrum (Kalodner et al., "Arbitrum: Scalable, private smart contracts", USENIX Security 2018 [MEM]); Optimism [MEM] | Sequencer posts results; anyone re-executes; a challenge game (bisection over execution steps) proves one step on-chain; economic bond slashed | Closest structural match to "one party simulates, others independently re-simulate and dispute": needs deterministic VM, a challenge window (days on mainnet), and bonds. Latency class is far outside 50-100 ms; CultMesh has no challenge path today | +| Refereed delegation: Canetti, Riva, Rothblum, "Practical Delegation of Computation using Multiple Servers", CCS 2011 [MEM]; TrueBit (Teutsch & Reitwiessner, 2017/2019) [MEM] | Multiple servers compute; disagreement resolved by referee via bisection | Same family; assumes at least one honest server | +| Verifiable computation / zk (SNARKs, STARKs; zkVMs) [MEM] | Prover returns a proof the computation was done correctly | Removes trust in witnesses; proving cost far above real-time tick budget for a general sim (no real-time game case surveyed this pass) | +| N-version / replicated execution (BFT state-machine replication, Schneider 1990 "Implementing Fault-Tolerant Services Using the State Machine Approach", ACM Computing Surveys [MEM]) | Replicas execute the same deterministic ops and vote on outputs | The textbook ancestor of "peers independently simulate and vote on results"; the standard requires deterministic execution and agreed input order, which CultMesh does not yet provide | +| Output/response voting in BFT clients (PBFT client waits for f+1 matching replies) [MEM] | Client accepts a result when f+1 replicas agree | Structurally the same as accepting a candidate once enough matching claim hashes arrive; differs in that f is fixed and replicas are authenticated | +| Transparency-log witness cosigning (see C) | Quorum of independent signed attestations over a hash | Closest to CultMesh's "claim hash + quorum" at the data-structure level | + +--------------------------------------------------------------------------- + +## STEP 3. Synthesis + +### (1) Comparison across A-F + +"Realtime .io fit" = tens to hundreds of players, 50-100 ms budgets. Ratings are descriptive judgments from the facts above, with confidence noted. + +| Family | Fault model | What is agreed | Latency class | Group size | Cheating resistance | Realtime .io fit (descriptive) | +|---|---|---|---|---|---|---| +| A. Crash consensus (Paxos/Raft/VR/ZAB) | Crash only | Ordered log | ms (LAN) to 100s of ms (WAN) per commit | 3-7 | None (trusts members) | Fine as a coordination/lease/failover backend for shards, not per-tick; high confidence | +| B. CRDTs/OT | Crash/partition, trusted replicas | Converged state, no order | Zero coordination latency | Any | None | Fits presence/cosmetic state; cannot enforce game invariants; high confidence | +| C. BFT (PBFT/HotStuff/Tendermint/DAG) | Byzantine, n>=3f+1 | Ordered log of signed requests | ~100 ms-seconds, grows with n | 4-100ish | Strong against <1/3 faulty identities; none against Sybil without identity scarcity | Latency/size mismatch for tick-rate; plausible for low-frequency events; medium confidence | +| C'. Transparency-log witnesses | Byzantine log, honest-enough witnesses | Signed checkpoint hash | seconds-minutes | handful-dozens | Detects equivocation (split view) | Suited to auditing/after-the-fact, not tick path; medium confidence | +| D1. Authoritative server + prediction | Server trusted | Server state | one tick, 50-100 ms perceived hidden by prediction | tens-100s per server | Strong vs clients if server honest | The incumbent for the stated target | +| D2. Lockstep/rollback | Honest peers | Inputs | input delay 100-200 ms (lockstep) or hidden by rollback | 2-8 typical | Detects desync; no information hiding | Small-group only; high confidence | +| E. P2P anti-cheat (NEO, Watchmen, RACS, Kabus) | Cheating peers, limited collusion | Event order / verified actions via proxies/referees | Designed for realtime (Watchmen: Quake III prototype) | up to hundreds (Watchmen abstract) | Reduced, not eliminated; collusion and Sybil are known gaps | Closest in goal; research prototypes, not shipped infrastructure (to my knowledge, unverified) | +| F. Fraud proofs / VC | Byzantine with >=1 honest verifier or crypto proof | Execution result | Challenge window (minutes-days) or proving time | Open | Strong given determinism + bonds/proofs | Not on the tick path; plausible for deferred settlement | +| CultMesh today (witness plurality + primary shard commit) | Trusted primary; witnesses unauthenticated | Claim hash per (shard, epoch, frame, subject, kind) | In-process aggregation O(total observations) per submit | Unspecified | None against a lying witness or primary (see 1.4) | Latency is not the problem (no network round in aggregator); the trust model is | + +### (2) Design questions CultMesh's witness consensus must answer, tied to prior art + +| # | Question | Where prior art answered it | State in CultMesh source | +|---|---|---|---| +| 1 | What exactly is agreed: inputs, per-tick state hash, or sparse facts? | Lockstep/state-machine replication agree on inputs (Schneider 1990; Bettner & Terrano); rollups agree on state roots; CT on tree heads; CultMesh agrees on sparse fact hashes | Sparse facts (claimHash). No input log or state hash | +| 2 | Determinism requirement: can independent peers compute the same claim bits? | Lockstep (fixed-point, ordered inputs), Photon Quantum (fixed-point ECS), rollups (deterministic VM) | Not addressed; `ComputeClaimHash` hashes caller-chosen strings. CultMath exists in CultLib as parity-oriented math (`packages\cultmath`) but no link to witnesses found | +| 3 | Who may witness; selection and enrollment | BFT: fixed membership; Ethereum: random committee sampling; Ouroboros: stake-weighted VRF; Watchmen: randomized dynamic proxies; Sigsum: operator-chosen witness policy | None. Any runtime may submit; `AuthorityLease` exists for roles but isn't applied to witnesses | +| 4 | Authenticated identity of an observation | PBFT signed messages; CT cosignatures; HotStuff QCs | Unsigned, witness id self-asserted; bridge does not bind to peer identity in lines read | +| 5 | Sybil resistance / weight | Douceur 2002; stake (Ouroboros); invite-only membership (BFT) | `weight` is observer-supplied (line 52) | +| 6 | Quorum arithmetic: threshold over a fixed membership vs over observed votes | PBFT/HotStuff 2f+1 of 3f+1; Raft majority of configured members | Ratio of observed weight; MinimumWitnesses is the only count floor; default 1 | +| 7 | Collusion | Watchmen (random proxies per round), NEO/SEA, "honest majority of a randomly sampled set" (Ethereum) | Not addressed | +| 8 | Time/ordering: what is frame N, who orders facts, when is a fact final | Lockstep rounds; Raft log index; BFT sequence numbers; rollup challenge window finality | `frame` asserted by witness; commit at first quorum; no finality/retraction | +| 9 | Disagreement handling | Fraud proofs / bisection (Arbitrum, TrueBit); view change (PBFT); desync detection by state hash (lockstep); slashing | Minority recorded only as lower `Confidence`; later conflicting quorum silently skipped (FactId excludes claimHash) | +| 10 | Equivocation (same witness, two claims) | PBFT/Tendermint treat as Byzantine evidence; CT witnesses detect split views | Aggregator keeps one of them by weight then id, no evidence retained | +| 11 | Commit authority and its failure | Raft/Paxos leader election; BFT view change; Verse `PeerToPeer` is only a descriptor | Single shard primary, no election/failover (documented "Not implemented") | +| 12 | Cost per tick | Narwhal/Bullshark: seconds; Raft: 1 RTT; lockstep: input exchange each turn | Aggregator is O(N) rescans of the full observation list per submit; list is unbounded (lines 300, 347) | +| 13 | Bandwidth: observation fanout topology | Gossip (SWIM, Hashgraph), interest management (Colyseus/Donnybrook), proxies (Watchmen) | Fanout listed as "not implemented"; peer exchange exists for contact discovery only | +| 14 | Information exposure (peers hold full state) | Known limit of lockstep/P2P (Baughman & Levine; Kabus; Watchmen); server authority hides state | Not addressed | +| 15 | Replay/audit: can a third party verify a committed fact later | CT consistency proofs; rollup state roots; replay verification servers | Facts store counts and a hash only; no witness signatures or evidence archived | + +### (3) Novelty vs known constructions + +Judgement is descriptive; all of it rests on what was read, and I did not search the academic literature exhaustively (see unverified list). + +Known constructions under another name: +- Primary-per-shard with epochs and log catch-up = Raft/VR primary/term structure (the docs say so themselves). +- Authority lease with validity window, signed issuer, epoch = classical leases (Gray & Cheriton) with signed capabilities. +- "Quorum candidate" with claim hash and support count = result voting in replicated state machines (f+1 matching replies) or a CT-style quorum of cosignatures, minus signatures and fixed membership. +- Predicted/Reconciled input documents = client-side prediction with server reconciliation (Quake/Source; Gambetta). +- Shard = zone-based authority partition (Colyseus/SpatialOS-style). +- Fact keyed by (shard, epoch, frame, subject, kind) = idempotent event id. +- Peers checking each other's claimed events = the E family (Kabus, RACS, Watchmen), whose designs add selection, authentication and verification rules that CultMesh lacks. + +Possibly distinctive in this codebase (not shown to be novel in the literature): +- Combining a typed-document database (CultCache) with a witness-fact document type so witness facts travel through the same shard log, schema catalog and replication path as all other state (`CultMeshSimulationFact` is an ordinary `[CultDocument]`). I found no direct counterpart in the sources I looked at; this is an integration choice rather than a protocol. +- Treating witness opinion as an explicitly non-authoritative "fast mesh opinion layer" that feeds, rather than replaces, a primary commit. The literature has analogues (referee schemes with a trusted referee, RACS), so this is a framing difference more than a new construction. + +Nothing in the implemented witness path is a new consensus algorithm; it is a deterministic weighted-plurality aggregator (the tie-break and dedup rules are the only protocol content). + +### (4) Ten most relevant primary sources + +1. Yahyavi, Huguenin, Gascon-Samson, Kienzle, Kemme. Watchmen. IEEE ICDCS 2013, pp 134-144. https://hal.archives-ouvertes.fr/hal-00797352 [WEB-confirmed bibliographic data] +2. Baughman & Levine. Cheat-Proof Playout for Centralized and Distributed Online Games. IEEE INFOCOM 2001. https://www.comp.nus.edu.sg/~bleong/hydra/related/baughman01cheatproof.pdf and ToN 2007 version https://people.cs.umass.edu/~liberato/home/publication/cheat-proof-playout-for-centralized-and [WEB] +3. GauthierDickey, Zappala, Lo, Marr. NEO, NOSSDAV 2004. https://www.cs.du.edu/~chrisg/publications/chrisg-nossdav04.pdf [WEB]; read with the later critique/SEA (Corman et al.) cited at https://staff.csie.ncu.edu.tw/%7Ejrjiang/network2008/EASES.pdf [WEB result; content not opened] +4. Castro & Liskov. Practical Byzantine Fault Tolerance. OSDI 1999 [MEM; link not fetched: https://pmg.csail.mit.edu/papers/osdi99.pdf] +5. Yin et al. HotStuff. PODC 2019, arXiv 1803.05069 [MEM] +6. Ongaro & Ousterhout. Raft. USENIX ATC 2014; local copy `F:\Projects\CultLib\research\cultnet-distributed-database\raft-extended.pdf` +7. Baird. Swirlds Hashgraph TR-2016-01; local copy `F:\Projects\CultLib\research\cultnet-distributed-database\hashgraph-swirlds-tr-2016-01.pdf` +8. Schneider. Implementing Fault-Tolerant Services Using the State Machine Approach. ACM Computing Surveys 1990 [MEM] +9. C2SP tlog-cosignature and tlog-witness specs, https://github.com/C2SP/C2SP (tlog-cosignature at https://github.com/C2SP/C2SP/blob/tlog-cosignature/v1.0.0/tlog-cosignature.md) [WEB, specs not opened]; Sigsum https://www.sigsum.org +10. Kalodner et al. Arbitrum, USENIX Security 2018 [MEM] (fraud proof / bisection), paired with Spiegelman et al. Bullshark, CCS 2022, https://arxiv.org/abs/2201.05677 [WEB] as the current DAG-BFT state of the art. Honourable mentions: Shapiro et al. CRDT survey arXiv 1805.06358 (local copy); Gambetta, Fast-Paced Multiplayer https://www.gabrielgambetta.com/client-server-game-architecture.html [MEM]; Fiedler, gafferongames.com [MEM]; Douceur, The Sybil Attack, IPTPS 2002 [MEM]; Narwhal and Tusk arXiv 2105.11827 [WEB]. + +--------------------------------------------------------------------------- + +## Unverified / gaps + +- Everything tagged [MEM] was not re-fetched this pass: Paxos/Raft/VR/EPaxos/ZAB publication details, CRDT/OT/Automerge/Yjs/local-first details, PBFT, Tendermint, HotStuff, DAG-Rider, Ouroboros, Ethereum sync committees, GGPO, Photon Quantum, Gambetta/Fiedler/Valve pages, Colyseus/Donnybrook, RACS, Kabus et al., Mönch et al., Arbitrum/TrueBit/Canetti, Douceur, Schneider. +- WEB-confirmed only at citation/abstract level, not full text: Watchmen, Baughman & Levine, NEO, Narwhal/Tusk, Bullshark, C2SP witness specs, Improbable corporate events. NEO "cannot prevent all cheats" claim comes from a snippet of a 2008 paper, not read. +- SpatialOS end-of-life/GDK deprecation: NOT confirmed by the searches (they returned the 2019 Unity dispute and 2022-2023 divestments only). +- Photon Quantum scale figures ("hundreds") and its verification mode are from memory; no docs fetched. +- In CultLib: I read the observation server only to line ~70 (no peer-identity check seen), did not open the TS interop test body, `cultnet-py` simulation module, or `CultNetSchemaRegistry`. No "Witness" lease role was found by grep but the Authority resolver internals (`CultMeshAuthority*.cs`) were read only partially. Did not search Aetheria/StreamPixels design docs for witness-consensus intent beyond a filename hit list (`F:\Projects\Aetheria\docs\research\ew-prior-art.md` uses "compliance witness" in an unrelated game-fiction sense). `.claude\worktrees\agent-a1917e987955dada5` under CultLib duplicates some files; ignored. +- Latency/throughput numbers for A, C families are typical-literature figures from memory except where marked WEB. +- No empirical measurement of CultMesh aggregator cost was made. + +--- + +## Design notes from discussion (operator and Self, 2026-10-26) + +These are working directions from a design conversation, not rulings. Points marked "operator" are the operator's stated intent; the rest are analysis offered in that conversation. + +### Shape of the system + +- **Goal (operator):** a quorum of clients resolves local ordering disputes (who shot first) without a central authoritative server. The loser of a dispute is corrected exactly as under server authority with client-side prediction, but before the server has heard of the dispute. +- **Shape this implies:** an optimistic fast path among peers, with the server reduced to a slow path: audit and arbitration in the rare case where the quorum fails, splits, or is later shown to have lied. The server can overturn a peer decision after the fact and penalise provably dishonest witnesses. This is the structure of optimistic rollups (fraud proofs), applied at realtime latency; the closest game-networking prior art is Watchmen and NEO. +- **Cost (operator):** every client carries the lag-compensation machinery that is normally only on the server. + +### Prerequisites before any quorum can agree + +1. **Shared timeline.** Each party sees the other through latency, so "first" is undefined without a common reference: tick numbers plus signed, tick-stamped inputs. Without it, witnesses vote on their own latency. +2. **Commit before reveal.** Without it, a client can wait for the opponent's input and then claim an earlier action. Baughman and Levine (2001) solved this with commit-reveal lockstep. +3. **Escalation when the quorum fails or splits,** handled by the server slow path above. + +### Witness selection: network proximity over game proximity + +The operator questioned whether game-space proximity (area of interest) adds much over geographic or network proximity. Analysis in favour of network proximity: + +- **Neutrality.** Network-near clients are statistically disinterested in the dispute and rarely on either party's team. Game-near clients are disproportionately the parties' teammates, which makes collusion the main threat rather than an edge case. +- **Latency.** Low RTT among witnesses is what the quorum round actually needs. Game-near players can be 200 ms apart on the network. +- **Calculable risk.** Random selection from a network-near pool makes the probability of a captured quorum a binomial function of the colluding fraction. Game-near selection makes it depend on team composition. + +### Dispute-scoped verification keeps witnessing cheap + +A witness does not simulate the world, only the dispute. A dispute packet carries the last committed state of the entities involved and both parties' signed inputs with tick numbers. The witness rewinds the relevant hitboxes over a few ticks and signs its verdict: lag compensation in miniature, small in both CPU and bandwidth. Watchmen's randomly assigned proxies are the nearest precedent. + +### Problems this introduces + +- **Information leakage.** A witness learns positions it would not otherwise see; a witness colluding with a third party could act as a wallhack relay. Possible mitigations: pseudonymous entity identities in dispute packets, and adjudicating only past ticks. +- **History provenance.** If one party supplies the history, it can lie. Both parties' inputs must be signed by their authors and checked against previously committed state, which makes commit-before-reveal mandatory. +- **Cross-runtime determinism.** A witness may run a different runtime from the parties (for example a browser TypeScript client adjudicating between native C# clients). Every hit test must then produce bit-identical results across runtimes, which requires deterministic arithmetic (fixed-point or strictly specified floating point). CultLib's cross-runtime parity, and CultMath's HLSL-parity math, become security properties here rather than conveniences. This is also the strongest argument that witness-authoritative networking depends on a substrate like CultLib. + +### Gap to the current implementation (from the report above) + +The implemented path is a weighted plurality over self-asserted, unsigned claim hashes, committed by a trusted shard primary, with no fixed witness set, no failover, no determinism contract tied to witnesses, and happy-path tests only. A proof of concept (for example a browser .io game, which first needs CultMesh browser clients that can write) would need, at minimum: + +- signed observations +- an authenticated witness set whose weights are not self-declared +- explicit handling of disagreement and of a later contradicting quorum +- primary failover +- the shared timeline and commit-reveal described above +- a determinism contract across runtimes + +Its demonstration case is a cheating client being caught and corrected by witnesses. + +## Product direction (operator, 2026-10-27, verbatim) + +"the value is in solving the multiplayer scaling problem for indie developers in an ergonomic fashion. Having one authoritative server is fine, up to a point, but there's no real open source playbook on how to ship an MMO, and existing solutions like SpacetimeDB have a huge ergonomics gap. I want CultMesh to feel like magic: as a garden path, you annotate your domain POCOs and simulation code and declare your authority structure (I see no reason why we couldn't support server-authoritative and trusted co-op as a configurable subset of the much harder problem we're aiming at, feel free to correct me if that's a silly notion), and it'll all just work without the developer needing to understand networking deeply." + +Witness authority is therefore one authority policy inside a garden-path framework, not the product. The ergonomics prior-art pass is `research/cultmesh-garden-path/`. + +Reference game (operator, 2026-10-27): "the .io game, Aetheria later". The garden-path target is proved first on a deliberately small browser .io game; Aetheria is the later proof under real load. diff --git a/samples/eve-browser-network/README.md b/samples/eve-browser-network/README.md index 445bfb9d..62a7980b 100644 --- a/samples/eve-browser-network/README.md +++ b/samples/eve-browser-network/README.md @@ -4,7 +4,7 @@ This is the real-process continuation of `samples/eve-two-runtime`: - one C# provider owns a durable `.cc` counter, the Eve surface, command idempotency, and canonical receipts; -- one real Chromium page leases both documents through `cultmesh-browser` and +- one real Chromium page leases both documents through `@gamecult/cultmesh-browser` and lowers the provider-owned Eve surface; - one retained C# headless client discovers the provider through the same Odin identity, observes the typed counter and canonical receipt ids, survives the diff --git a/samples/eve-browser-network/browser/main.ts b/samples/eve-browser-network/browser/main.ts index fc57e364..76f8817a 100644 --- a/samples/eve-browser-network/browser/main.ts +++ b/samples/eve-browser-network/browser/main.ts @@ -5,7 +5,7 @@ import { CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload, -} from "cultmesh-browser"; +} from "@gamecult/cultmesh-browser"; import { defineDocumentType } from "@gamecult/cultcache-ts/document"; // A payload is read through the definition that says how its bytes are laid out. These are plain-map definitions: the diff --git a/samples/eve-two-runtime/sample.mjs b/samples/eve-two-runtime/sample.mjs index 93ed1dc8..e7dc56c5 100644 --- a/samples/eve-two-runtime/sample.mjs +++ b/samples/eve-two-runtime/sample.mjs @@ -5,7 +5,7 @@ import { join } from "node:path"; import { JSDOM } from "jsdom"; import { z } from "zod"; import { defineDocumentType } from "@gamecult/cultcache-ts"; -import { CultMesh } from "cultmesh-ts"; +import { CultMesh } from "@gamecult/cultmesh-ts"; import { parseEveCommandReceipt, parseEveSurfaceDocument, diff --git a/scripts/run-typescript-workspaces.mjs b/scripts/run-typescript-workspaces.mjs index 0ef34005..3ed4514c 100644 --- a/scripts/run-typescript-workspaces.mjs +++ b/scripts/run-typescript-workspaces.mjs @@ -11,7 +11,7 @@ const npmCli = process.env.npm_execpath ?? // moves when a package is scoped: `8cb3b72` renamed `cultcache-ts` to // `@gamecult/cultcache-ts` and left this entry behind, which broke `build:ts` and // `test:ts` for every workspace from that commit on. -const workspaces = ["@gamecult/cultcache-ts", "cultnet-ts", "cultmesh-ts", "cultmesh-browser"]; +const workspaces = ["@gamecult/cultcache-ts", "@gamecult/cultnet-ts", "@gamecult/cultmesh-ts", "@gamecult/cultmesh-browser"]; const childEnv = process.platform === "win32" ? { ...process.env, diff --git a/scripts/test-declared-dependency-ranges.mjs b/scripts/test-declared-dependency-ranges.mjs new file mode 100644 index 00000000..b528fb75 --- /dev/null +++ b/scripts/test-declared-dependency-ranges.mjs @@ -0,0 +1,155 @@ +// Observes the dependency ranges the TypeScript workspace packages declare, not the workspace symlinks that satisfy them. +// +// Every workspace test resolves a sibling package through node_modules/ -> packages/, so a package that starts +// importing a new export from a sibling passes whatever range it declares. A consumer outside the workspace installs the +// newest published version the range admits. This check builds every tagged release (`-v`) that a declared +// range admits and requires each runtime value the dependent imports from that package to be an export of it. +// +// node scripts/test-declared-dependency-ranges.mjs +// +// It needs the workspace installed (typescript at the repo root) and git with the release tags. A package whose range +// admits no tagged release other than the workspace's own passes, because nothing a consumer could install lacks the export. +// Namespace imports, default imports, type-only imports and subpath imports are not checked. + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const rootRequire = createRequire(join(repoRoot, "package.json")); +const ts = rootRequire("typescript"); +const semver = createRequire(join(npmRoot(), "package.json"))("semver"); + +// npm ships semver, so the check needs no dependency of its own. npm's root is beside the node binary on Windows and under lib/ on Unix. +function npmRoot() { + const candidates = [ + ...(process.env.npm_execpath ? [dirname(dirname(process.env.npm_execpath))] : []), + join(dirname(process.execPath), "node_modules", "npm"), + join(dirname(process.execPath), "..", "lib", "node_modules", "npm"), + ]; + const root = candidates.find((candidate) => existsSync(join(candidate, "package.json"))); + assert.ok(root, `npm's package root was not found in ${candidates.join(", ")}`); + return root; +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function git(...args) { + return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }); +} + +// The runtime names `sourceText` imports from, or re-exports from, exactly `packageName`. +function importedNames(sourceText, packageName) { + const file = ts.createSourceFile("source.ts", sourceText, ts.ScriptTarget.Latest, true); + const names = new Set(); + for (const statement of file.statements) { + const from = statement.moduleSpecifier; + if (!from || !ts.isStringLiteral(from) || from.text !== packageName) { + continue; + } + + let clause; + if (ts.isImportDeclaration(statement) && statement.importClause && !statement.importClause.isTypeOnly) { + clause = statement.importClause.namedBindings; + } else if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) { + clause = statement.exportClause; + } + + if (clause && (ts.isNamedImports(clause) || ts.isNamedExports(clause))) { + for (const element of clause.elements) { + if (!element.isTypeOnly) { + names.add((element.propertyName ?? element.name).text); + } + } + } + } + + return [...names].sort(); +} + +function sourceFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + return sourceFiles(path); + } + + return entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts") ? [path] : []; + }); +} + +// Builds the package as the tag released it, in a scratch directory that borrows the workspace's node_modules. +function buildRelease(directory, tag, scratch) { + const archive = join(scratch, `${tag}.tar`); + git("archive", "--format=tar", "-o", archive, tag, `packages/${directory}`); + const extracted = join(scratch, tag); + mkdirSync(extracted); + execFileSync("tar", ["-xf", archive, "-C", extracted]); + symlinkSync(join(repoRoot, "node_modules"), join(extracted, "node_modules"), "junction"); + const packageRoot = join(extracted, "packages", directory); + execFileSync(process.execPath, [rootRequire.resolve("typescript/bin/tsc"), "-p", "tsconfig.json"], { cwd: packageRoot, stdio: "inherit" }); + return rootRequire(join(packageRoot, "dist", "index.js")); +} + +const workspaces = readJson(join(repoRoot, "package.json")).workspaces.map((directory) => { + const manifest = readJson(join(repoRoot, directory, "package.json")); + return { directory: directory.replace(/^packages\//u, ""), root: join(repoRoot, directory), manifest }; +}); +const byName = new Map(workspaces.map((workspace) => [workspace.manifest.name, workspace])); +const tags = git("tag", "-l").split(/\r?\n/u).filter(Boolean); +assert.ok(tags.length > 0, "this clone has no release tags; fetch them (git fetch --tags) so the admitted releases can be built"); +const scratch = mkdtempSync(join(tmpdir(), "cultlib-declared-ranges-")); +const built = new Map(); +let checked = 0; + +try { + for (const dependent of workspaces) { + for (const [name, range] of Object.entries(dependent.manifest.dependencies ?? {})) { + const dependency = byName.get(name); + if (!dependency) { + continue; + } + + assert.ok( + semver.satisfies(dependency.manifest.version, range), + `${dependent.manifest.name} declares ${name} ${range}, which excludes the workspace's own ${dependency.manifest.version}`, + ); + const src = join(dependent.root, "src"); + const imported = existsSync(src) + ? [...new Set(sourceFiles(src).flatMap((file) => importedNames(readFileSync(file, "utf8"), name)))].sort() + : []; + const prefix = `${dependency.directory}-v`; + const admitted = tags.filter((tag) => { + const version = tag.startsWith(prefix) ? tag.slice(prefix.length) : undefined; + return version !== undefined && semver.valid(version) !== null && semver.satisfies(version, range) && version !== dependency.manifest.version; + }); + for (const tag of admitted) { + if (!built.has(tag)) { + built.set(tag, buildRelease(dependency.directory, tag, scratch)); + } + + const exports = built.get(tag); + const missing = imported.filter((symbol) => !(symbol in exports)); + assert.deepEqual( + missing, + [], + `${dependent.manifest.name} declares ${name} ${range}, which admits ${tag}, and ${tag} does not export ${missing.join(", ")}`, + ); + } + + checked += 1; + console.log(`${dependent.manifest.name} -> ${name} ${range}: ${imported.length} imported names, ${admitted.length} admitted tagged releases checked`); + } + } + + assert.ok(checked > 0, "no workspace package declares a dependency on another workspace package"); + console.log(`Declared dependency ranges: ${checked} checked`); +} finally { + rmSync(scratch, { recursive: true, force: true }); +} diff --git a/scripts/test-typescript-package-closure.mjs b/scripts/test-typescript-package-closure.mjs index 6b0d7661..1fb6c140 100644 --- a/scripts/test-typescript-package-closure.mjs +++ b/scripts/test-typescript-package-closure.mjs @@ -79,8 +79,8 @@ try { const assert = require("node:assert/strict"); const cache = require("@gamecult/cultcache-ts"); const documentEntry = require("@gamecult/cultcache-ts/document"); -const net = require("cultnet-ts"); -const mesh = require("cultmesh-ts"); +const net = require("@gamecult/cultnet-ts"); +const mesh = require("@gamecult/cultmesh-ts"); assert.equal(typeof cache.defineDocumentType, "function"); // The document owner is also an entry of its own, which the browser client imports without the Node-only cache. assert.equal(documentEntry.decodeDocumentPayload, cache.decodeDocumentPayload); @@ -147,8 +147,8 @@ assert.deepEqual(mesh.CultMesh.vec2(3, 4), { x: 3, y: 4 }); writeFileSync(join(consumerRoot, "browser-runtime-smoke.mjs"), ` import assert from "node:assert/strict"; -import { CultMeshBrowserClient, decodeCultNetPayload } from "cultmesh-browser"; -import { parseCultNetMessage } from "cultnet-ts/contracts"; +import { CultMeshBrowserClient, decodeCultNetPayload } from "@gamecult/cultmesh-browser"; +import { parseCultNetMessage } from "@gamecult/cultnet-ts/contracts"; assert.equal(typeof CultMeshBrowserClient.connect, "function"); assert.equal(typeof decodeCultNetPayload, "function"); assert.equal(typeof parseCultNetMessage, "function"); @@ -159,14 +159,14 @@ assert.equal("putDocument" in CultMeshBrowserClient.prototype, false); writeFileSync(join(consumerRoot, "types-smoke.ts"), ` import { defineDocumentType } from "@gamecult/cultcache-ts"; import { decodeDocumentPayload, type DocumentPayload } from "@gamecult/cultcache-ts/document"; -import { CultNetPeer } from "cultnet-ts"; +import { CultNetPeer } from "@gamecult/cultnet-ts"; import { CultMesh, CultMeshProviderSession, type CultMeshProviderIdentity, type CultMeshProviderTransport, type CultMeshVec2, -} from "cultmesh-ts"; +} from "@gamecult/cultmesh-ts"; const point: CultMeshVec2 = CultMesh.vec2(3, 4); const defineDocument: typeof defineDocumentType = defineDocumentType; @@ -209,7 +209,7 @@ import { CultMeshBrowserClient, type CultMeshBrowserIdentity, type CultMeshRawDocumentLeaseOptions, -} from "cultmesh-browser"; +} from "@gamecult/cultmesh-browser"; const identity: CultMeshBrowserIdentity = { verseId: "smoke", diff --git a/scripts/verify-eve-browser-network.mjs b/scripts/verify-eve-browser-network.mjs index 401e4afb..998ee52c 100644 --- a/scripts/verify-eve-browser-network.mjs +++ b/scripts/verify-eve-browser-network.mjs @@ -46,8 +46,8 @@ try { const moduleMap = { "@gamecult/eve-contracts": join(eveRoot, "packages", "eve-contracts", "src", "index.ts"), "@gamecult/eve-browser-lowering": join(eveRoot, "packages", "eve-browser-lowering", "src", "index.ts"), - "cultmesh-browser": join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"), - "cultnet-ts/contracts": join(repoRoot, "packages", "cultnet-ts", "src", "contracts.ts"), + "@gamecult/cultmesh-browser": join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"), + "@gamecult/cultnet-ts/contracts": join(repoRoot, "packages", "cultnet-ts", "src", "contracts.ts"), }; const sampleTsconfig = join(workRoot, "tsconfig.json"); await writeFile(sampleTsconfig, JSON.stringify({ diff --git a/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs index 4fa55076..8d0c7c96 100644 --- a/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs +++ b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs @@ -23,10 +23,10 @@ namespace GameCult.Caching.MessagePack // options, resolvers included, so dictionary keys compare by the bytes the store writes; a document type // deserializes as a registered document, a member type as itself. public static CultCodec CreateCodec(CultDocumentRegistry registry) => new CultCodec( - (value, type, document) => global::MessagePack.MessagePackSerializer.Serialize(type, value, CultDocumentMessagePackSerialization.OptionsFor(document.Assembly)), + (value, type, document) => global::MessagePack.MessagePackSerializer.Serialize(type, value, CultDocumentMessagePackSerialization.OptionsForDocument(document)), (type, document, bytes) => type == document ? CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, registry) - : global::MessagePack.MessagePackSerializer.Deserialize(type, bytes, CultDocumentMessagePackSerialization.OptionsFor(document.Assembly)), + : global::MessagePack.MessagePackSerializer.Deserialize(type, bytes, CultDocumentMessagePackSerialization.OptionsForDocument(document)), CultDocumentMessagePackSerialization.OverlaySlots); // The inspection model over that codec. diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index df7302de..55fd7584 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Buffers; using System.Collections.Concurrent; using System.Collections.Generic; @@ -37,11 +38,23 @@ public static class CultDocumentMessagePackSerialization private const int SchemaCatalogMemberFieldCount = 8; private const int StoreSnapshotFieldCount = 3; - public static readonly MessagePackSerializerOptions Options = Compose(Array.Empty()); + private static readonly (MessagePackSerializerOptions Options, IFormatterResolver[] Ahead) Plain = Compose(Array.Empty()); - private static readonly ConcurrentDictionary AssemblyOptions = new(); + public static readonly MessagePackSerializerOptions Options = Plain.Options; - public static MessagePackSerializerOptions OptionsFor(Assembly documentAssembly) + // Per assembly: the options a document type's bytes are written with, and the resolvers composed ahead of + // StandardResolver, the ones that can claim a type before its [Key] layout does. + private static readonly ConcurrentDictionary AssemblyOptions = new(); + + // Per document type: why its element ids cannot reach disk under the composed resolvers, or null. Types and resolvers + // are fixed for the process, so this is a cache of a pure answer. + private static readonly ConcurrentDictionary DocumentProblems = new(); + + private static readonly MethodInfo GetFormatterMethod = typeof(IFormatterResolver).GetMethod(nameof(IFormatterResolver.GetFormatter))!; + + public static MessagePackSerializerOptions OptionsFor(Assembly documentAssembly) => ComposedFor(documentAssembly).Options; + + private static (MessagePackSerializerOptions Options, IFormatterResolver[] Ahead) ComposedFor(Assembly documentAssembly) { if (documentAssembly == null) throw new ArgumentNullException(nameof(documentAssembly)); return AssemblyOptions.GetOrAdd(documentAssembly, static assembly => @@ -49,19 +62,118 @@ public static class CultDocumentMessagePackSerialization var resolvers = assembly.GetCustomAttributes() .Select(attribute => CreateResolver(attribute.ResolverType)) .ToArray(); - return resolvers.Length == 0 ? Options : Compose(resolvers); + return resolvers.Length == 0 ? Plain : Compose(resolvers); }); } - private static MessagePackSerializerOptions Compose(IFormatterResolver[] consumerResolvers) + // The options a document's bytes are written and read with. A document's list elements carry their ids by their [Key] + // layout, so a resolver ahead of StandardResolver that formats a type on the way to those lists would write them + // without the id: refused here, once per document type, on every encode and decode path. + internal static MessagePackSerializerOptions OptionsForDocument(Type documentType) + { + if (documentType == null) throw new ArgumentNullException(nameof(documentType)); + var (options, ahead) = ComposedFor(documentType.Assembly); + var problem = DocumentProblems.GetOrAdd(documentType, type => ResolverClaim(type, ahead)); + return problem == null ? options : throw new InvalidOperationException(problem); + } + + private static string? ResolverClaim(Type documentType, IFormatterResolver[] ahead) + { + foreach (var type in CultElementIds.Inspect(documentType).IdPath) + { + var generic = GetFormatterMethod.MakeGenericMethod(type); + // A resolver that hands back MessagePack's own formatter for the type writes its [Key] layout: that is no claim, and the + // composite stops there, so no later resolver is asked. StandardResolverAllowPrivate's formatter writes the same layout + // plus non-public [Key] members; those extra members are whole values the walk does not own, so it claims nothing + // either. + object? standard; + try + { + standard = generic.Invoke(StandardResolver.Instance, null); + } + catch (Exception exception) + { + return CannotBuild(documentType, type, (exception.InnerException ?? exception).Message); + } + + if (standard == null) + return CannotBuild(documentType, type, "StandardResolver has no formatter for it"); + + if (WrittenAsCollection(type, generic, standard)) + return $"Cult document {documentType.Name}: MessagePack writes {type.Name} as a collection, but the element-id walk reads it by its [Key] layout, so element ids would be lost or invented. A [MessagePackObject] type that is also a collection (ICollection, IList, IDictionary) is written as a collection; make it one or the other. Or declare [MessagePackFormatter] on the type or member, which makes it one whole value with no element ids."; + + var allowPrivateAsked = false; + object? allowPrivate = null; + foreach (var resolver in ahead) + { + object? formatter; + try + { + formatter = generic.Invoke(resolver, null); + } + catch (Exception exception) + { + return $"Cult document {documentType.Name}: formatter resolver {resolver.GetType().FullName} failed to build a formatter for {type.Name}, which is on the path to a list whose elements carry ids ({(exception.InnerException ?? exception).Message}); a resolver may not throw for such a type."; + } + + if (formatter == null) + continue; + if (ReferenceEquals(formatter, standard)) + break; + if (!allowPrivateAsked) + { + allowPrivateAsked = true; + try + { + allowPrivate = generic.Invoke(StandardResolverAllowPrivate.Instance, null); + } + catch (Exception) + { + allowPrivate = null; + } + } + + if (allowPrivate != null && ReferenceEquals(formatter, allowPrivate)) + break; + return $"Cult document {documentType.Name}: formatter resolver {resolver.GetType().FullName} formats {type.Name}, which is on the path to a list whose elements carry ids, so its element ids would never reach disk. A resolver may not claim such a type; write it by its [Key] layout, keep it out of the document's lists, or declare [MessagePackFormatter] on the type or member, which makes it one whole value with no element ids."; + } + } + + return null; + } + + private static string CannotBuild(Type documentType, Type type, string reason) => + $"Cult document {documentType.Name}: MessagePack cannot build a formatter for {type.Name}, which is on the path to a list whose elements carry ids ({reason})."; + + // The door verifies the walk and never decides ownership. It refuses a proven mismatch: a type the walk reads by key + // (an owner) whose StandardResolver formatter is the very instance the generic resolver's singleton hands out (a keyed + // ICollection or dictionary). Union types walk as owners and the immutable and builtin resolvers claim only framework + // types, so neither can contradict. Any other formatter, a source-generated one among them, passes. + private static bool WrittenAsCollection(Type type, MethodInfo generic, object? standard) => + CultElementIds.WalksAsOwner(type) && ReferenceEquals(Formatter(DynamicGenericResolver.Instance, generic), standard); + + private static object? Formatter(IFormatterResolver resolver, MethodInfo generic) + { + try + { + return generic.Invoke(resolver, null); + } + catch (Exception) + { + return null; + } + } + + 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 // resolver still encodes an arm while the key check runs. - var inner = CompositeResolver.Create( - consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(CultMathExprResolver.Instance).Append(StandardResolver.Instance).ToArray()); - return MessagePackSerializerOptions.Standard + var ahead = consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(CultMathExprResolver.Instance).ToArray(); + var inner = CompositeResolver.Create(ahead.Append(StandardResolver.Instance).ToArray()); + var options = MessagePackSerializerOptions.Standard .WithResolver(CompositeResolver.Create(new CultUnionGuard(inner), inner)) .WithSecurity(CultMessagePackSecurity.Instance); + return (options, ahead); } private static IFormatterResolver CreateResolver(Type resolverType) @@ -83,7 +195,7 @@ public static class CultDocumentMessagePackSerialization { if (registry == null) throw new ArgumentNullException(nameof(registry)); if (value != null) registry.GetRequired(type); - var payload = MessagePackSerializer.Serialize(type, value, OptionsFor(type.Assembly)); + var payload = MessagePackSerializer.Serialize(type, value, OptionsForDocument(type)); CultPayloadDepth.Check(payload, type, PayloadDirection.Write); return payload; } @@ -98,7 +210,7 @@ public static class CultDocumentMessagePackSerialization if (registry == null) throw new ArgumentNullException(nameof(registry)); registry.GetRequired(type); CultPayloadDepth.Check(payload, type, PayloadDirection.Read); - return MessagePackSerializer.Deserialize(type, payload, OptionsFor(type.Assembly)) + return MessagePackSerializer.Deserialize(type, payload, OptionsForDocument(type)) ?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}."); } @@ -133,7 +245,7 @@ public static class CultDocumentMessagePackSerialization elements[slot] = value; } - var buffer = new ArrayBufferWriter(); + var buffer = new global::System.Buffers.ArrayBufferWriter(); var writer = new MessagePackWriter(buffer); writer.WriteArrayHeader(elements.Count); foreach (var element in elements) @@ -160,100 +272,223 @@ public static class CultDocumentMessagePackSerialization return ReadPersistedRecord(ref reader); } - public static byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot) + /// + /// Encodes the snapshot: every catalog entry and record from its fields, so what a caller edited is what is written. + /// + public static byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot) => + SerializeStore( + snapshot.FormatVersion, + snapshot.SchemaCatalog.Select(SerializeCatalogEntry).ToArray(), + snapshot.Records.Select(SerializePersistedRecord).ToArray()); + + // A store file from its parts, each already encoded: what a store carries forward is the bytes its file held, what it writes is + // an encoding of the object. + internal static byte[] SerializeStore(string formatVersion, IReadOnlyList catalogEntries, IReadOnlyList records) { + if (formatVersion == null) + throw new ArgumentException("A store is written under a format version; the snapshot carries none.", nameof(formatVersion)); var buffer = new global::System.Buffers.ArrayBufferWriter(); var writer = new MessagePackWriter(buffer); writer.WriteArrayHeader(StoreSnapshotFieldCount); - writer.Write(snapshot.FormatVersion); - writer.WriteArrayHeader(snapshot.SchemaCatalog.Length); - foreach (var entry in snapshot.SchemaCatalog) + writer.Write(formatVersion); + writer.WriteArrayHeader(catalogEntries.Count); + foreach (var entry in catalogEntries) { - WriteSchemaCatalogEntry(ref writer, entry); + writer.WriteRaw(new ReadOnlySpan(entry)); } - writer.WriteArrayHeader(snapshot.Records.Length); - foreach (var record in snapshot.Records) + writer.WriteArrayHeader(records.Count); + foreach (var record in records) { - WritePersistedRecord(ref writer, record); + writer.WriteRaw(new ReadOnlySpan(record)); } writer.Flush(); return buffer.WrittenSpan.ToArray(); } - public static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload) + /// + /// 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 + /// is not a header string, a store with fewer or more slots, a record whose schema the catalog does not publish, or a body + /// that does not decode are refused with + /// , 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. + /// + public static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload) => ReadStore(payload).Snapshot; + + // The reader behind DeserializeSnapshot, and beside the snapshot the exact bytes the file held for each catalog entry and each + // record (parallel to the snapshot's arrays). The snapshot's objects hold no bytes: a store that carries entries forward keeps + // these slices itself. The stamp decides readability and the body never does: requireReadable, when a store passes its rule, + // runs on slot 0 before the catalog or any record is parsed; the public parser passes none and reads any stamp. + internal static (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) ReadStore( + byte[] payload, + Action? requireReadable = null) { - var reader = new MessagePackReader(payload); - var fieldCount = reader.ReadArrayHeader(); - var snapshot = new CultPersistedStoreSnapshot(); - - if (fieldCount > 0) + try { - snapshot.FormatVersion = reader.ReadString() - ?? throw new NotSupportedException("Store snapshot declares no format version; this runtime reads " + CultPersistedStoreSnapshot.FormatV1 + " and " + CultPersistedStoreSnapshot.FormatV2 + "."); - } + // The stamp is judged from the leading bytes alone, so a body that will not parse can never make a newer store look + // like damage. + JudgeStamp(payload, requireReadable); + RequireOneArray(payload); + var reader = new MessagePackReader(payload); + var fieldCount = reader.ReadArrayHeader(); + if (fieldCount != 0 && fieldCount != StoreSnapshotFieldCount) + { + throw new CultStoreUnreadableException( + $"The store has {fieldCount} top-level slots; this runtime reads {StoreSnapshotFieldCount} (header, schema catalog, records) or none."); + } + + var snapshot = new CultPersistedStoreSnapshot(); + if (fieldCount == 0) + return (snapshot, Array.Empty(), Array.Empty()); + + snapshot.FormatVersion = reader.ReadString()!; - if (fieldCount > 1) - { var catalogCount = reader.ReadArrayHeader(); snapshot.SchemaCatalog = new CultSchemaCatalogEntry[catalogCount]; + var catalogBytes = new byte[catalogCount][]; for (var index = 0; index < catalogCount; index++) { + var start = reader.Position; snapshot.SchemaCatalog[index] = ReadSchemaCatalogEntry(ref reader); + catalogBytes[index] = reader.Sequence.Slice(start, reader.Position).ToArray(); } - } - if (fieldCount > 2) - { var recordCount = reader.ReadArrayHeader(); snapshot.Records = new CultPersistedRecord[recordCount]; + var recordBytes = new byte[recordCount][]; for (var index = 0; index < recordCount; index++) { + var start = reader.Position; snapshot.Records[index] = ReadPersistedRecord(ref reader); + recordBytes[index] = reader.Sequence.Slice(start, reader.Position).ToArray(); } - } - for (var index = StoreSnapshotFieldCount; index < fieldCount; index++) + RequirePublishedSchemas(snapshot); + return (snapshot, catalogBytes, recordBytes); + } + catch (CultStoreUnreadableException) { - reader.Skip(); + throw; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + throw new CultStoreUnreadableException(ex.Message, innerException: ex); + } + } + + // A record whose schema the store's catalog does not publish cannot be read by any runtime that has only the file. + private static void RequirePublishedSchemas(CultPersistedStoreSnapshot snapshot) + { + var published = new HashSet(StringComparer.Ordinal); + foreach (var entry in snapshot.SchemaCatalog) + { + published.Add(entry.SchemaId); + foreach (var compatible in entry.CompatibleSchemaIds) + published.Add(compatible); } - return snapshot; + var unpublished = snapshot.Records.FirstOrDefault(record => !published.Contains(record.SchemaId)); + if (unpublished != null) + { + throw new CultStoreUnreadableException( + $"Record '{unpublished.Key}' names schema '{unpublished.SchemaId}', which the store's schema catalog does not publish."); + } + } + + // 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. + private static void JudgeStamp(byte[] payload, Action? requireReadable) + { + string? stamp; + try + { + var reader = new MessagePackReader(payload); + if (reader.NextMessagePackType != MessagePackType.Array || reader.ReadArrayHeader() == 0) + return; + if (reader.NextMessagePackType == MessagePackType.Binary) + throw new CultStoreUnreadableException("Store snapshot declares its format version as binary; this runtime reads " + CultPersistedStoreSnapshot.FormatV1 + ", " + CultPersistedStoreSnapshot.FormatV2 + " and " + CultPersistedStoreSnapshot.FormatV3 + " as strings."); + if (reader.TryReadNil()) + throw new CultStoreUnreadableException("Store snapshot declares no format version; this runtime reads " + CultPersistedStoreSnapshot.FormatV1 + ", " + CultPersistedStoreSnapshot.FormatV2 + " and " + CultPersistedStoreSnapshot.FormatV3 + "."); + stamp = reader.NextMessagePackType == MessagePackType.String ? reader.ReadString() : null; + } + catch (Exception ex) when (ex is EndOfStreamException or MessagePackSerializationException or InvalidOperationException) + { + return; + } + + if (stamp != null) + requireReadable?.Invoke(stamp); + } + + private static void RequireOneArray(byte[] bytes) + { + var whole = new MessagePackReader(bytes); + try + { + if (whole.NextMessagePackType != MessagePackType.Array) + throw new CultStoreUnreadableException($"The store is a MessagePack {whole.NextMessagePackType}, not an array."); + whole.Skip(); + } + catch (Exception ex) when (ex is EndOfStreamException or MessagePackSerializationException or InvalidOperationException) + { + throw new CultStoreUnreadableException("The store is not one complete MessagePack array.", innerException: ex); + } + + if (!whole.End) + throw new CultStoreUnreadableException("The store has bytes after its MessagePack array."); } /// Refuses a single-file snapshot this runtime cannot read, naming the format or record it found. public static void RequireSingleFileFormat(CultPersistedStoreSnapshot snapshot) { var version = snapshot.FormatVersion; - if (!string.Equals(version, CultPersistedStoreSnapshot.FormatV1, StringComparison.Ordinal) && - !string.Equals(version, CultPersistedStoreSnapshot.FormatV2, StringComparison.Ordinal)) - { - throw new NotSupportedException( - $"Store format {DescribeHeader(version)} is not readable; this runtime reads {CultPersistedStoreSnapshot.FormatV1} and {CultPersistedStoreSnapshot.FormatV2}."); - } + RequireReadableSingleFileStamp(version); - // Only a v2 store may hold a variant: a v1 header over a variant record is a store no reader could trust. + // Only a v2 or v3 store may hold a variant: a v1 header over a variant record is a store no reader could trust. if (string.Equals(version, CultPersistedStoreSnapshot.FormatV1, StringComparison.Ordinal)) { var variant = snapshot.Records.FirstOrDefault(record => record.Variant != null); if (variant != null) { - throw new NotSupportedException( - $"Record '{variant.Key}' (schema '{variant.SchemaId}') is a variant but the store declares {version}; variants need {CultPersistedStoreSnapshot.FormatV2}."); + throw new CultStoreUnreadableException( + $"Record '{variant.Key}' (schema '{variant.SchemaId}') is a variant but the store declares {version}; variants need {CultPersistedStoreSnapshot.FormatV2} or {CultPersistedStoreSnapshot.FormatV3}."); } } } + // The one verdict on a single-file store's stamp, judged on slot 0 before the body is parsed: this runtime reads v1, v2 and v3. + internal static void RequireReadableSingleFileStamp(string version) + { + if (!string.Equals(version, CultPersistedStoreSnapshot.FormatV1, StringComparison.Ordinal) && + !string.Equals(version, CultPersistedStoreSnapshot.FormatV2, StringComparison.Ordinal) && + !string.Equals(version, CultPersistedStoreSnapshot.FormatV3, StringComparison.Ordinal)) + { + throw new CultStoreUnreadableException( + $"Store format {DescribeHeader(version)} is not readable; this runtime reads {CultPersistedStoreSnapshot.FormatV1}, {CultPersistedStoreSnapshot.FormatV2} and {CultPersistedStoreSnapshot.FormatV3}."); + } + } + // A store header as a refusal may show it: echoed in the shape cultcache.store.v, and otherwise described // only by its length, since the bytes are the store's. - private static string DescribeHeader(string header) + internal static string DescribeHeader(string header) { const string prefix = "cultcache.store.v"; - var version = header.StartsWith(prefix, StringComparison.Ordinal) ? header.Substring(prefix.Length) : ""; - return version.Length > 0 && version.All(character => character >= '0' && character <= '9') - ? header - : $"an unrecognised cultcache.store.* header of {System.Text.Encoding.UTF8.GetByteCount(header)} bytes"; + if (header.StartsWith(prefix, StringComparison.Ordinal)) + { + var end = prefix.Length; + while (end < header.Length && header[end] >= '0' && header[end] <= '9') + end++; + var digits = end - prefix.Length; + if (digits is > 0 and <= 6 && (end == header.Length || header[end] == '.')) + { + var named = header.Substring(0, end); + return end == header.Length ? named : $"{named} with a suffix of {System.Text.Encoding.UTF8.GetByteCount(header) - end} bytes"; + } + } + + return $"an unrecognised cultcache.store.* header of {System.Text.Encoding.UTF8.GetByteCount(header)} bytes"; } private static void WritePersistedRecord(ref MessagePackWriter writer, CultPersistedRecord record) @@ -384,6 +619,15 @@ public static class CultDocumentMessagePackSerialization return record; } + internal static byte[] SerializeCatalogEntry(CultSchemaCatalogEntry entry) + { + var buffer = new global::System.Buffers.ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + WriteSchemaCatalogEntry(ref writer, entry); + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + private static void WriteSchemaCatalogEntry(ref MessagePackWriter writer, CultSchemaCatalogEntry entry) { writer.WriteArrayHeader(SchemaCatalogEntryFieldCount); @@ -536,21 +780,31 @@ public class SingleFileMessagePackBackingStore : SingleFileBackingStore { } - protected override byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot) + protected override byte[] SerializeStore(string formatVersion, IReadOnlyList catalogEntries, IReadOnlyList records) { - return CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot); + return CultDocumentMessagePackSerialization.SerializeStore(formatVersion, catalogEntries, records); } - protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data) + protected override byte[] SerializeRecord(CultPersistedRecord record) { - var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(data); - CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot); - return snapshot; + return CultDocumentMessagePackSerialization.SerializePersistedRecord(record); } - protected override byte[] SerializePayload(object document) + protected override byte[] SerializeCatalogEntry(CultSchemaCatalogEntry entry) { - return CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType(), Registry); + return CultDocumentMessagePackSerialization.SerializeCatalogEntry(entry); + } + + protected override (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) ReadStore(byte[] data) + { + var store = CultDocumentMessagePackSerialization.ReadStore(data, CultDocumentMessagePackSerialization.RequireReadableSingleFileStamp); + CultDocumentMessagePackSerialization.RequireSingleFileFormat(store.Snapshot); + return store; + } + + protected override byte[] SerializePayload(object document, Type documentType) + { + return CultDocumentMessagePackSerialization.SerializeUntyped(document, documentType, Registry); } protected override object DeserializePayload(Type documentType, byte[] payload) diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 61e65208..a113db44 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -15,7 +15,9 @@ namespace GameCult.Caching.MessagePack; // A small hot manifest indexes the store; each record lives in one cold content-addressed page. public sealed class DirectoryMessagePackBackingStore : CacheBackingStore { - private const string IndexedFormatVersion = "cultcache.store.v4.directory-content-addressed-pages"; + // The two manifest headers; the shared header decision names them. + private static readonly string IndexedFormatVersion = HeaderFor(holdsIds: false, existingHeader: null, wholeStore: true, directoryStore: true); + private static readonly string IndexedFormatVersionWithIds = HeaderFor(holdsIds: true, existingHeader: null, wholeStore: true, directoryStore: true); // An unleased load that has not settled after this many attempts throws. private const int UnleasedLoadAttempts = 5; private readonly FileInfo _manifestFile; @@ -23,7 +25,6 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore private readonly ConcurrentDictionary _dirtyKeys = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _deletedKeys = new(StringComparer.Ordinal); private readonly HashSet _hydratedKeys = new(StringComparer.Ordinal); - private CultSchemaCatalogEntry[] _durableCatalog = Array.Empty(); public DirectoryMessagePackBackingStore(string manifestPath, string? recordDirectoryPath = null, bool readOnly = false) : base(readOnly) @@ -62,18 +63,19 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore var reports = new List(); var loaded = new Dictionary(StringComparer.Ordinal); - CultSchemaCatalogEntry[] catalog; + var foreign = new List(); for (var attempt = 1; ; attempt++) { loaded.Clear(); reports.Clear(); + foreign.Clear(); // Only pages named by the manifest read under the lease are loaded; orphaned pages are never loaded. With no // lock to lease (none created yet, or a store copied without it) a writer may commit mid-load, so an unleased // load stands only if its manifest is still current; if the manifest moved it reloads, under the lease the // writer created. A page that fails under an unmoved manifest is corruption, not a race. using var lease = AcquireCommitLease(wait: true, create: false); var manifestBytes = ReadManifestBytes(); - var manifest = ParseManifest(manifestBytes); + var (manifest, _) = ParseManifest(manifestBytes); Trace($"manifest records={manifest.Records.Length}"); try { @@ -83,10 +85,10 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore manifest.Records.OrderBy(record => record.Key, StringComparer.Ordinal).ToArray(), manifest.SchemaCatalog, loaded, - reports); + reports, + foreign); if (lease != null || !ManifestMoved(manifestBytes)) { - catalog = manifest.SchemaCatalog; Trace($"indexed-pages loaded={loaded.Count}"); break; } @@ -109,8 +111,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore var arrived = loaded .Where(pair => !Staged(pair.Key) && (!Entries.TryGetValue(pair.Key, out var existing) || - existing.StoredAt != pair.Value.StoredAt || - existing.PersistedSchemaId != pair.Value.PersistedSchemaId)) + !SameStoredIdentity(existing.StoredVersion, pair.Value.StoredVersion))) .Select(pair => pair.Value) .ToArray(); var departed = _hydratedKeys @@ -121,7 +122,6 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore Loaded?.Invoke(arrived, departed); // Adopted only once the cache admitted the load; a refused load leaves the store's durable view untouched. - _durableCatalog = catalog; foreach (var stored in departed) Entries.TryRemove(stored.Key.Value, out _); _hydratedKeys.RemoveWhere(key => !loaded.ContainsKey(key) && !Staged(key)); @@ -131,6 +131,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore _hydratedKeys.Add(key); SetLastSchemaMigrationReports(reports); + SetForeignRecords(foreign); IsDirty = !_dirtyKeys.IsEmpty || !_deletedKeys.IsEmpty; Trace("publish"); } @@ -179,10 +180,24 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore using var commitLease = AcquireCommitLease(wait, create: true); if (commitLease == null) return CultCommitOutcome.Contended; - var manifest = ReadManifest(); - if (!request.ConditionsHold(manifest.Records, Entries.Values)) + var (manifest, catalogBytes) = ReadManifest(); + if (request.HasConditions && !request.ConditionsHold( + manifest.Records.ToDictionary(record => record.Key, record => VersionOf(record.Payload), StringComparer.Ordinal), Entries.Values, ForeignRecords)) return CultCommitOutcome.Mismatch; + RefuseUndeclaredOnDisk( + manifest, + request.Upserts.Concat(request.Deletes).Select(entry => entry.Key.Value).Concat(request.Checks).Concat(StagedKeys), + request.Upserts, + request.Deletes, + request.Checks); + if (request.Upserts.Count == 0 && request.Deletes.Count == 0 && !IsDirty) + { + // Nothing to write: the batch only judged keys it does not hold. + MarkFlushSucceeded(); + return CultCommitOutcome.Committed; + } + var previousEntries = Entries.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); var previousDirtyKeys = _dirtyKeys.Keys.ToArray(); var previousDeletedKeys = _deletedKeys.Keys.ToArray(); @@ -202,7 +217,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore _deletedKeys.TryRemove(entry.Key.Value, out _); } IsDirty = true; - WriteGeneration(manifest); + WriteGeneration(manifest, catalogBytes); return CultCommitOutcome.Committed; } catch @@ -227,20 +242,91 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore ThrowIfReadOnly(); Held(() => { - if (!IsDirty) + if (!IsDirty && StagedChecks.Count == 0) return; Directory.CreateDirectory(_manifestFile.DirectoryName!); using var commitLease = AcquireCommitLease(wait: true, create: true); - WriteGeneration(ReadManifest()); + var (manifest, catalogBytes) = ReadManifest(); + RefuseUndeclaredOnDisk(manifest, StagedKeys, Array.Empty(), Array.Empty(), Array.Empty()); + if (IsDirty) + WriteGeneration(manifest, catalogBytes); + else + MarkFlushSucceeded(); }); } + // Every key the next flush or commit judges: the staged writes and removals, and the staged checks. + private IEnumerable StagedKeys => _deletedKeys.Keys.Concat(_dirtyKeys.Keys).Concat(StagedChecks); + + // A writable store judges a removal of a key its cache does not hold at its next flush or commit, under the commit lease + // (StageChecks); a read-only store has neither, so it answers from its view and never creates the lease. + protected internal override void RefuseRemovalOfUndeclared(IReadOnlyCollection keys) + { + if (IsReadOnly) + base.RefuseRemovalOfUndeclared(keys); + else + StageChecks(keys); + } + + // A read-only store answers a batch's checks from its view and never creates the lease; a writable one reads the manifest + // under the commit lease. + protected internal override CultCommitOutcome JudgeChecks(IReadOnlyCollection keys, bool wait) + { + if (IsReadOnly) + return base.JudgeChecks(keys, wait); + return Held(() => + { + using var commitLease = AcquireCommitLease(wait, create: true); + if (commitLease == null) + return CultCommitOutcome.Contended; + RefuseUndeclaredOnDisk(ReadManifest().Snapshot, keys, Array.Empty(), Array.Empty(), Array.Empty()); + return CultCommitOutcome.Committed; + }); + } + + // A manifest record no registered type claims, listed at the version its index record names. + private CultForeignRecord? ForeignAt(CultPersistedRecord record, IReadOnlyCollection catalog) => + Foreign(record, catalog, VersionOf(record.Payload)); + + // The manifest's other records stay as they are, foreign and read-only ones included; a write that would replace or remove a + // record the manifest holds that the writer does not own is refused before any page is written, by the one rule every + // store shares (CacheBackingStore.UndeclaredAt, CacheBackingStore.RefuseUndeclaredOnDisk). The refused records reload as a + // pull loads them. A key is owned by the entry the write puts there: the batch's upsert, or the staged document. A key it + // removes (the batch's deletes, the staged deletes) or only checks goes to the registry's removal rule. + private void RefuseUndeclaredOnDisk(CultPersistedStoreSnapshot currentManifest, IEnumerable written, IReadOnlyList upserts, IReadOnlyList deletes, IEnumerable checks) + { + CultPersistedRecord? HeldAt(string key) => + currentManifest.Records.FirstOrDefault(record => string.Equals(record.Key, key, StringComparison.Ordinal)); + var batch = upserts.ToDictionary(entry => entry.Key.Value, entry => entry.Descriptor, StringComparer.Ordinal); + CultSchemaCatalogEntry? WrittenAt(string key) => + batch.TryGetValue(key, out var descriptor) ? descriptor.ToCatalogEntry() + : _dirtyKeys.ContainsKey(key) && Entries.TryGetValue(key, out var staged) ? staged.Descriptor.ToCatalogEntry() + : null; + RefuseUndeclaredOnDisk( + written, + currentManifest.SchemaCatalog, + HeldAt, + WrittenAt, + deletes.Select(entry => entry.Key.Value).Concat(checks), + key => HeldAt(key) is { } held && Foreign(held, currentManifest.SchemaCatalog, VersionOf(held.Payload)) == null + ? LoadPage(held, currentManifest.SchemaCatalog, out _, out _) + : null, + key => HeldAt(key) is { } held ? ForeignAt(held, currentManifest.SchemaCatalog) : null, + key => + { + _dirtyKeys.TryRemove(key, out _); + _deletedKeys.TryRemove(key, out _); + IsDirty = !_dirtyKeys.IsEmpty || !_deletedKeys.IsEmpty; + }); + } + // Runs under the commit lease: pages first, then the manifest that names them. - private void WriteGeneration(CultPersistedStoreSnapshot currentManifest) + private void WriteGeneration(CultPersistedStoreSnapshot currentManifest, IReadOnlyDictionary catalogBytes) { Directory.CreateDirectory(_recordDirectory.FullName); var currentIndex = currentManifest.Records.ToDictionary(record => record.Key, record => record, StringComparer.Ordinal); + var replaced = currentManifest.Records.ToDictionary(record => record.Key, record => record.StoredAt, StringComparer.Ordinal); foreach (var key in _deletedKeys.Keys) currentIndex.Remove(key); foreach (var key in _dirtyKeys.Keys) @@ -249,21 +335,9 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore currentIndex[key] = ToIndexRecord(stored); } - var catalogCandidates = currentManifest.SchemaCatalog - .Concat(_durableCatalog) - .Concat(Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry())) - .GroupBy(entry => entry.SchemaId, StringComparer.Ordinal) - .Select(group => group.Last()) - .ToArray(); - var usedSchemaIds = currentIndex.Values.Select(record => record.SchemaId).ToHashSet(StringComparer.Ordinal); - var targetCatalog = catalogCandidates - .Where(entry => usedSchemaIds.Contains(entry.SchemaId)) - .OrderBy(entry => entry.SchemaName, StringComparer.Ordinal) - .ToArray(); var keysToWrite = _dirtyKeys.Keys .OrderBy(value => value, StringComparer.Ordinal) .ToArray(); - var written = new List(keysToWrite.Length); foreach (var key in keysToWrite) { if (!Entries.TryGetValue(key, out var stored)) @@ -271,6 +345,9 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore continue; } + // A record this write replaces is stored later than it: the one re-mint every store kind shares (StoredAtOver). + if (replaced.TryGetValue(key, out var replacedAt)) + stored.StoredAt = StoredAtOver(stored.StoredAt, replacedAt); var record = ToPersistedRecord(stored, document => CultDocumentMessagePackSerialization.SerializeUntyped( document, @@ -278,45 +355,76 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore Registry)); var pagePayload = CultDocumentMessagePackSerialization.SerializePersistedRecord(record); var indexRecord = ToContentAddressedIndexRecord(record, pagePayload); + stored.StoredVersion = VersionOf(indexRecord.Payload); + stored.StoredSchemaId = stored.Descriptor.SchemaId; currentIndex[key] = indexRecord; WriteFileAtomically( ContentAddressedRecordPath(indexRecord), pagePayload); - written.Add(stored); } - WriteManifest(targetCatalog, currentIndex.Values + // The catalog and the header follow the shared write rules: the registered entry publishes only the ids this write put a record + // under, every other record and entry stays as the manifest holds it. + var written = keysToWrite.Where(key => Entries.ContainsKey(key)).ToHashSet(StringComparer.Ordinal); + var targetCatalog = CultSchemaCatalogEntry.Derive( + currentIndex.Values, + written.Select(key => Entries[key].Descriptor).Distinct().Select(descriptor => descriptor.ToCatalogEntry()).ToArray(), + currentManifest.SchemaCatalog) + .OrderBy(entry => entry.SchemaName, StringComparer.Ordinal) + .ThenBy(entry => entry.SchemaId, StringComparer.Ordinal) + .ToArray(); + + var holdsIds = written.ToDictionary(key => key, key => Entries[key].HoldsIds, StringComparer.Ordinal); + var header = HeaderForWrite( + currentManifest.FormatVersion, + holdsIds.Values.Select(holds => (bool?)holds).ToArray(), + copiesAny: currentIndex.Values.Any(record => !written.Contains(record.Key)), + copiedHoldIds: false, // only C# writes a directory store, and it marks by content + directoryStore: true, + holdsVariants: false); + WriteManifest(targetCatalog, catalogBytes, currentIndex.Values .OrderBy(record => record.Key, StringComparer.Ordinal) - .ToArray()); - // The manifest names the new pages: each was written under its own descriptor. - foreach (var stored in written) - MarkWritten(stored); + .ToArray(), header); DeleteUnreferencedRecordPages(currentIndex.Values); - _durableCatalog = targetCatalog; _dirtyKeys.Clear(); _deletedKeys.Clear(); MarkFlushSucceeded(); } - private void WriteManifest(CultSchemaCatalogEntry[] catalog, CultPersistedRecord[] index) - { - var manifest = new CultPersistedStoreSnapshot - { - FormatVersion = IndexedFormatVersion, - SchemaCatalog = catalog, - Records = index - }; - WriteFileAtomically(_manifestFile.FullName, CultDocumentMessagePackSerialization.SerializeSnapshot(manifest)); - } + // The index records are this store's own and are encoded from their fields; a catalog entry the manifest already held is written + // as the slice it held, one the writer built is encoded. + private void WriteManifest( + CultSchemaCatalogEntry[] catalog, + IReadOnlyDictionary catalogBytes, + CultPersistedRecord[] index, + string header) => + WriteFileAtomically( + _manifestFile.FullName, + CultDocumentMessagePackSerialization.SerializeStore( + header, + catalog.Select(entry => catalogBytes.TryGetValue(entry, out var held) ? held : CultDocumentMessagePackSerialization.SerializeCatalogEntry(entry)).ToArray(), + index.Select(CultDocumentMessagePackSerialization.SerializePersistedRecord).ToArray())); private void LoadRecordPages( CultPersistedRecord[] records, IReadOnlyCollection catalogEntries, Dictionary loaded, - List reports) + List reports, + List foreign) { + // A record no registered type claims stays as the manifest names it: its page is never read, and a commit keeps it. + var claimed = new List(records.Length); + foreach (var metadata in records) + { + if (ForeignAt(metadata, catalogEntries) is { } carried) + foreign.Add(carried); + else + claimed.Add(metadata); + } + + records = claimed.ToArray(); var tracePages = string.Equals( Environment.GetEnvironmentVariable("CULTCACHE_TRACE_STARTUP_PHASES"), "1", @@ -342,16 +450,10 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore index => { var started = tracePages ? Stopwatch.GetTimestamp() : 0L; - var metadata = records[index]; - var record = ReadPersistedRecordPage(metadata, out var pagePayload); - storedRecords[index] = ToStoredDocument( - record, - catalogEntries, - (type, payload) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, Registry), - out recordReports[index]); + storedRecords[index] = LoadPage(records[index], catalogEntries, out recordReports[index], out var pageLength); if (tracePages) { - pageBytes[index] = pagePayload.LongLength; + pageBytes[index] = pageLength; pageElapsedTicks[index] = Stopwatch.GetTimestamp() - started; } }); @@ -409,7 +511,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore Payload = HashPayload(pagePayload) }; - private CultPersistedStoreSnapshot ReadManifest() => ParseManifest(ReadManifestBytes()); + private (CultPersistedStoreSnapshot Snapshot, Dictionary CatalogBytes) ReadManifest() => ParseManifest(ReadManifestBytes()); private byte[]? ReadManifestBytes() => File.Exists(_manifestFile.FullName) ? ReadAllBytesShared(_manifestFile.FullName) : null; @@ -435,27 +537,52 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore private InvalidOperationException Unsettled(Exception? last) => new( $"Directory store {_manifestFile.FullName} changed under every one of {UnleasedLoadAttempts} unlocked loads; it did not settle.", last); - // A missing manifest is an empty store. An existing manifest in any other format is refused. - private CultPersistedStoreSnapshot ParseManifest(byte[]? manifestBytes) + // A missing manifest is an empty store. An existing manifest in any other format is refused. Beside the snapshot, the bytes the + // manifest held for each catalog entry, by the entry this read produced: the slices a write carries forward. + private (CultPersistedStoreSnapshot Snapshot, Dictionary CatalogBytes) ParseManifest(byte[]? manifestBytes) { if (manifestBytes == null) { - return new CultPersistedStoreSnapshot + return (new CultPersistedStoreSnapshot { FormatVersion = IndexedFormatVersion, SchemaCatalog = Array.Empty(), Records = Array.Empty() - }; + }, new Dictionary()); } - var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(manifestBytes); - if (!string.Equals(snapshot.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal)) + // The stamp is judged before the manifest body is parsed, so a store stamped newer is refused whatever its body. Every + // write path parses the manifest under its commit lease before it writes a page or the manifest, so a store another + // process has since stamped newer is refused there too, untouched. + (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) read; + try { - throw new InvalidOperationException( - $"Directory store {_manifestFile.FullName} is {snapshot.FormatVersion}; only {IndexedFormatVersion} is readable."); + read = CultDocumentMessagePackSerialization.ReadStore(manifestBytes, RequireReadableStamp); + } + catch (CultStoreUnreadableException ex) when (ex.Path == null) + { + throw new CultStoreUnreadableException(ex.Message, _manifestFile.FullName, ex.InnerException); } - return snapshot; + var snapshot = read.Snapshot; + // An array with no stamp claims no directory format: it parses to the default v1 without the callback above ever seeing a stamp. + RequireReadableStamp(snapshot.FormatVersion); + var catalogBytes = new Dictionary(); + for (var index = 0; index < snapshot.SchemaCatalog.Length; index++) + catalogBytes[snapshot.SchemaCatalog[index]] = read.CatalogBytes[index]; + return (snapshot, catalogBytes); + } + + // The one verdict on a directory manifest's stamp: this runtime reads v4 and v5. + private void RequireReadableStamp(string stamp) + { + if (!string.Equals(stamp, IndexedFormatVersion, StringComparison.Ordinal) && + !string.Equals(stamp, IndexedFormatVersionWithIds, StringComparison.Ordinal)) + { + throw new CultStoreUnreadableException( + $"Directory store {_manifestFile.FullName} is {CultDocumentMessagePackSerialization.DescribeHeader(stamp)}; only {IndexedFormatVersion} and {IndexedFormatVersionWithIds} are readable.", + _manifestFile.FullName); + } } private string ContentAddressedRecordPath(CultPersistedRecord metadata) @@ -496,6 +623,26 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore } } + // One record as a load decodes it, from the page its manifest record names: a pull's pages and a refusal's reload. + private CultStoredDocument LoadPage( + CultPersistedRecord metadata, + IReadOnlyCollection catalog, + out CultSchemaMigrationReport report, + out long pageLength) + { + var record = ReadPersistedRecordPage(metadata, out var pagePayload); + pageLength = pagePayload.LongLength; + var stored = ToStoredDocument( + record, + VersionOf(metadata.Payload), + catalog, + (type, payload) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, Registry), + out report); + // The manifest is this store's record of what is on disk: the stored id and the version a commit condition compares with. + stored.StoredSchemaId = metadata.SchemaId; + return stored; + } + private CultPersistedRecord ReadPersistedRecordPage(CultPersistedRecord metadata, out byte[] pagePayload) { var path = ContentAddressedRecordPath(metadata); diff --git a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj index c2588e24..6261ca3d 100644 --- a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj +++ b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj @@ -11,5 +11,6 @@ + diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index dfc7bff6..d34490f7 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -59,6 +59,13 @@ await cache.PullAllBackingStoresAsync(); - The raw CultNet document lane should treat these payload bytes as already blessed, not decode and re-encode them for sport. +## MessagePack source generator + +NuGet consumers get `[assembly: MessagePackAssumedFormattable(typeof(CultRecordRef<>))]` from the package, so a +`CultRecordRef` member or dictionary key compiles with MessagePack's source generator on and is formatted by +CultLib's resolver; no per-member `[MessagePackFormatter]` is needed. Unity and `ProjectReference` consumers that turn the generator +on write that line themselves (or import `buildTransitive/GameCult.Caching.MessagePack.targets`). + ## CultMath Value Shapes Every public CultMath value type has one MessagePack shape (`CultMathResolver`, diff --git a/src/GameCult.Caching.MessagePack/buildTransitive/GameCult.Caching.MessagePack.targets b/src/GameCult.Caching.MessagePack/buildTransitive/GameCult.Caching.MessagePack.targets new file mode 100644 index 00000000..99388bb9 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/buildTransitive/GameCult.Caching.MessagePack.targets @@ -0,0 +1,9 @@ + + + + + <_Parameter1>typeof(global::GameCult.Caching.CultRecordRef<>) + <_Parameter1_IsLiteral>true + + + diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index 57b5d7fb..93faf63f 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -46,23 +46,71 @@ store. The store commits the batch as one durable step. Nothing in the batch is visible, to the committing flow or to observers, until the store has accepted it; a failed commit changes nothing on disk or in memory. -A batch may carry conditions: per-record `(schemaId, storedAt)` identity, or -the whole store unchanged since it was last loaded. Conditions are evaluated +A batch may carry conditions: per-record version, or the whole store unchanged +since it was last loaded. A record's version is the SHA-256 of the exact bytes the +record is stored as: in a single file the record's own slice of the file, the array +`[key, schemaId, storedAt, payload(, variant)]` exactly as the file holds it, in +whatever legal MessagePack encoding its writer chose (never a re-encoding of the +decoded record); in a directory store the page the manifest indexes. A record being +written has the version of the bytes it is written as. It names the schema id and +storedAt as well as the payload. storedAt is metadata and decides no condition. +Conditions are evaluated under the store's exclusive lock against what is durably on disk. A failed condition is a lost race, not an error: the commit reports it and writes nothing. -**Conditional commit is the only safe multi-process write.** A plain flush and -an unconditional commit are last-writer-wins and write the same bytes: a single -file is replaced by the writer's whole view, a directory manifest takes the -writer's staged keys over the current manifest. An unconditional commit also -persists writes staged earlier and leaves the store clean. Processes sharing a -store must all use conditional commit. +**A flush, a commit and a single-file document write apply a write set.** Under +the store's exclusive lock the writer reads what is durably in the store now, +applies only what it staged (writes and removals) and, for a commit, its batch, +and replaces the store with the result. Every other record, and every catalog +entry the result references, is copied forward as stored: a record's exact bytes +and a catalog entry's exact bytes, unknown fields included, so a copied record +keeps its version. No type interprets a copied record: a write may hash another +record's bytes to compare versions, and reads its header (key, schema id, variant +slot), but never decodes its payload. +A record is encoded from the object the writer hands in, and every other record is copied as the store holds it. The +store that read a file owns that file's bytes: for each stored record, its exact slice of the file and the SHA-256 of that +slice, which is the record's version; likewise each catalog entry's slice. A decoded record or snapshot object carries no +bytes, so an object a caller edited in place is never mistaken for what the file holds. Consequently a record the writer +staged is re-encoded canonically and has the version of its new encoding, even when its content is unchanged, and a +record nobody staged keeps its bytes and its version for as long as no writer stages it. The public snapshot and record +serializers encode every record from its fields. +A record written over one the store holds gets a storedAt later than the one it +replaces. A document changed in place and not staged is not written. + +**In a store that holds no variant**, a plain flush and an unconditional commit +write the same bytes and are last-writer-wins only per staged key. An +unconditional commit also persists writes staged earlier and leaves the store +clean. Conditional commit is how such a writer learns that another writer moved +the keys it depends on. + +**A store that holds a variant, or would after the write, is written only from +what its writer last read.** A variant resolves against other records (its base +chain, and the index values it shares the store with). So before writing, the +writer compares every record's version with the version it last read +or wrote. If any record moved, appeared or disappeared, the whole write is +refused with the typed write conflict, naming the keys it would have written and +the keys that moved, and nothing is written. A writer that has read nothing, +such as CultMesh's single-file document write, never writes into a store holding +a variant. + +**A refused write reloads what it refused.** A refusal (a foreign record at a +key the write names, or a write conflict) reloads the durable view of every +refused and moved key into the cache, as a pull would, and only then forgets the +staged changes at those keys. The cache then serves what the store holds, and a +retry is judged against that. If the reload is itself refused (the store holds a +record this cache cannot load), nothing changes and the refusal carries that +cause. A writer refuses a file whose header it cannot read (truncated, or a +format it does not read) and leaves the file as it was. C# applies this to +single-file stores, directory stores and CultMesh's single-file document writes; +Rust, TypeScript and Python rewrite the store from their own copy until their +parity cuts land. A store that fails to hydrate on open (corrupt bytes, unresolvable schema) makes the open throw and is left byte-identical; it is never silently overwritten. Within one process the lock order is the cache's gate, then the -store's file lock; a store's I/O blocks its cache's readers. +store's gate, then the file lock; nothing under a store's gate enters a cache's +gate. A store's I/O blocks its cache's readers. C# implements batch and conditional commit (`CultCache.Commit`, `TryCommit`, `CultCacheBatch`). Rust implements both. TypeScript and Python implement @@ -116,6 +164,87 @@ These are store-level bookkeeping fields, not per-record domain metadata. The store must embed a catalog entry for every schema referenced by the records it contains. +A reader never recovers a schema from a record's payload: a record whose schema id the +catalog does not publish (by id or as a compatible id) makes the store unreadable, whatever +the payload's first field says. The catalog is the store's own description of itself. + +A writer keeps the same invariant: every record it writes carries a schema id that some catalog +entry it writes publishes, as that entry's id or as one of its compatible ids. A cache stamps a +record it puts with its registered schema's id, and a record it loads under an id its type owns or +declares compatible (see below for one it does not). The cache is the only owner of that stamp: a store writer never +changes the identity of a record it did not receive from its caller, so a record another writer +changed since this cache read it is written back as that writer left it. + +A schema type declares the older ids its records may sit under: `CultDocumentAttribute.CompatibleSchemaIds` +(C#), `compatibleSchemaIds` (TypeScript, Python). The registered catalog entry lists them after its own id, and +the cache restamps a record loaded under one to the type's own id, so the next write of that record carries the +registered id. A record another writer left under a declared id, and that a commit does not rewrite, stays under it +and the registered entry publishes it. Without the declaration such a record is read-only to the cache (see +ownership by declaration, below). Rust has no such declaration. + +A cache resolves a record to a local type by the record's schema id: the registered type that owns +the id, else one that lists it as compatible. The name the catalog entry carries is metadata. It +names a local type only when no local type has the id, and then only to read with: a record so +resolved is read-only (below). That serves a store written by a runtime whose schema ids this one +cannot know (a Rust type's schema id is its entry type). A schema renamed under a stable id +therefore opens in every runtime that holds the id. + +**A cache owns a record when it declares the record's schema id, or when the type that reads it reads +it whole.** A type declares an id by registering it or listing it compatible. A type reads a record +whole when the catalog entry that publishes the id has the same content hash as the type's entry, or +compares compatible with it and leaves no persisted slot unread. An owned record is writable: a write, +relabel or removal is accepted and the write stores it under the writing type's own id (restamp on +write; only a record the cache writes is restamped, the rest is copied forward as stored). A record +stored under any other id that a type can read, through the catalog's schema name or an entry listing a +local id, only by dropping persisted slots is loaded read-only: Get and queries serve it, a write of +another record copies it as stored, and a staged write or removal of it (upsert, variant upsert, +flatten, remove, commit) is refused with the typed schema conflict before anything is staged. +Declaring its id compatible on any registered type makes it writable and is the consent to the loss: +the next write stores it under the writing type's own id and sheds the slots. + +The rule is checked again where the write lands, under the store's lock: a write or removal at a key +whose durable record is not owned, because another writer rewrote it after this cache +loaded, is refused typed, and the record is reloaded as the store holds it, read-only when a type can +read it. This holds for every write through the store, the store's own `Push`, `Delete` and +`CommitBatch` included. Whether a write may replace the record at a key is decided per key by the type it writes there, for a cache +and for a CultMesh single-file write (`WriteDocument`, `WriteRecord`, or `PushAll` on an unattached store) alike, in a single-file store and +a directory store alike: the written type owns the stored record when its entry declares the stored id (its own id, or one it lists +compatible), or when it is the type that reads the stored record's published entry and reads it whole (no persisted slot unread), so an older +id of its own schema name that it reads whole is its own and is restamped. Anything else is refused, another registered type's record +included, and so is a record the written type would read only by dropping persisted slots. No registry enters the decision, `Shared` included: +the answer for a given written entry, stored id and catalog is the same lent a registry or not, and a cache's registered types do not make +each other's records writable. Retyping a key is a removal and an add: in one batch (the removal called before the add) or staged apart, with or without a backing store. The removal half is judged as a removal, so the key is retyped only when the removal rule allows it. A one-batch retype is staged as the held record's eviction and then the add: a watcher of the old type sees Removed and a watcher of the new type sees Added, a global's slot is freed, and every check, validator and store judge sees what it sees when the two are staged apart; this holds for plain records, variants and flattens alike (a Flatten after a Remove in the same batch flattens nothing and is refused). A one-batch retype across two home stores is refused, by the rule that a commit lands in one home store: the removal is in the first store and the add in the second, so the batch spans both. Stage the removal and the add apart, as two commits (not atomic); the key is then in exactly one store. A variant rebased onto a base of another type follows the plain-write rule: where a plain write of the new type would own the variant's record (the new type declares the old id) the rebase commits and the variant becomes that type, and where a plain write would be refused the rebase is refused. A removal or check of a key goes to the +registry's removal rule (a record a registered type declares, or reads whole, may be removed), and a cacheless call, which has no registry, +removes a record only when an entry it writes owns it; a call that writes no entry removes nothing. + +A record that resolves to no local type is a foreign record, for example one of a type this build does not +have, or one renamed under a new schema name without declaring its old id. A store never destroys or relabels it: the store carries it +byte for byte under its own id and its own catalog entry, lists it as foreign (`CacheBackingStore.ForeignRecords` +in C#), and the cache never holds it. Writes of other records proceed and copy it forward as the file holds it. A write that would replace or remove it is refused with the typed schema conflict +naming its key and id, and nothing is written. A removal of a key the cache does not hold removes nothing and is free; the store judges the key at its next flush or commit, under its lock, with the keys that write writes, and refuses the write if it holds a record there under an undeclared id, also when that record arrived after the load. A commit judges such a key in every writable store its cache attaches, each under its own lock: the stores it does not write first, then the store it writes, and a refusal in any of them writes nothing. A judged key is never removed. A read-only store answers at the removal, from its last load. The refused keys are reloaded as the store holds them (a foreign record is not held, and is listed as foreign from then on, so its next write is refused before staging), and their staged changes are forgotten. A write that carries a record forward keeps a marked header marked. Declaring its id on a type claims it at the next load. + +A reader resolves a record's schema id to the entry that owns it, that entry's own `schemaId`, and +only when no entry owns the id to an entry that lists it as a compatible id (the first such entry +in catalog order). + +A writer derives the catalog from the records it writes, and from nothing else. For each distinct +schema id a record carries it chooses exactly one entry that publishes it. For an id the write stages +a record under, that is the entry the writer registered or passed, and a rename or a stale content hash +takes the descriptor's name, hash, members and compatible ids as they are. For every other id it is +the durable entry that owns the id, else one that lists it as compatible, carried as its exact bytes, +unknown trailing fields included. A registered entry never publishes an id the write stages nothing +under. Entries are written as chosen, deduplicated by own id, with +no union of compatible ids and no dependence on the order records arrive in; an entry no record needs +is not written. A write is refused, and the store left as it was, when two entries of one tier share +an own id and disagree on the schema name, when records of different types share a schema id, when a +write would change which type an existing record resolves to (the schema names of the records already +in the store and of the records written differ), or when a record's id is published by no chosen entry. +The retyping refusal has one exception, a rename: when the entries of both the existing records and +the written ones own the id, the registered descriptor wins. (A C# cache stamps every record it +writes with its descriptor's own id, so the retyping refusal cannot arise there.) Each runtime refuses with one typed error naming the id, the +schema names and a record key: `CultSchemaConflictException` (C#), `SchemaConflictError` +(Rust, TypeScript, Python). + This is not decorative paperwork. It is what allows another CultCache implementation to inspect an old store, resolve the schema for each record, and decide whether it can: @@ -212,10 +341,46 @@ Example: The payload is the domain object only. It does not repeat the key, schema id, or stored timestamp. -Under `cultcache.store.v1` a record has exactly these four slots. A reader refuses any -`cultcache.store.*` header it does not know and any record with more slots than its -header allows, naming the version or the record's key and schema id. It never skips a -slot it does not understand. +Under `cultcache.store.v1` and `cultcache.store.v3` a record has exactly these four slots. +A reader refuses any `cultcache.store.*` header it does not know and any record with more +slots than its header allows, naming the version or the record's key and schema id. It +never skips a slot it does not understand. + +The headers are `v1` (no variant, no element ids), `v2` (holds a variant) and `v3` (holds +an element id, variants or not). A single-file store declares `v3` when any record it +holds carries an element id: a non-empty `[CultElementId]` value in a record's document, +or in a variant's override values. A store that cannot read a variant's override values +(no codec attached) counts the variant as holding one. An element id is an ordinary keyed +string in the payload, so only a reader with the record's type can tell; a C# write +decides for the records it encodes and, while the header it found is unmarked, for every +plain record it copies forward that its registry can read, decoding the bytes the file +holds under the store's lock. A copied record it cannot read, and a raw CultMesh payload, +decide nothing. A write that copies any record forward +therefore keeps a `v3` header it found. Only a write that encodes every record the store +will hold decides by content, and drops to `v1` (or `v2` with a variant) when none holds +an id. A directory store's manifest says +`cultcache.store.v5.directory-content-addressed-pages` under the same rule, and `v4` +otherwise. + +A single-file store is exactly one MessagePack array of three slots: the header, the schema +catalog and the records. Every runtime refuses a file that is truncated, has bytes after the +array (a second store appended included), is not an array, has fewer or more than three slots +(a header alone included), whose first slot is not a header it reads, or whose catalog or +records it cannot decode (a record with more slots than its header allows, a variant record +it does not read). A runtime never skips a slot it does not understand. It refuses on open +and on rewrite alike, because the rewrite asks the same full reader as the open. Only nothing at the path is a missing +file, and a missing file and an empty array are an empty store; a zero-byte file is not a store and is refused as unreadable. +In C#, a link whose target is gone and a directory at the path are I/O errors, never an empty store. Within a runtime, every +reader and writer of a single-file store shares that one reader, lock and replace (in C#, CultMesh's single-file document +helpers included), and a catalog entry has one layout. A flush or a commit replaces a file only when the +runtime's own reader opens it, so one verdict covers open, flush and commit, and a file the +runtime cannot read is left as it is. Each runtime refuses with one typed error: +`CultStoreUnreadableException` (C#), `CultCacheStoreUnreadable{path, kind}` (Rust, its kind +telling an undecodable store from an unsupported format), `StoreUnreadableError` (TypeScript, +Python), with the underlying cause attached. Rust, TypeScript and Python also open the legacy envelope +array (an array of `key`/`type`/`payload`/`storedAt` maps) and replace it; C# has no legacy +reader and refuses it. The shared byte vectors and each runtime's verdict are +`tests/vectors/document-variants-c2a/readability/manifest.txt`. A record's payload nests at most 256 MessagePack containers. Every array and map counts, whether a reader decodes it or skips past it, and the outermost array is @@ -243,17 +408,73 @@ fifth slot: `elementId` is `""` unless the step enters an object-list element. `value` is the member's own MessagePack encoding, inline. A top-level Set has one step and `id` `""`; a reader that resolves only that shape refuses the others, naming the variant. -- A store holding at least one variant declares `cultcache.store.v2`; a store holding - none declares `cultcache.store.v1` and is byte-identical to what a runtime without - variants writes. A `v1` header over a variant record is refused. +- A store holding at least one variant declares `cultcache.store.v2` (`v3` when it holds an + element id); a store holding none declares `cultcache.store.v1` (or `v3`) and is + byte-identical to what a runtime without variants writes. A `v1` header over a variant + record is refused. - A variant's resolved document is derived in memory when the cache admits the record or any record on its base chain, and is never written back. A plain write at a variant's key is refused; `Flatten` replaces a variant with a plain record holding its resolved document. -- The C# runtime resolves variants. Every other runtime refuses a `v2` store by name. +- The C# runtime resolves variants. Every other runtime refuses a `v2` store by name, and + refuses a variant record inside a `v3` store by its record key. CultMesh single-file reads, the directory store and CultNet refuse a variant record by name until each has its own cut. +## Element Ids + +Every object that sits in a list, array or dictionary value of a shape the element-id walk +owns carries an id: one string member with an integer `[Key]` and `[CultElementId]`. The +owned shapes are arrays, `IEnumerable` classes and interfaces, `IDictionary<,>` and +`IReadOnlyDictionary<,>` values, and `[MessagePackObject]` or `[Union]` types with integer +keys (a keyed struct included), read through the members MessagePack writes. Union subtypes +are included. The id identifies the element inside its list, so an override can name it (a +path step is `[slot, elementId]`) and a base edit that reorders or inserts elements +retargets nothing. + +Any other shape is one whole value. Its elements carry no element ids: a `[CultElementId]` +member inside it is a plain string, never minted or checked and never counted for `v3`. A +variant replaces it whole (operator, 2026-10-16). + +- A random id is 12 lowercase hex characters. A derived id + (`[CultElementId(nameof(Source))]`, for content-addressed elements) is the + invariant-culture text of its source member, which must be non-empty; it needs no + other format. An id is unique within its list. +- A write mints random ids for unset elements, including inside variant override + values, and refuses a duplicate, a malformed random id or an empty derived source. A + load refuses the same things, naming the record, the list and the id. A record + written before ids existed loads, and its ids are minted from SHA-256 of the record + key and the element's path, so every reader mints the same ids; they reach disk with + the record's next write, or `CultCache.MintElementIds()`. +- A store that holds an id declares the `v3` header (above), so a reader older than ids + refuses it instead of skipping the id slot and rewriting the elements without ids. + A C# write also marks the store when a record it copies forward holds an id, judged + from the file's bytes at the write (above). TypeScript, + Python and Rust read `v3` and keep a `v3` header they found when they rewrite a + store; they carry ids and do not decide when a document carries them. +- An object list's elements are written by their `[Key]` layout, because the id is a + keyed member of the element. Value lists are replaced whole: a list of a non-keyed + class or struct, or of primitives. So is a list of keyed structs, since a struct is a + value and never an element, but an object list inside a keyed struct is walked like any + other. The walk follows declared member types: a member declared `object` is untyped on + disk, and a subclass's extra members under a non-union base-typed member are not written, + so neither is walked. Whole values are `ImmutableArray`, `ArraySegment`, + `Memory`, `KeyValuePair`, tuples, `Lazy`, a class or struct without + `[MessagePackObject]`, a `[DataContract]` type, a string-keyed type, a type or member + declaring `[MessagePackFormatter]`, and a non-public member of a type without + `AllowPrivate`. The members read are an integer `[Key]` with no `[IgnoreMember]`: a + public field or a property whose getter is public, or any visibility up the base chain + under the most derived `[MessagePackObject(AllowPrivate = true)]` declaration. An id member + needs a setter MessagePack calls (public or `init`; any under `AllowPrivate`), so the id + MessagePack writes is read back. Still refused: a bad id member (missing, duplicate, not + a string, no integer `[Key]`, `[MessagePackFormatter]` on it, one MessagePack does not + write or cannot assign, a key shared with another member, a bad derived source), a + concrete union arm that is not keyed, a keyed type that is also a collection + (`ICollection`, `IList`, `IDictionary`: MessagePack writes it as a collection, so C# + refuses it at the first encode or decode when it is on the path to an object list), a + type MessagePack has no formatter for, and a formatter resolver that claims a type on the + path. A formatter MessagePack's source generator emits is not refused. + ## Canonical Semantic Schema Hash `schemaId` should be derived from a canonical semantic schema hash. @@ -408,8 +629,12 @@ The v1 concurrent single-file policy is: 2. Writers take an exclusive sidecar lock derived from the `.cc` path. 3. Writers re-read the current snapshot after taking the lock. 4. Writers using conditional commit evaluate their conditions against the - latest snapshot and write nothing if one fails. A plain flush and an - unconditional commit compare nothing and are last-writer-wins. + latest snapshot and write nothing if one fails. In a store with no variant, a + plain flush and an unconditional commit compare nothing: they apply their + staged writes onto the current snapshot, last-writer-wins per staged key. A + write into a store that holds or would hold a variant compares every record's + version with what the writer last read, and is refused if any moved. One whose + header they cannot read is refused untouched. 5. Writers write a temp file, flush it, atomically replace the `.cc` file, and release the lock. diff --git a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md index f44ff667..dc7e7bea 100644 --- a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md +++ b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md @@ -61,6 +61,12 @@ either way is refused: a get-only property, a readonly field without `AllowPrivate`, or a property whose setter is non-public without `AllowPrivate`, when the constructor does not fill it. +Never reuse a dropped slot; retire it. A write set copies a record the writer did not touch forward as stored bytes, so an +untouched record keeps a dropped member's value in its slot through every write. A type that later puts a new member in that slot +reads the dropped value as its own. The store does not remember retired slots, and a reuse that changes the slot's shape is refused +at load, but a reuse of the same shape reads silently. This is the rule protobuf, Thrift and MessagePack-CSharp give for numbered +fields: leave the slot empty, and take the next unused number for a new member. + ## Which schema a record carries A record is written under the schema of its runtime type. Writing a derived @@ -123,10 +129,15 @@ lie about the payload: - target schema change - name/index lookup semantics change - missing embedded catalog entry -- no compatible local schema candidate This is not negotiable. Better a loud refusal than quiet bit-rot. +## Foreign records + +A record with no local schema candidate is not rejected: the store carries it untouched as a +foreign record and refuses any write that would replace or remove it. See "foreign record" in +`cultcache-persistence-format.md`. + ## Variant overrides A variant's override follows the same outcome as the same member on a plain record; there diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index 93bcd5f5..1a03d4b1 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -81,9 +81,8 @@ which route wrote it. after subscribing with a `Sequence` at or below the read is already in the document and is dropped. CultMesh cache-record mirrors (`ObserveAsync`, `ReactiveAsync`, and `RefreshAsync` on both) do this. -- Not stale-protected: schema-alias handles (`AsSchemaAlias`) get no - `Sequence`; removals do not advance a mirror's applied `Sequence`, and the - mirrors do not surface removals. +- Not stale-protected: removals do not advance a mirror's applied `Sequence`, + and the mirrors do not surface removals. - `AddJournal` registers an ordered admission journal, for audit, replication or a derived index. The cache calls each journal under its gate, right after an admission is applied and before that admission is published, once per @@ -127,10 +126,39 @@ which route wrote it. ## Locking -- One lock order: the cache's gate, then the store's lock. A store attached to a - cache takes that cache's gate as its own lock, so a store's load callback into - the cache cannot take the two out of order. A store must not call `Loaded` - while holding its own lock outside the cache's hold. +- One lock order: the cache's gate, then the store's gate, then the file lock. A + store's gate may be taken inside a cache's gate (the attach; a journal writing + a cacheless file), never the reverse. What the code enforces, at run time and + by `InvalidOperationException`: a thread holds at most one store's gate; + while it holds a store's gate it enters no cache gate it does not already + hold, reads included; a cacheless write made while the thread already holds + that store's gate is refused; and a cache's hold (a write, a flush, an attach) + entered inside another cache's hold is refused. What it does not enforce: a cache + read made inside another cache's hold takes that cache's gate without a + check, so two threads each holding one cache and reading the other can + deadlock. That is open as `variants:follow_up:cross-cache-read-lock-order`; + until it lands, a journal or a getter must not read a second cache. User code + a cacheless call or an attach runs under the store's gate (a document's + getter, a constructor while a copied record is read) is the way into the + refused cases. +- One owner picks the gate a store call holds. A store attached to a cache takes + that cache's gate as its own lock, so a store's load callback into the cache + cannot take the two out of order; a cacheless store takes its own gate. The + store reads which one applies, takes it, and checks the answer again under it: + `Cache` is written only by the attach, holding both gates: it publishes `Cache` + before its pull and takes it back in the same hold if the pull fails. Another + thread's unlocked guess may read the tentative cache, but none runs a body + against it: the guess is checked under that cache's gate, which the attach + holds, and fails once the pull retracts it. Under the store's gate `Cache` is + settled, and a settled cache never changes, because nothing detaches. A call + takes at most three gates: the guessed cache's, the store's, the settled + cache's. The pull reads the registry the attach lent, not `Cache`; a variant it + loads carries no codec, and the cache resolves it with its own. During an + attach the store is attached, so a journal the pull runs that calls the store + is refused as it would be afterwards; a cacheless write that finds a cache + published, during an attach or after it, is refused with the same text. A + store must not call `Loaded` while holding its own lock outside the cache's + hold. - A journal runs under the cache's gate, so a slow journal slows every writer of that cache. It must not enter the cache, and must not wait for anything that needs the cache or its gate: another thread's write, a lock a writer holds. @@ -140,8 +168,6 @@ which route wrote it. CultNet journal, under the cache's gate: it must not call the cache or wait for a thread that does. A store that writes its own cache throws through the journal's reentry guard, and the admission stands. -- A thread holds one cache's gate at a time: entering a cache's hold inside - another cache's hold throws. - A store doing I/O on behalf of its cache (pull, flush, commit) holds the gate, so that cache's readers wait for the I/O. The file lock (`.lock` or the directory commit lease) is always taken inside the gate and released before a @@ -223,25 +249,12 @@ against what is durably on disk at that moment: constrained; unrelated concurrent writes do not fail the commit. Passing any instance other than the one the cache holds throws. - `ExpectUnchanged()`: the store's persisted record set must equal what this - cache last loaded from it, compared as the ordered list of - `(key, schemaId, storedAt)`. Any insert, delete or replace fails it. + cache last loaded from it, compared as the ordered list of `(key, version)`. + Any insert, delete or replace fails it. -Record identity for conditions is `(schemaId, storedAt)`, where `schemaId` is -the id the record is persisted under (`CultStoredDocument.PersistedSchemaId`), -not the one this cache decodes it as. A value change mints a `storedAt` -strictly later than the record it replaces; writers bump a minted timestamp by -one tick when it is not later. An entry loaded under compatible schema drift -keeps its persisted id until its store rewrites it; a store records each -record its completed write rewrote with the protected instance method -`CacheBackingStore.MarkWritten`, which refuses an entry that is not the one -the store holds in its own `Entries` at that key. That guards against a store -marking by mistake an entry it never held; a store that writes another copy -into its own `Entries` and then marks it is trusted to have rewritten that -record, so a mark is the store's own claim. A store that does not mark leaves -the entry's `Expect` failing closed until the next pull. A whole-file flush or -unconditional commit rewrites every record under the writer's current schema -and keeps its `storedAt`, so another cache's `Expect` on a drifted record it -read before that rewrite fails until it re-reads the store. +Record identity for conditions is the record's version, the SHA-256 of its stored +bytes (cultcache-persistence-format.md). Any write that changes a record changes +its version, whatever storedAt it carries. - A failed condition is a lost race, not an error: `Commit` returns false and nothing is written, changed in memory, or published. `TryCommit` makes one @@ -267,17 +280,33 @@ read before that rewrite fails until it re-reads the store. match its content address, at most five times; a pull that has not settled by then throws. -**A plain flush and an unconditional commit are last-writer-wins: per file for -single-file stores, per key for directory stores.** Both run -under the same lock, so two writers never interleave bytes, but they compare -nothing. Only a conditional commit (`Expect` or `ExpectUnchanged`) protects -against another writer; processes sharing a store must all use it. +**In a store holding no variant, a plain flush and an unconditional commit are +last-writer-wins per staged key, in both store kinds.** Both run under the same lock, so two writers never +interleave bytes, but they compare nothing. Only a conditional commit +(`Expect` or `ExpectUnchanged`) tells a writer another writer moved the keys it +depends on. A writer wins only over a file it can read: a single-file store +refuses to replace a file whose header it cannot read, and leaves it as it was. -- A single-file store writes this cache's whole view of the file: a record - another writer added since this cache last pulled is gone. -- A directory store writes this cache's staged keys onto the current manifest: - another writer's unrelated keys survive, a key both wrote holds the last - write. +- A single-file store and a directory store apply this cache's staged writes and + removals onto what the store holds now: another writer's unrelated records + survive as stored, a key both wrote holds the last write. +- In both store kinds, a write or removal at a key whose durable record is under + an id the writer does not declare is refused with the typed schema conflict + before anything is written, and that record is reloaded as the store holds + it: dropped when foreign, held read-only when a type reads it + (cultcache-persistence-format.md, ownership by declaration). +- A removal of a key the cache does not hold travels as a check-only key: a + commit carries it in the request, a flush stages it, and either store kind + judges it under its lock with the keys it writes, never removing it. Every + writable store judges a batch's checks under its own lock: the stores the + batch does not commit through first (`JudgeChecks`), the home last. A batch + that only checks keys writes no file. `ExpectUnchanged` compares the foreign + records a store carries at the versions it read them; a store kind lists a + foreign record through `Foreign` with the version it read. +- A single-file store holding a variant (the directory store refuses variants) + is written only from what its writer last read. A record that moved refuses + the whole write with the typed write conflict, and the refused and moved keys + are reloaded (cultcache-persistence-format.md). - An unconditional commit writes exactly what a flush of the same staged state plus the batch would write. It also persists any single writes staged earlier in that store, and leaves the store clean. @@ -300,6 +329,25 @@ and Rust's open fails rather than waits while a C# writer holds the lock for `X` that `Doc` needs is declared in `Doc`'s assembly. - An assembly that declares nothing, and the store envelope, use the base options. +- A resolver may not claim a type a document's element lists pass through: the list's + element types, the types and members on the way to them, and the collection types + the walk passes (`List`, `T[]`, a collection class, a dictionary on the way). + Those are written by their `[Key]` layout so each element's id reaches disk, and the + codec refuses the document on first encode or decode, naming the resolver and the + type. The formatter `StandardResolver` or `StandardResolverAllowPrivate` hands out for a + type does not claim it: the extra non-public members the `AllowPrivate` formatter writes + are whole values the walk does not own. A type or member declaring + `[MessagePackFormatter]` is a whole value off the path, the supported way to give a type its + own formatter. A value list (`List`, a list of a non-keyed class) is not on the + path, so a resolver may claim it. +- The codec also checks, for every type on that path, that `StandardResolver` does not write it + against the walk. A type read by key may not be written by the generic collection resolver + (a `[MessagePackObject]` class that is also an `ICollection` or dictionary); it is + refused on first encode or decode, naming the type. Any other formatter, such as one + MessagePack's source generator emits, passes: the walk decides which types carry ids and + the door only verifies. It also refuses, typed, a type MessagePack cannot build a formatter + for (it throws, or `StandardResolver` returns none), and a resolver that throws while asked + about one. - Deserialization runs under untrusted-data security. `CultRecordRef` is a permitted dictionary key, compared by its key string. @@ -326,6 +374,37 @@ type name is the CLR full name, the same rule as `System.Int32`. A reader in another runtime needs only the component count and primitive type. CultMath has no serialization; a CultMath encoding, if added, uses this shape. +## Cacheless single-file access + +A single-file store file can be read and written without a cache, through public members of `SingleFileBackingStore` (for +example `SingleFileMessagePackBackingStore`): `ReadStoreSnapshot()`, `WriteRecord(record, catalogEntry, holdsIds, registry)`, +and `WriteDocument(key, document, storedAt, registry)`. Decoding a stored payload needs no file, so it is on the codec, not the store: +`CultCodec.DecodeDocument(key, payload)` (`CultCacheMessagePack.CreateCodec(registry)` makes the codec). + +- `ReadStoreSnapshot()` reads the file once, under the store's gate (or its cache's gate when attached, validated after it is taken), and returns the durable snapshot (records, schema catalog, + format stamp), or null when nothing is at the path. It throws `CultStoreUnreadableException` for a file the reader refuses. +- `WriteRecord` writes one record under the store's lock: the record replaces the one at its key, every other record and catalog + entry is copied forward as stored, and the file is replaced atomically. It takes a payload the caller already encoded, so a + payload it cannot read leaves the header as the file has it (`holdsIds` null). +- `WriteDocument` writes one typed document as a cache write does: it plans element ids, encodes the document as `T` (the type + its schema id and ids are taken from; a derived instance is stored as `T`) with the registry, and stores it as `WriteRecord` + does. A failed write gives the minted ids back. +- `CultCodec.DecodeDocument` decodes a payload read from the store as a cache load does: it mints the ids a load mints and + refuses what a cache load refuses. It reads no file and holds no state. + +The `registry` argument of `WriteRecord` and `WriteDocument` serves that one call: the store attaches it under its gate for the call +and keeps none, so the same call decides the same header on a fresh instance and on one that has served other calls. + +They share everything with a cache's store and nothing is re-implemented beside it: one reader (the stamp is judged before the +body, and a file the reader refuses is refused by a read and by a write alike), the store's lock, the atomic replace, and the +header rule (`holdsIds` true marks a v3 store; false leaves it as the rule decides; null, for a payload the caller cannot read, +keeps whatever the file already says). Ownership by declaration binds a cacheless write too: a record at the key under a schema id +the written catalog entry does not own or list as compatible is refused with `CultSchemaConflictException`, and the file is left as +it was. Element-id planning (`CultElementIds`) is internal to the library: a caller never plans or marks ids, the store does. A store +attached to a cache refuses `WriteRecord` and `WriteDocument` (write through the cache, whose staged writes land through the +same private write) with `InvalidOperationException`. CultMesh's single-file document helpers (`CultMesh.WriteSingleFileDocument`, +`ReadSingleFileDocument` and their payload forms) are built on these members and nothing else of the store. + ## Runtime status | Rule | C# | Rust | TypeScript | Python | diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index bc95ace9..5a0626a6 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -28,6 +28,83 @@ namespace GameCult.Caching public string[] CompatibleSchemaIds { get; set; } = Array.Empty(); public CultSchemaMemberCatalogEntry[] Members { get; set; } = Array.Empty(); + + // The catalog a write leaves, derived from the records the written store holds and nothing else. For each schema id a record + // carries, one entry that publishes it: for an id the write stages a record under, the entry the writer registered (or passed) + // for it, which owns the id; for every other id the arrived (durable) entry that owns it, else the first that lists it as + // compatible (the store that read it carries its bytes as read). A registered entry never publishes an id nothing staged sits under, so + // the caller passes registered entries only for the ids it writes. Entries are written as chosen, never merged; a registered + // descriptor wins over an arrived entry with the same own id. Two arrived entries with the same own id and different schema + // names, or a record no chosen entry publishes, refuse the write. + // Entries that tie are taken in one fixed order, so the catalog does not depend on the order they arrive in. + private static IEnumerable Canonical(IEnumerable entries) => entries + .OrderBy(entry => entry.SchemaName, StringComparer.Ordinal) + .ThenBy(entry => entry.ContentHash, StringComparer.Ordinal) + .ThenBy(entry => string.Join(",", entry.CompatibleSchemaIds), StringComparer.Ordinal); + + internal static CultSchemaCatalogEntry[] Derive( + IEnumerable records, + IReadOnlyCollection registered, + IReadOnlyCollection arrived) + { + var chosen = new Dictionary(StringComparer.Ordinal); + var carried = records.GroupBy(record => record.SchemaId, StringComparer.Ordinal).OrderBy(group => group.Key, StringComparer.Ordinal).ToArray(); + foreach (var group in carried) + { + var schemaId = group.Key; + var recordKey = group.Min(record => record.Key)!; + var ownRegisteredAll = Canonical(registered.Where(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal))).ToArray(); + var ownRegistered = ownRegisteredAll.FirstOrDefault(); + var ownArrived = Canonical(arrived.Where(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal))).ToArray(); + // Entries of one tier that share an own id must be one schema; a registered entry settles a disagreement among arrived ones. + var disagreeing = ownRegisteredAll.Length > 0 ? ownRegisteredAll : ownArrived; + if (disagreeing.Select(entry => entry.SchemaName).Distinct(StringComparer.Ordinal).Count() > 1) + { + var names = disagreeing.Select(entry => entry.SchemaName).Distinct(StringComparer.Ordinal).ToArray(); + throw new CultSchemaConflictException( + $"Schema id '{schemaId}' is claimed by schemas {string.Join(" and ", names.Select(name => $"'{name}'"))}; record '{recordKey}' cannot be written under it.", + schemaId, names, recordKey); + } + + CultSchemaCatalogEntry? pick; + bool isRegistered; + if (ownRegistered != null) + { + pick = ownRegistered; + isRegistered = true; + } + else if (ownArrived.Length > 0) + { + pick = ownArrived[0]; + isRegistered = false; + } + else + { + pick = Canonical(arrived.Where(entry => entry.CompatibleSchemaIds.Contains(schemaId, StringComparer.Ordinal))).FirstOrDefault(); + isRegistered = false; + } + + if (pick == null) + continue; + if (chosen.TryGetValue(pick.SchemaId, out var prior) && prior.Registered && !isRegistered) + continue; + chosen[pick.SchemaId] = (pick, isRegistered); + } + + foreach (var group in carried) + { + if (!chosen.Values.Any(pair => string.Equals(pair.Entry.SchemaId, group.Key, StringComparison.Ordinal) || + pair.Entry.CompatibleSchemaIds.Contains(group.Key, StringComparer.Ordinal))) + { + var recordKey = group.Min(record => record.Key)!; + throw new CultSchemaConflictException( + $"No schema entry publishes schema id '{group.Key}' of record '{recordKey}'.", + group.Key, chosen.Values.Select(pair => pair.Entry.SchemaName).ToArray(), recordKey); + } + } + + return chosen.Values.Select(pair => pair.Entry).ToArray(); + } } public sealed class CultSchemaMemberCatalogEntry @@ -60,12 +137,64 @@ namespace GameCult.Caching public CultVariantDelta? Variant { get; set; } } + // One record a write puts in a single-file store: encoded, with the catalog entry that publishes the id it is stored under and + // whether its document holds element ids (null when the writer cannot see, as for a raw payload). + internal sealed class CultStagedRecord + { + internal CultStagedRecord(CultPersistedRecord record, CultSchemaCatalogEntry entry, bool? holdsIds) + { + Record = record; + Entry = entry; + HoldsIds = holdsIds; + } + + internal CultPersistedRecord Record { get; } + internal CultSchemaCatalogEntry Entry { get; } + internal bool? HoldsIds { get; } + } + + /// + /// A record a store carries untouched because no registered type can read it, for example a record of a type this build does + /// not have, or one renamed under a new schema name without declaring its old id. The store keeps its bytes as the file holds + /// them, a write of another record keeps it, and a write that would replace or remove it is refused with a + /// . The cache never holds it. Declaring its schema id on a type claims it at the next load. + /// A record a type can read (through the catalog's schema name) but only by dropping persisted slots, and no type declares, is + /// not foreign: the cache holds it read-only, and the same refusal applies to a write or removal of it. A record a type reads + /// whole is owned and writable. + /// + public sealed class CultForeignRecord + { + internal CultForeignRecord(string key, string schemaId, string schemaName, string storedAt, string storedVersion) + { + Key = key; + SchemaId = schemaId; + SchemaName = schemaName; + StoredAt = storedAt; + StoredVersion = storedVersion; + } + + public string Key { get; } + + public string SchemaId { get; } + + // The name the store's catalog gives the schema. + public string SchemaName { get; } + + public string StoredAt { get; } + + // The version of the record's stored bytes when its store read it, in that store's spelling, given by the store through + // CacheBackingStore.Foreign. ExpectUnchanged compares it with the version on disk at the commit. + internal string StoredVersion { get; } + } + public sealed class CultPersistedStoreSnapshot { public const string FormatV1 = "cultcache.store.v1"; public const string FormatV2 = "cultcache.store.v2"; + public const string FormatV3 = "cultcache.store.v3"; public string FormatVersion { get; set; } = FormatV1; + public CultSchemaCatalogEntry[] SchemaCatalog { get; set; } = Array.Empty(); public CultPersistedRecord[] Records { get; set; } = Array.Empty(); } @@ -163,6 +292,7 @@ namespace GameCult.Caching string schemaName, string schemaVersion, string schemaId, + IReadOnlyList compatibleSchemaIds, string contentHash, string canonicalSchemaJson, bool isGlobal, @@ -176,6 +306,7 @@ namespace GameCult.Caching SchemaName = schemaName; SchemaVersion = schemaVersion; SchemaId = schemaId; + CompatibleSchemaIds = compatibleSchemaIds; ContentHash = contentHash; CanonicalSchemaJson = canonicalSchemaJson; IsGlobal = isGlobal; @@ -201,6 +332,9 @@ namespace GameCult.Caching public string SchemaName { get; } public string SchemaVersion { get; } public string SchemaId { get; } + + /// The ids besides this type declared through . + public IReadOnlyList CompatibleSchemaIds { get; } public string ContentHash { get; } public string CanonicalSchemaJson { get; } public bool IsGlobal { get; } @@ -292,7 +426,7 @@ namespace GameCult.Caching SchemaVersion = SchemaVersion, ContentHash = ContentHash, CanonicalSchemaJson = CanonicalSchemaJson, - CompatibleSchemaIds = [SchemaId], + CompatibleSchemaIds = [SchemaId, .. CompatibleSchemaIds], Members = Members .OrderBy(member => member.Slot) .Select(member => new CultSchemaMemberCatalogEntry @@ -338,19 +472,13 @@ namespace GameCult.Caching string storedAt, CultDocumentDescriptor descriptor, object document) - : this(key, storedAt, descriptor, null, document, new PersistedIdentity(descriptor.SchemaId)) - { - } - - // A plain record its store read: persistedSchemaId is the schema id its durable bytes are stored under. - internal CultStoredDocument( - CultRecordKey key, - string storedAt, - CultDocumentDescriptor descriptor, - object document, - string persistedSchemaId) - : this(key, storedAt, descriptor, null, document, new PersistedIdentity(persistedSchemaId)) { + Key = key; + StoredAt = storedAt; + Descriptor = descriptor; + StoredSchemaId = descriptor.SchemaId; + StoredVersion = UnstoredVersion(); + _document = document; } // A variant: the delta is what is stored; the cache resolves it against its base in the hold that admits it. @@ -360,43 +488,54 @@ namespace GameCult.Caching CultDocumentDescriptor descriptor, CultVariantDelta variant, object? resolved, - string persistedSchemaId) - : this(key, storedAt, descriptor, variant, resolved, new PersistedIdentity(persistedSchemaId)) - { - } - - private CultStoredDocument( - CultRecordKey key, - string storedAt, - CultDocumentDescriptor descriptor, - CultVariantDelta? variant, - object? document, - PersistedIdentity persisted) + CultCodec? codec) { Key = key; StoredAt = storedAt; Descriptor = descriptor; + StoredSchemaId = descriptor.SchemaId; + StoredVersion = UnstoredVersion(); Variant = variant; - _document = document; - _persisted = persisted; + _document = resolved; + _codec = codec; } private readonly object? _document; - // One record's, shared by a variant and its resolved view: the store holds the one and the cache the other. - private readonly PersistedIdentity _persisted; + // What reads a variant's override values; null for a plain record. + private readonly CultCodec? _codec; public CultRecordKey Key { get; } - public string StoredAt { get; } + + // When the record was stored: metadata. A write that stores the record mints it a storedAt later than the one it replaces; + // no condition reads it. + public string StoredAt { get; internal set; } public CultDocumentDescriptor Descriptor { get; } - /// - /// The schema id this record is durably stored under, as its backing store last read or wrote it. It differs from - /// only for a record loaded under compatible schema drift that its - /// store has not rewritten since; a store records its rewrite of an entry it holds with . - /// Conditional commit compares this id and with the disk. - /// - public string PersistedSchemaId => _persisted.SchemaId; + // The schema id the record carries in its store: the id it was loaded under, which may be an older or foreign id that + // resolved to Descriptor, until a write of this record puts it under Descriptor's id (a write of an owned record moves it). + public string StoredSchemaId { get; internal set; } + + // True when the schema this record was loaded under left none of its persisted slots unread by Descriptor, so the type reads + // it whole and owns it (CultDocumentRegistry.Owns). Set by ToStoredDocument from the schema comparison's ignored slots, + // before any variant override is shed. A record this cache constructed or wrote is stored under Descriptor's own id, which + // the registry declares, so only a loaded record under another id is ever asked. The staging half asks it; + // ConditionsHold never reads it. Only the store's loaded view carries it: the cache's resolved copies and flattens are never + // asked. + internal bool StoredSchemaReadWhole { get; set; } + + // The catalog entry that publishes StoredSchemaId in the store this record was loaded from, with the id it was published for, + // so staging can ask WrittenOwns the question the store's judge asks, over the catalog the record came with. Null for a record + // this cache constructed or wrote, which is stored under Descriptor's own id and published by Descriptor's entry. A write that + // relabels a loaded record in place (minting its element ids, then a flush) moves StoredSchemaId and leaves this behind, so a + // reader uses it only while the id still matches StoredSchemaId. + internal (string Id, CultSchemaCatalogEntry Entry)? StoredPublishedBy { get; set; } + + // Names the bytes this record was read from or written as: the SHA-256 of its persisted encoding, set by the store that + // read or wrote them. A record no store has read or written has a version of its own that equals nothing else. + internal string StoredVersion { get; set; } + + private static string UnstoredVersion() => "unstored:" + Guid.NewGuid().ToString("N"); // The complete document. For a variant, the resolved view: derived, never written back. public object Document => _document ?? @@ -408,19 +547,84 @@ namespace GameCult.Caching // A plain record staged by Flatten: the one way a plain record may replace a variant at its key. internal bool Flattens { get; set; } - internal bool IsResolved => _document != null; + // A plain record a batch staged after a Flatten at its key: it replaces the plain record that flatten left, so it may land on the variant the cache holds. + internal bool ReplacesFlattened { get; set; } - internal CultStoredDocument Resolved(object document) => new(Key, StoredAt, Descriptor, Variant!, document, _persisted); + // True while this record holds element ids minted when it loaded, that no write has persisted (see MintElementIds). + internal bool IdsInMemoryOnly { get; set; } - // Reached only through CacheBackingStore.MarkWritten, the one way a store records a rewrite. - internal void MarkWritten() => _persisted.SchemaId = Descriptor.SchemaId; - - private sealed class PersistedIdentity + // True when some element this record persists holds a non-empty id, and the only thing a store reads to mark its header. + // It is read off the stored form each time it is asked, so a store asks it as it writes: a document changed after it was + // admitted, or loaded and changed in place, writes the header its content needs. A plain record persists its document; a + // variant persists its overrides, so it holds an id when an override value does (its base's ids are the base's). A + // variant built without a codec (a store loaded it with no cache attached) cannot see into its values and counts as holding + // one, so a store never writes an unmarked header over ids it could not see. + public bool HoldsIds { - public PersistedIdentity(string schemaId) => SchemaId = schemaId; - - public string SchemaId; + get + { + if (Variant == null) + return CultElementIds.Holds(Descriptor.DocumentType, _document); + if (_codec == null) + return true; + return Variant.Overrides.Any(entry => + { + var memberType = Descriptor.RichMembers.First(member => member.Slot == entry.Path[0].Slot).MemberType; + return CultElementIds.Holds(memberType, _codec.Deserialize(memberType, Descriptor.DocumentType, entry.Value)); + }); + } } + + internal CultStoredDocument Resolved(object document, CultVariantDelta delta, bool idsInMemoryOnly, CultCodec codec) => + new(Key, StoredAt, Descriptor, delta, document, codec) { IdsInMemoryOnly = idsInMemoryOnly, StoredSchemaId = StoredSchemaId, StoredVersion = StoredVersion }; + } + + // What the one claimant precedence (CultDocumentRegistry.Claimant) asks of the entries a reader holds: the entry owning an id, + // the entries listing an id compatible, the entries of a schema name, and how to name one in a refusal. + internal interface IClaimantLookup where T : class + { + T? Owner(string schemaId); + IReadOnlyList Listing(string schemaId); + IReadOnlyList Named(string schemaName); + ClaimantFacts Describe(T claimant); + } + + internal readonly struct ClaimantFacts + { + public ClaimantFacts(string schemaName, string schemaVersion, string typeLabel) + { + SchemaName = schemaName; + SchemaVersion = schemaVersion; + TypeLabel = typeLabel; + } + + public string SchemaName { get; } + public string SchemaVersion { get; } + public string TypeLabel { get; } + } + + // The lookup a write's ownership asks: only the catalog entries the call writes. No registry enters it, so the answer is the + // written type's own (variants:ruling:write-ownership-by-written-type). + internal sealed class CatalogEntryLookup : IClaimantLookup + { + private readonly IReadOnlyCollection _entries; + + public CatalogEntryLookup(IReadOnlyCollection entries) + { + _entries = entries; + } + + public CultSchemaCatalogEntry? Owner(string schemaId) => + _entries.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal)); + + public IReadOnlyList Listing(string schemaId) => + _entries.Where(entry => entry.CompatibleSchemaIds.Contains(schemaId, StringComparer.Ordinal)).ToArray(); + + public IReadOnlyList Named(string schemaName) => + _entries.Where(entry => string.Equals(entry.SchemaName, schemaName, StringComparison.Ordinal)).ToArray(); + + public ClaimantFacts Describe(CultSchemaCatalogEntry claimant) => + new(claimant.SchemaName, claimant.SchemaVersion, claimant.SchemaId); } public sealed class CultDocumentRegistry @@ -528,52 +732,178 @@ namespace GameCult.Caching internal CultSchemaResolutionResult ResolvePersistedSchemaDetailed(string schemaId, IReadOnlyCollection catalog) { - var indexes = _indexes; - if (indexes.BySchemaId.TryGetValue(schemaId, out var exact)) + var local = Claimant(schemaId, catalog, out var persisted); + if (local == null) + throw new InvalidOperationException( + $"No local CultCache schema matches persisted schema '{persisted!.SchemaName}' ({schemaId})."); + if (persisted == null) { return new CultSchemaResolutionResult( - exact, + local, new CultSchemaMigrationReport { PersistedSchemaId = schemaId, - LocalSchemaId = exact.SchemaId, - PersistedSchemaName = exact.SchemaName, - LocalSchemaName = exact.SchemaName, + LocalSchemaId = local.SchemaId, + PersistedSchemaName = local.SchemaName, + LocalSchemaName = local.SchemaName, Kind = CultSchemaMigrationKind.Exact }); } - var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal)); + return BuildCompatibleResolutionResult(persisted, local); + } + + // The registered type a record under this schema id belongs to, or null when no registered type claims it: a foreign record, + // which a store carries untouched. Deciding this never decodes the record or compares shapes, so a store can classify what a + // file holds without reading a payload. persisted is the catalog entry the record resolves through, and null when a + // registered type owns the id. Claimants that cannot be told apart refuse. Branches past the listers (a local id the persisted + // entry lists, then the schema name) pick a type to read with. They confer no ownership: Owns decides what a writer may write. + internal CultDocumentDescriptor? Claimant( + string schemaId, + IReadOnlyCollection catalog, + out CultSchemaCatalogEntry? persisted) => + Claimant(schemaId, catalog, _indexes, out persisted); + + // The only claimant precedence: which entry reads a persisted catalog entry, over a lookup of the entries a reader holds (a + // registry's types, or the entries a cacheless writer writes). Exact id, then the one entry listing the id, then a local id the + // persisted entry lists, then the schema name. Every caller, cache or cacheless, asks it; none writes a precedence of its own. + internal static T? Claimant( + string schemaId, + IReadOnlyCollection catalog, + IClaimantLookup lookup, + out CultSchemaCatalogEntry? persisted) + where T : class + { + persisted = null; + if (lookup.Owner(schemaId) is { } exact) + return exact; + + persisted = PublishedBy(schemaId, catalog); if (persisted == null) { throw new InvalidOperationException($"Persisted schema '{schemaId}' is not present in the embedded catalog."); } - foreach (var compatibleSchemaId in persisted.CompatibleSchemaIds.Where(candidate => !string.IsNullOrWhiteSpace(candidate))) + // No type owns the id, so the one type that lists it is the record's type. Two listers and no owner cannot say which. + var listers = lookup.Listing(schemaId); + if (listers.Count > 0) { - if (indexes.BySchemaId.TryGetValue(compatibleSchemaId, out var compatibleLocal)) - { - return BuildCompatibleResolutionResult(persisted, compatibleLocal); - } + if (listers.Count > 1) + throw new CultSchemaConflictException( + $"CultCache schema id '{schemaId}' is owned by no registered type and declared compatible by several " + + $"({string.Join(", ", listers.Select(lister => lookup.Describe(lister).TypeLabel))}), so a record under it resolves to none. " + + "Register the type that owns the id, or declare it on one type.", + schemaId, + listers.Select(lister => lookup.Describe(lister).SchemaName).ToArray(), + string.Empty); + return listers[0]; } - if (indexes.BySchemaName.TryGetValue(persisted.SchemaName, out var localVersions)) + foreach (var compatibleSchemaId in persisted.CompatibleSchemaIds.Where(candidate => !string.IsNullOrWhiteSpace(candidate))) { - if (localVersions.Length == 1) - { - return BuildCompatibleResolutionResult(persisted, localVersions[0]); - } + if (lookup.Owner(compatibleSchemaId) is { } compatibleLocal) + return compatibleLocal; + } + + var localVersions = lookup.Named(persisted.SchemaName); + if (localVersions.Count > 0) + { + if (localVersions.Count == 1) + return localVersions[0]; throw new InvalidOperationException( $"CultCache schema name '{persisted.SchemaName}' is ambiguous across local versions " + - $"[{string.Join(", ", localVersions.Select(candidate => candidate.SchemaVersion).OrderBy(version => version, StringComparer.Ordinal))}]. " + + $"[{string.Join(", ", localVersions.Select(candidate => lookup.Describe(candidate).SchemaVersion).OrderBy(version => version, StringComparer.Ordinal))}]. " + "Persisted schema compatibility must identify an explicit schema id."); } - throw new InvalidOperationException( - $"No local CultCache schema matches persisted schema '{persisted.SchemaName}' ({schemaId})."); + return null; } + // The catalog entry a record under this id is published by: the entry that owns the id, else the first that lists it compatible. + internal static CultSchemaCatalogEntry? PublishedBy(string schemaId, IReadOnlyCollection catalog) => + catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal)) + ?? catalog.FirstOrDefault(entry => entry.CompatibleSchemaIds.Contains(schemaId, StringComparer.Ordinal)); + + // Whether a removal, or a check of a key, may proceed over a record stored under this id, and the load-time marking of a + // record read-only: a registered type declares the id (owns it or lists it compatible), or the type that reads it reads it + // whole (variants:ruling:drift-record-write-ownership). It never decides an upsert: WrittenOwns does + // (variants:ruling:write-ownership-by-written-type). Read live, so a type registered later that declares the id claims its + // records at their next removal. A record read only by dropping slots is not owned: declaring its id compatible is the + // consent to the loss. Header only: the persisted catalog entry that publishes the id is compared with the claimant's + // entry, never a payload. A claimant that cannot be named (ambiguous, id missing from the catalog, shapes incompatible) owns + // nothing. + internal bool Owns(string schemaId, IReadOnlyCollection catalog) => + Owns(schemaId, catalog, Declared, _indexes, descriptor => descriptor.ToCatalogEntry()); + + // The one answer to whether an upsert of the written type may replace the record stored under this id at its key, for a cache + // and a cacheless writer, a single-file store and a directory store alike. The written entry owns the record when it declares + // the stored id (its own id, or one it lists compatible), or when it is the type that reads the record's published entry + // (the one Claimant precedence over this one entry) and reads it whole, so an older id of its own schema name that it reads + // whole is its own (variants:ruling:own-entry-reads-whole). Anything else, another registered type's record included, is + // refused: retyping a key is a removal and an add (variants:ruling:cache-refuses-retype). No registry enters it. + internal static bool WrittenOwns( + CultSchemaCatalogEntry written, + string storedId, + IReadOnlyCollection catalog) => + Owns(storedId, catalog, id => DeclaredBy(written, id), new CatalogEntryLookup(new[] { written }), entry => entry); + + // WrittenOwns' declared half: the entry's own id or one it lists compatible. + internal static bool DeclaredBy(CultSchemaCatalogEntry written, string storedId) => + string.Equals(written.SchemaId, storedId, StringComparison.Ordinal) || + written.CompatibleSchemaIds.Contains(storedId, StringComparer.Ordinal); + + // The rule itself: declared, or the claimant (picked by the one Claimant over a lookup) reads the record's published entry + // whole. The lookup is a registry's indexes (the removal rule) or the one entry a write puts at the key (WrittenOwns). + internal static bool Owns( + string schemaId, + IReadOnlyCollection catalog, + Func declared, + IClaimantLookup lookup, + Func entryOf) + where T : class + { + if (declared(schemaId)) + return true; + try + { + var claimant = Claimant(schemaId, catalog, lookup, out var persisted); + if (persisted == null || claimant == null) + return false; + return ReadsWhole(persisted, entryOf(claimant)); + } + catch (InvalidOperationException) + { + return false; + } + } + + // Owns' declared half: a registered type owns the id or lists it compatible. + internal bool Declared(string schemaId) + { + var indexes = _indexes; + return indexes.BySchemaId.ContainsKey(schemaId) || indexes.ByCompatibleSchemaId.ContainsKey(schemaId); + } + + // A persisted entry a local entry reads whole: identical content, or compatible shapes that leave no persisted slot ignored. + internal static bool ReadsWhole(CultSchemaCatalogEntry persisted, CultSchemaCatalogEntry local) => + string.Equals(persisted.ContentHash, local.ContentHash, StringComparison.Ordinal) || + CompareSchemaShapes(ShapeOf(persisted), ShapeOf(local)) is { IsCompatible: true, IgnoredExtraSlots.Length: 0 }; + + private static CanonicalSchemaShape ShapeOf(CultSchemaCatalogEntry entry) => + new( + entry.SchemaName, + entry.SchemaVersion, + entry.Members.Select(member => new CanonicalSchemaMember( + member.Slot, + member.MemberName, + member.TypeName, + member.IsReference, + member.IsMany, + member.TargetSchemaName, + member.IsName, + member.IndexAlias)).ToArray()); + private CultDocumentDescriptor RegisterDescriptor(CultDocumentDescriptor descriptor) { lock (_registrationGate) @@ -596,63 +926,46 @@ namespace GameCult.Caching if (indexes.ByType.TryGetValue(descriptor.DocumentType, out var registered)) return registered; + // One type owns a schema, whatever its declaration. Its id hashes the schema's name, version and members, so a second + // type of the same name and version is refused here whether or not it hashes alike. Other types may list the id as + // compatible beside its owner, as a v2 class does that keeps v1's class registered: a record under the id resolves to + // its owner, and to a lister only when nothing owns it. Neither depends on the order types were registered in. indexes.BySchemaName.TryGetValue(descriptor.SchemaName, out var schemaNameVersions); var schemaVersionOwner = schemaNameVersions?.FirstOrDefault(candidate => string.Equals(candidate.SchemaVersion, descriptor.SchemaVersion, StringComparison.Ordinal)); if (schemaVersionOwner != null) - { - if (IsExactWireAlias(schemaVersionOwner, descriptor)) - { - indexes.ByType[descriptor.DocumentType] = descriptor; - return descriptor; - } - - throw DuplicateSchemaRegistration( - $"schema name '{descriptor.SchemaName}' version '{descriptor.SchemaVersion}'", - schemaVersionOwner.DocumentType, - descriptor.DocumentType); - } - - if (indexes.BySchemaId.TryGetValue(descriptor.SchemaId, out var schemaIdOwner)) - { - throw DuplicateSchemaRegistration( - $"schema id '{descriptor.SchemaId}'", - schemaIdOwner.DocumentType, - descriptor.DocumentType); - } + throw SchemaClaimedTwice(schemaVersionOwner, descriptor); indexes.ByType[descriptor.DocumentType] = descriptor; indexes.BySchemaId[descriptor.SchemaId] = descriptor; + foreach (var compatibleSchemaId in descriptor.CompatibleSchemaIds) + indexes.ByCompatibleSchemaId[compatibleSchemaId] = indexes.ByCompatibleSchemaId.TryGetValue(compatibleSchemaId, out var listers) + ? [.. listers, descriptor] + : [descriptor]; indexes.BySchemaName[descriptor.SchemaName] = schemaNameVersions == null ? [descriptor] : [.. schemaNameVersions, descriptor]; return descriptor; } - private static bool IsExactWireAlias( - CultDocumentDescriptor canonical, - CultDocumentDescriptor candidate) => - string.Equals(canonical.SchemaName, candidate.SchemaName, StringComparison.Ordinal) && - string.Equals(canonical.SchemaVersion, candidate.SchemaVersion, StringComparison.Ordinal) && - string.Equals(canonical.SchemaId, candidate.SchemaId, StringComparison.Ordinal) && - string.Equals(canonical.ContentHash, candidate.ContentHash, StringComparison.Ordinal) && - string.Equals(canonical.CanonicalSchemaJson, candidate.CanonicalSchemaJson, StringComparison.Ordinal); - - private static InvalidOperationException DuplicateSchemaRegistration( - string identity, - Type existingType, - Type claimedType) => + private static CultSchemaConflictException SchemaClaimedTwice( + CultDocumentDescriptor existing, + CultDocumentDescriptor claimed) => new( - $"CultCache {identity} is already registered to CLR type " + - $"'{existingType.FullName}' and cannot also be claimed by '{claimedType.FullName}'. " + - "Schema compatibility must be declared through explicit persisted-schema alias metadata."); + $"CultCache schema '{claimed.SchemaName}' version '{claimed.SchemaVersion}' is already registered to CLR type " + + $"'{existing.DocumentType.FullName}' and cannot also be claimed by '{claimed.DocumentType.FullName}'. " + + "A schema is owned by one registered type; others may only declare its id compatible.", + claimed.SchemaId, + new[] { existing.SchemaName, claimed.SchemaName }, + string.Empty); - private sealed class RegistryIndexes + private sealed class RegistryIndexes : IClaimantLookup { public RegistryIndexes() { ByType = new Dictionary(); BySchemaId = new Dictionary(StringComparer.Ordinal); + ByCompatibleSchemaId = new Dictionary(StringComparer.Ordinal); BySchemaName = new Dictionary(StringComparer.Ordinal); } @@ -660,12 +973,26 @@ namespace GameCult.Caching { ByType = new Dictionary(source.ByType); BySchemaId = new Dictionary(source.BySchemaId, StringComparer.Ordinal); + ByCompatibleSchemaId = new Dictionary(source.ByCompatibleSchemaId, StringComparer.Ordinal); BySchemaName = new Dictionary(source.BySchemaName, StringComparer.Ordinal); } public Dictionary ByType { get; } public Dictionary BySchemaId { get; } + // Every type declaring the id compatible, in registration order; resolution never depends on that order. + public Dictionary ByCompatibleSchemaId { get; } public Dictionary BySchemaName { get; } + + public CultDocumentDescriptor? Owner(string schemaId) => BySchemaId.GetValueOrDefault(schemaId); + + public IReadOnlyList Listing(string schemaId) => + ByCompatibleSchemaId.GetValueOrDefault(schemaId) ?? Array.Empty(); + + public IReadOnlyList Named(string schemaName) => + BySchemaName.GetValueOrDefault(schemaName) ?? Array.Empty(); + + public ClaimantFacts Describe(CultDocumentDescriptor claimant) => + new(claimant.SchemaName, claimant.SchemaVersion, claimant.DocumentType.FullName ?? string.Empty); } private static CultDocumentDescriptor BuildDescriptor(Type type) @@ -674,6 +1001,9 @@ namespace GameCult.Caching ?? throw new InvalidOperationException( $"Type {type.FullName} is not marked with {nameof(CultDocumentAttribute)}."); var members = DiscoverMembers(type); + var ids = CultElementIds.Inspect(type); + if (ids.Problems.Count > 0) + throw new InvalidOperationException(ids.Problems[0]); var nameMember = members.FirstOrDefault(member => member.IsName); var indexAccessors = members .Where(member => member.IndexAlias != null) @@ -698,12 +1028,17 @@ namespace GameCult.Caching var contentHash = Sha256(schemaJson); var semanticFingerprint = BuildSemanticFingerprint(attribute.SchemaName, attribute.SchemaVersion, descriptorMembers); var schemaId = Sha256(semanticFingerprint); + var compatibleSchemaIds = attribute.CompatibleSchemaIds + .Where(id => !string.IsNullOrWhiteSpace(id) && !string.Equals(id, schemaId, StringComparison.Ordinal)) + .Distinct(StringComparer.Ordinal) + .ToArray(); return new CultDocumentDescriptor( type, attribute.SchemaName, attribute.SchemaVersion, schemaId, + compatibleSchemaIds, contentHash, schemaJson, type.GetCustomAttribute() != null, @@ -818,31 +1153,7 @@ namespace GameCult.Caching }); } - var persistedSchema = new CanonicalSchemaShape( - persisted.SchemaName, - persisted.SchemaVersion, - persisted.Members.Select(member => new CanonicalSchemaMember( - member.Slot, - member.MemberName, - member.TypeName, - member.IsReference, - member.IsMany, - member.TargetSchemaName, - member.IsName, - member.IndexAlias)).ToArray()); - var localSchema = new CanonicalSchemaShape( - local.SchemaName, - local.SchemaVersion, - local.Members.Select(member => new CanonicalSchemaMember( - member.Slot, - member.MemberName, - member.TypeName, - member.IsReference, - member.IsMany, - member.TargetSchemaName, - member.IsName, - member.IndexAlias)).ToArray()); - var comparison = CompareSchemaShapes(persistedSchema, localSchema); + var comparison = CompareSchemaShapes(ShapeOf(persisted), ShapeOf(local.ToCatalogEntry())); if (!comparison.IsCompatible) { throw new InvalidOperationException( @@ -892,11 +1203,6 @@ namespace GameCult.Caching var ignoredExtraSlots = new List(); var defaultedMissingSlots = new List(); - if (!string.Equals(persisted.SchemaName, local.SchemaName, StringComparison.Ordinal)) - { - errors.Add($"schema name drift '{persisted.SchemaName}' -> '{local.SchemaName}'"); - } - var persistedBySlot = persisted.Members.ToDictionary(member => member.Slot); var localBySlot = local.Members.ToDictionary(member => member.Slot); @@ -918,7 +1224,22 @@ namespace GameCult.Caching errors.Add($"slot {localMember.Slot} changed type '{persistedMember.TypeName}' -> '{localMember.TypeName}'"); } - if (localMember.IsReference != persistedMember.IsReference) + // 1.0.x persisted a CultRecordRef collection as a plain value slot (not a reference, no target); the + // payload is the same array of keys. Gaining the reference classification on an otherwise identical slot + // is compatible drift. Losing it, or any other change of reference or target, is not. + var gainedReference = !persistedMember.IsReference + && persistedMember.TargetSchemaName == null + && localMember.IsReference + && localMember.TargetSchemaName != null; + if (gainedReference) + { + warnings.Add(new CultSchemaMigrationWarning + { + Code = "gained_reference_semantics", + Message = $"Local slot {localMember.Slot} ('{localMember.Name}') is a reference to '{localMember.TargetSchemaName}'; the persisted schema stored it as a plain value with the same payload." + }); + } + else if (localMember.IsReference != persistedMember.IsReference) { errors.Add($"slot {localMember.Slot} changed reference semantics"); } @@ -928,7 +1249,7 @@ namespace GameCult.Caching errors.Add($"slot {localMember.Slot} changed cardinality"); } - if (!string.Equals(localMember.TargetSchemaName, persistedMember.TargetSchemaName, StringComparison.Ordinal)) + if (!gainedReference && !string.Equals(localMember.TargetSchemaName, persistedMember.TargetSchemaName, StringComparison.Ordinal)) { errors.Add($"slot {localMember.Slot} changed target schema '{persistedMember.TargetSchemaName ?? ""}' -> '{localMember.TargetSchemaName ?? ""}'"); } @@ -1699,53 +2020,68 @@ namespace GameCult.Caching internal CultCommitRequest( IReadOnlyList upserts, IReadOnlyList deletes, - IReadOnlyList<(CultRecordKey Key, string? SchemaId, string? StoredAt)> expected, + IReadOnlyList checks, + IReadOnlyList<(CultRecordKey Key, string? Version)> expected, bool expectUnchanged) { Upserts = upserts; Deletes = deletes; + Checks = checks; Expected = expected; ExpectUnchanged = expectUnchanged; } public IReadOnlyList Upserts { get; } public IReadOnlyList Deletes { get; } - public IReadOnlyList<(CultRecordKey Key, string? SchemaId, string? StoredAt)> Expected { get; } + + /// + /// Keys the batch removes that its cache does not hold. Such a removal removes nothing, so it is never in + /// ; but a store that holds a record at one of these keys under an id no registered type declares + /// refuses the commit, as it refuses a write of that record. The home's judges + /// these keys under its lock, with the keys it writes; every other attached store judges them first through + /// , each under its own lock, and a refusal anywhere writes nothing. A judged + /// key is never removed: a record a declared type reads stays where it is. + /// + public IReadOnlyList Checks { get; } + public IReadOnlyList<(CultRecordKey Key, string? Version)> Expected { get; } public bool ExpectUnchanged { get; } public bool HasConditions => Expected.Count > 0 || ExpectUnchanged; - // durable is what the store holds on disk now; observed is what its cache last loaded or committed. - // Identity is (the schema id the record is persisted under, storedAt). A value change mints a later storedAt; a - // whole-file write keeps storedAt and rewrites a drifted record under another schema id, which the schema half sees. - public bool ConditionsHold(IReadOnlyCollection durable, IEnumerable observed) + /// + /// Whether the batch's conditions hold. is the version of each record the store holds on disk now, + /// by key; is what the store's cache last loaded or committed, and the + /// foreign records the store carries as it last read them (; empty for a home + /// with none). Under each foreign record compares against the version it had when the store + /// read it, as a record the cache holds compares against its own. Identity is the record's version, the SHA-256 of its stored + /// bytes, which names its schema id and storedAt too. + /// + public bool ConditionsHold(IReadOnlyDictionary durable, IEnumerable observed, IEnumerable foreign) { - var byKey = durable.ToDictionary(record => record.Key, StringComparer.Ordinal); - foreach (var (key, schemaId, storedAt) in Expected) + foreach (var (key, version) in Expected) { - byKey.TryGetValue(key.Value, out var record); - var holds = schemaId == null - ? record == null - : record != null && record.SchemaId == schemaId && record.StoredAt == storedAt; + var holds = durable.TryGetValue(key.Value, out var stored) + ? version != null && string.Equals(stored, version, StringComparison.Ordinal) + : version == null; if (!holds) return false; } return !ExpectUnchanged || - durable.Select(record => Identity(record.Key, record.SchemaId, record.StoredAt)) - .OrderBy(identity => identity, StringComparer.Ordinal) + durable.Select(pair => (pair.Key, Version: pair.Value)) + .OrderBy(pair => pair.Key, StringComparer.Ordinal) .SequenceEqual(observed - .Select(stored => Identity(stored.Key.Value, stored.PersistedSchemaId, stored.StoredAt)) - .OrderBy(identity => identity, StringComparer.Ordinal)); + .Select(stored => (Key: stored.Key.Value, Version: stored.StoredVersion)) + .Concat(foreign.Select(record => (record.Key, Version: record.StoredVersion))) + .OrderBy(pair => pair.Key, StringComparer.Ordinal)); } - - private static string Identity(string key, string schemaId, string storedAt) => $"{key}\n{schemaId}\n{storedAt}"; } // One staged write: a plain record (Stored), a variant (Variant), a flatten, or, with none of those, a removal. internal sealed class StagedOperation { - public StagedOperation(CultRecordKey key, CultStoredDocument? stored, CultVariantDelta? variant, bool flatten) + public StagedOperation(CultRecordKey key, CultStoredDocument? stored, CultVariantDelta? variant, bool flatten, bool afterFlatten = false) { + AfterFlatten = afterFlatten; Key = key; Stored = stored; Variant = variant; @@ -1756,6 +2092,8 @@ namespace GameCult.Caching public CultStoredDocument? Stored { get; } public CultVariantDelta? Variant { get; } public bool Flatten { get; } + // A plain record staged after a Flatten at its key: the key holds a plain record by then, so it replaces that, not a variant. + public bool AfterFlatten { get; } public bool IsRemoval => Stored == null && Variant == null && !Flatten; } @@ -1764,7 +2102,10 @@ namespace GameCult.Caching { private readonly CultCache _cache; internal readonly Dictionary Operations = new(StringComparer.Ordinal); - internal readonly List<(CultRecordKey Key, string? SchemaId, string? StoredAt)> Expected = new(); + // Every key Remove named, in whatever order: Land evicts the record the cache holds at each (or checks the key when it holds + // none) before anything lands, so a record staged at a removed key is an add after an eviction, as when staged apart. + internal readonly HashSet Removals = new(StringComparer.Ordinal); + internal readonly List<(CultRecordKey Key, string? Version)> Expected = new(); internal bool ExpectsUnchanged; internal bool Sealed; @@ -1788,6 +2129,7 @@ namespace GameCult.Caching public void Remove(CultRecordKey key) { ThrowIfSealed(); + Removals.Add(key.Value); Operations[key.Value] = new StagedOperation(key, null, null, false); } @@ -1796,6 +2138,10 @@ namespace GameCult.Caching public CultRecordKey UpsertVariant(CultRecordKey key, CultRecordKey baseKey, IEnumerable? overrides = null) { ThrowIfSealed(); + // A flatten staged here leaves a plain record at the key, which cannot become a variant in place, as when staged apart. + if (Operations.TryGetValue(key.Value, out var flattened) && flattened.Flatten) + throw new InvalidOperationException( + $"Record {key.Value} is a plain record; it cannot become a variant of {baseKey.Value} in place. Use a new key."); Operations[key.Value] = new StagedOperation( key, null, new CultVariantDelta(baseKey.Value, (overrides ?? Array.Empty()).ToArray()), false); return key; @@ -1805,14 +2151,25 @@ namespace GameCult.Caching public void Flatten(CultRecordKey key) { ThrowIfSealed(); + // A flatten acts on what the key will hold, as staged apart: a variant staged here is flattened as it lands; a removal or a + // plain record staged here, or an earlier flatten, leaves nothing to flatten; with nothing staged it flattens the variant + // the cache holds. + if (Operations.TryGetValue(key.Value, out var staged)) + { + if (staged.Variant == null || staged.Flatten) + throw new InvalidOperationException($"Flatten({key.Value}): the record is not a variant."); + Operations[key.Value] = new StagedOperation(key, null, staged.Variant, true); + return; + } + Operations[key.Value] = new StagedOperation(key, null, null, true); } public void Expect(CultRecordKey key, object? current) { ThrowIfSealed(); - var identity = _cache.Observe(key, current); - Expected.Add((key, identity?.SchemaId, identity?.StoredAt)); + var observed = _cache.Observe(key, current); + Expected.Add((key, observed?.StoredVersion)); } public void ExpectUnchanged() @@ -1825,7 +2182,8 @@ namespace GameCult.Caching { ThrowIfSealed(); var stored = _cache.CreateStoredDocument(document, key); - Operations[stored.Key.Value] = new StagedOperation(stored.Key, stored, null, false); + Operations[stored.Key.Value] = new StagedOperation( + stored.Key, stored, null, false, afterFlatten: Operations.TryGetValue(stored.Key.Value, out var prior) && prior.Flatten); return stored.Key; } @@ -1858,6 +2216,10 @@ namespace GameCult.Caching private long _sequence; // What this thread's outermost hold admitted, published by that hold when it exits. [ThreadStatic] private static Hold? _held; + // The store gate this thread holds (InStoreHold), if any. With _held this is the whole of what a thread holds: at most one + // cache's gate and at most one store's. A store's gate may be taken inside a cache's, never the reverse, so while this is set a + // thread enters no cache gate it does not already hold, reads included (Gate). + [ThreadStatic] private static object? _storeHeld; // Journals run under the gate, in registration order; both fields are read and written only under it. private readonly List>>> _journals = new(); private bool _journaling; @@ -1873,8 +2235,24 @@ namespace GameCult.Caching public CultDocumentRegistry Registry => _registry; - // Read under the gate, by a store judging a merge. - internal bool HoldsVariants => _variantKeys.Count > 0; + // A store wrote: every record it wrote is stored under its type's own id at the storedAt the write gave it, with the + // version of the bytes it wrote, and a plain record carries the ids it held, so it is no longer in-memory only. A variant's ids are not cleared here: the + // store wrote the delta it was handed, and only a write of the variant changes that. + internal void Wrote(IEnumerable<(CultPersistedRecord Record, string Version)> written) => Held(() => + { + foreach (var (record, version) in written) + { + if (!_entries.TryGetValue(record.Key, out var entry)) + continue; + entry.StoredSchemaId = record.SchemaId; + entry.StoredAt = record.StoredAt; + entry.StoredVersion = version; + if (entry.Variant == null) + entry.IdsInMemoryOnly = false; + } + + return true; + }); public CultCodec? Codec => _codec; @@ -1891,11 +2269,39 @@ namespace GameCult.Caching public CultVariantOverride Override(string member, object? value) where T : class => Override(typeof(T), member, value); + // The one-shot rewrite for stores written before element ids existed: upserts every record whose ids exist only in + // memory (minted when it loaded), through the normal write path, one commit per home store. Returns how many records + // it rewrote; a second call finds none. A selected record that is neither declared nor read whole is read-only, + // so its home store's commit is refused with CultSchemaConflictException; commits to earlier stores stand. + public int MintElementIds() + { + var groups = Held(() => _entries.Values + .Where(entry => entry.IdsInMemoryOnly) + .GroupBy(entry => Home(entry.Descriptor.DocumentType)) + .Select(group => group.ToArray()) + .ToArray()); + foreach (var group in groups) + { + Commit(batch => + { + foreach (var entry in group) + { + if (entry.Variant is { } delta) + batch.UpsertVariant(entry.Key, new CultRecordKey(delta.BaseKey), delta.Overrides); + else + batch.Upsert(entry.Descriptor.DocumentType, entry.Document, entry.Key); + } + }); + } + + return groups.Sum(group => group.Length); + } + public bool IsDirty { get { - lock (_gate) + lock (Gate()) return _stores.Count == 0 ? _dirtyInMemory : _stores.Any(entry => entry.Store.IsDirty); } } @@ -1908,7 +2314,7 @@ namespace GameCult.Caching { get { - lock (_gate) + lock (Gate()) return _stores.Select(entry => entry.Store).ToArray(); } } @@ -1917,7 +2323,7 @@ namespace GameCult.Caching { get { - lock (_gate) + lock (Gate()) return _entries.Values.Select(entry => entry.Document).ToArray(); } } @@ -1926,7 +2332,7 @@ namespace GameCult.Caching { get { - lock (_gate) + lock (Gate()) return _entries.Values .OrderBy(entry => entry.Descriptor.SchemaName, StringComparer.Ordinal) .ThenBy(entry => entry.Key.Value, StringComparer.Ordinal) @@ -2015,9 +2421,14 @@ namespace GameCult.Caching { if (store == null) throw new ArgumentNullException(nameof(store)); homes ??= Array.Empty(); - Held(() => + // The only writer of store.Cache, holding the cache's gate and the store's. It publishes Cache before the pull, so the store is + // attached for the whole of its hold and every entry point agrees: a journal the pull runs that calls the store is a journal + // entering its cache and is refused, as it is after the attach; every other thread waits on the cache's or the store's gate. + // A failed pull takes Cache back in that same hold. Another thread's unlocked guess may read the tentative cache, but none runs a + // body against it: the guess is checked under that cache's gate, which the attach holds, and fails once the pull retracts it. + Held(() => store.UnderStoreGate(() => { - if (store.Loaded != null) + if (store.Cache != null) throw new InvalidOperationException($"Backing store {store} is already attached to a cache."); if (store.IsDirty) throw new InvalidOperationException($"Backing store {store} has staged writes; attach it clean."); @@ -2044,10 +2455,9 @@ namespace GameCult.Caching "attach routed stores before the untyped store."); } - store.AttachRegistry(_registry); + store.UseRegistry(_registry); store.Cache = this; store.Loaded = (loaded, dropped) => Admit(loaded, dropped, store, (_, _) => CultCommitOutcome.Committed); - store.Judging = (arriving, departing) => { Resolve(arriving, departing, store); }; _stores.Add(candidate); try { @@ -2057,12 +2467,12 @@ namespace GameCult.Caching { _stores.Remove(candidate); store.Loaded = null; - store.Judging = null; + store.UseRegistry(null); store.Cache = null; throw; } return true; - }); + })); } public Task PullAllBackingStoresAsync() @@ -2093,7 +2503,7 @@ namespace GameCult.Caching { foreach (var (store, _) in _stores) { - if (!store.IsReadOnly && store.IsDirty) + if (!store.IsReadOnly && (store.IsDirty || store.HasStagedChecks)) store.PushAll(); } return true; @@ -2110,12 +2520,14 @@ namespace GameCult.Caching return UpsertAsync(document, handle); } + // Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares. public Task> UpsertAsync(T document, CultRecordHandle? handle = null) { if (document == null) throw new ArgumentNullException(nameof(document)); return Task.FromResult(new CultRecordHandle(Write(document, handle?.Key))); } + // Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares. public Task UpsertAsync(Type documentType, object document, CultRecordKey? key = null) { RequireInstanceOf(documentType, document); @@ -2123,6 +2535,7 @@ namespace GameCult.Caching } // A single-call variant write; a batch stages the same thing with CultCacheBatch.UpsertVariant. + // Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares. public Task UpsertVariantAsync(CultRecordKey key, CultRecordKey baseKey, IEnumerable? overrides = null) { var delta = new CultVariantDelta(baseKey.Value, (overrides ?? Array.Empty()).ToArray()); @@ -2139,6 +2552,7 @@ namespace GameCult.Caching } // Replaces a variant with a plain record holding its resolved document (Q1a's explicit operation). + // Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares. public Task FlattenAsync(CultRecordKey key) => Task.FromResult(Held(() => { var stored = StampFlatten(key); @@ -2150,13 +2564,26 @@ namespace GameCult.Caching return key; })); + /// + /// Removes the record at ; false when the cache holds nothing there. + /// + /// + /// A record stored under an id no registered type declares, read-only or foreign, is never removed: removing one the cache + /// holds is refused with now. A key the cache does not hold costs nothing here and + /// returns false; each writable store judges it at its next flush or commit, under its lock, and refuses that flush or + /// commit if it holds a record there under such an id (a read-only store answers now, from what it last loaded). + /// public bool Remove(CultRecordKey key) => Held(() => { if (!_entries.TryGetValue(key.Value, out var existing)) + { + RefuseRemovalOfUndeclared(new[] { key.Value }); return false; + } Admit(Array.Empty(), new[] { existing }, null, (home, _) => { home?.Delete(existing); + home?.NoteRemoved(existing.Key.Value); return CultCommitOutcome.Committed; }); return true; @@ -2189,20 +2616,20 @@ namespace GameCult.Caching public CultRecordHandle? TryGetHandle(T document) { if (document == null) throw new ArgumentNullException(nameof(document)); - lock (_gate) + lock (Gate()) return _handles.TryGetValue(document, out var box) ? new CultRecordHandle(box.Key) : null; } // The stored form of a record: for a variant, the delta beside the resolved document. public CultStoredDocument? GetStored(CultRecordKey key) { - lock (_gate) + lock (Gate()) return _entries.TryGetValue(key.Value, out var stored) ? stored : null; } public object? Get(CultRecordKey key) { - lock (_gate) + lock (Gate()) return _entries.TryGetValue(key.Value, out var stored) ? stored.Document : null; } @@ -2214,7 +2641,7 @@ namespace GameCult.Caching // Every change whose Sequence is at or below the returned one is reflected in the returned document. public (object? Document, long Sequence) GetWithSequence(CultRecordKey key) { - lock (_gate) + lock (Gate()) return (_entries.TryGetValue(key.Value, out var stored) ? stored.Document : null, _sequence); } @@ -2226,7 +2653,7 @@ namespace GameCult.Caching public IEnumerable GetAll() where T : class { - lock (_gate) + lock (Gate()) return _entries.Values.Select(entry => entry.Document).OfType().ToArray(); } @@ -2260,7 +2687,7 @@ namespace GameCult.Caching { Held(() => { - if (FlushAttachedStoresOnDispose && IsDirty) + if (FlushAttachedStoresOnDispose && (IsDirty || _stores.Any(entry => entry.Store.HasStagedChecks))) FlushAllBackingStores(); return true; }); @@ -2307,7 +2734,7 @@ namespace GameCult.Caching internal CultStoredDocument CreateStoredDocument(object document, CultRecordKey? key) { var descriptor = _registry.GetRequired(document.GetType()); - lock (_gate) + lock (Gate()) return Stamp( key ?? (_handles.TryGetValue(document, out var box) ? box.Key : descriptor.IsGlobal ? new CultRecordKey($"global:{descriptor.SchemaId}") @@ -2317,19 +2744,20 @@ namespace GameCult.Caching } // Call under the gate that admits the result, so no load can land a later storedAt in between. - private CultStoredDocument Stamp(CultRecordKey key, CultDocumentDescriptor descriptor, object document) => - new(key, MintStoredAt(PreviousStoredAt(key)), descriptor, document); + private CultStoredDocument Stamp(CultRecordKey key, CultDocumentDescriptor descriptor, object document, bool replacesFlattened = false) => + new(key, MintStoredAt(PreviousStoredAt(key), Clock()), descriptor, document) { ReplacesFlattened = replacesFlattened }; // An unresolved variant: the admitting hold resolves it against its base before anything lands. - private CultStoredDocument StampVariant(CultRecordKey key, CultDocumentDescriptor descriptor, CultVariantDelta delta) => - new(key, MintStoredAt(PreviousStoredAt(key)), descriptor, delta, null, descriptor.SchemaId); + // A staged variant that a Flatten followed lands as the plain record of its resolution (flattens). + private CultStoredDocument StampVariant(CultRecordKey key, CultDocumentDescriptor descriptor, CultVariantDelta delta, bool flattens = false) => + new(key, MintStoredAt(PreviousStoredAt(key), Clock()), descriptor, delta, null, _codec) { Flattens = flattens }; // The plain record a Flatten stages: the variant's resolved document, under the variant's own key and schema. private CultStoredDocument StampFlatten(CultRecordKey key) { if (!_entries.TryGetValue(key.Value, out var existing) || existing.Variant == null) throw new InvalidOperationException($"Flatten({key.Value}): the record is not a variant."); - return new CultStoredDocument(key, MintStoredAt(existing.StoredAt), existing.Descriptor, existing.Document) { Flattens = true }; + return new CultStoredDocument(key, MintStoredAt(existing.StoredAt, Clock()), existing.Descriptor, existing.Document) { Flattens = true }; } private string? PreviousStoredAt(CultRecordKey key) => _entries.TryGetValue(key.Value, out var previous) ? previous.StoredAt : null; @@ -2366,16 +2794,15 @@ namespace GameCult.Caching } } - // The durable identity of the record holding current, read under the gate, where a store's write moves it. - internal (string SchemaId, string StoredAt)? Observe(CultRecordKey key, object? current) + internal CultStoredDocument? Observe(CultRecordKey key, object? current) { if (current == null) return null; - lock (_gate) + lock (Gate()) { if (_handles.TryGetValue(current, out var box) && box.Key.Equals(key) && _entries.TryGetValue(key.Value, out var stored) && ReferenceEquals(stored.Document, current)) - return (stored.PersistedSchemaId, stored.StoredAt); + return stored; } throw new ArgumentException($"Expect({key.Value}) was given an instance this cache does not hold at that key.", nameof(current)); @@ -2384,9 +2811,8 @@ namespace GameCult.Caching // The wall clock behind every storedAt. Internal: a test that writes a golden store pins it, so the bytes reproduce. internal Func Clock { get; set; } = () => DateTimeOffset.UtcNow; - private string MintStoredAt(string? previous) + internal static string MintStoredAt(string? previous, DateTimeOffset now) { - var now = Clock(); if (previous != null && DateTimeOffset.TryParse(previous, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var last) && now <= last) @@ -2424,18 +2850,33 @@ namespace GameCult.Caching // Staging stamped outside this gate; a pull since then may hold a later storedAt, so stamp again. var upserts = batch.Operations.Values .Where(op => !op.IsRemoval) - .Select(op => op.Stored != null ? Stamp(op.Key, op.Stored.Descriptor, op.Stored.Document) - : op.Variant != null ? StampVariant(op.Key, DescriptorOfBase(op.Key, op.Variant.BaseKey, batch), op.Variant) + .Select(op => op.Stored != null ? Stamp(op.Key, op.Stored.Descriptor, op.Stored.Document, op.AfterFlatten) + : op.Variant != null ? StampVariant(op.Key, DescriptorOfBase(op.Key, op.Variant.BaseKey, batch), op.Variant, op.Flatten) : StampFlatten(op.Key)) .ToArray(); - var deletes = batch.Operations.Values - .Where(op => op.IsRemoval && _entries.ContainsKey(op.Key.Value)) - .Select(op => _entries[op.Key.Value]) - .ToArray(); - return Admit(upserts, deletes, null, (home, resolved) => + // A removal evicts the record the cache holds at the key, and a record staged at the key lands after the eviction, as + // when the two are staged apart (variants:ruling:one-batch-retype-is-eviction-then-add). A removal of a key the cache + // does not hold removes nothing: it is a check every store the commit reaches judges under its lock. + var deletes = batch.Removals.Where(_entries.ContainsKey).Select(key => _entries[key]).ToArray(); + var checks = batch.Removals.Where(key => !_entries.ContainsKey(key)).ToArray(); + return Admit(upserts, deletes, null, removed: batch.Removals, land: (home, resolved) => { + // A batch that only checks keys names no home; it commits through the cache's one writable store, if it has one. + if (home == null && checks.Length > 0 && _stores.Where(entry => !entry.Store.IsReadOnly).Take(2).ToArray() is { Length: 1 } only) + home = only[0].Store; + // Every store the batch does not commit through judges its checks first, each under its own lock, so a refusal or + // contention writes nothing; the home judges its own in CommitBatch. + foreach (var (store, _) in _stores) + { + if (store == home || checks.Length == 0) + continue; + var judged = store.JudgeChecks(checks, wait); + if (judged != CultCommitOutcome.Committed) + return judged; + } + // What the store holds is what the hold resolved: a variant's delta beside its resolved view. - var request = new CultCommitRequest(resolved, deletes, batch.Expected.ToArray(), batch.ExpectsUnchanged); + var request = new CultCommitRequest(resolved, deletes, checks, batch.Expected.ToArray(), batch.ExpectsUnchanged); if (home != null) { if (request.HasConditions && home.IsDirty) @@ -2445,31 +2886,76 @@ namespace GameCult.Caching if (request.HasConditions && _stores.Count > 0) throw new InvalidOperationException("A conditional batch names its home store through a record it upserts or removes."); - var inMemory = _entries.Values - .Select(entry => new CultPersistedRecord { Key = entry.Key.Value, SchemaId = entry.PersistedSchemaId, StoredAt = entry.StoredAt }) - .ToArray(); - return request.ConditionsHold(inMemory, _entries.Values) ? CultCommitOutcome.Committed : CultCommitOutcome.Mismatch; + var inMemory = _entries.Values.ToDictionary(entry => entry.Key.Value, entry => entry.StoredVersion, StringComparer.Ordinal); + return request.ConditionsHold(inMemory, _entries.Values, Array.Empty()) ? CultCommitOutcome.Committed : CultCommitOutcome.Mismatch; }); }); } + // A removal of a key the cache does not hold removes nothing, so no write carries it to a store; but a store that holds a + // record there under an id no registered type declares refuses it, as it refuses every write of that record, rather than let + // the caller believe it was removed. Each store judges it its own way (CacheBackingStore.RefuseRemovalOfUndeclared). + private void RefuseRemovalOfUndeclared(IReadOnlyCollection keys) + { + if (keys.Count == 0) + return; + foreach (var (store, _) in _stores) + store.RefuseRemovalOfUndeclared(keys); + } + // Every add and remove passes here: single writes, committed batches, and loads (source set). // Nothing lands unless all of it is admissible; land is the durable step between validation and memory. private CultCommitOutcome Admit( IReadOnlyList admitted, IReadOnlyList evicted, CacheBackingStore? source, - Func, CultCommitOutcome> land) => Held(() => + Func, CultCommitOutcome> land, + IReadOnlyCollection? removed = null) => Held(() => { if (_held == null) throw new InvalidOperationException("An admission reached the cache under a plain lock on its gate; every admission runs in a hold."); - var home = Validate(admitted, evicted, source); - // The one resolution: a variant admitted, a base admitted or evicted, all resolved here against the post-batch - // set, before land and before memory. land receives the admitted records with variants resolved. - var plan = Resolve(admitted, evicted, source); - var outcome = land(home, plan.Admitted); + var home = Validate(admitted, evicted, source, removed); + // Ids are decided before anything lands, and the caller's documents change only inside this admission: a plan that + // refuses (a duplicate, a malformed id) has changed nothing, and a write that does not commit gives its ids back. + // A write mints random ids; a load of a record written before ids existed mints the ids its reload will mint + // again, and lands in memory only (variants mint inside Resolve, into their override values). + var idPlans = admitted + .Where(stored => stored.Variant == null) + .Select(stored => (Stored: stored, Plan: CultElementIds.Plan(stored.Descriptor.DocumentType, stored.Document, stored.Key.Value, deterministic: source != null))) + .ToArray(); + VariantPlan plan; + CultCommitOutcome outcome; + try + { + foreach (var (_, idPlan) in idPlans) + idPlan.Apply(); + // The one resolution: a variant admitted, a base admitted or evicted, all resolved here against the post-batch + // set, before land and before memory. land receives the admitted records with variants resolved. + plan = Resolve(admitted, evicted, source); + outcome = land(home, plan.Admitted); + } + catch + { + foreach (var (_, idPlan) in idPlans) + idPlan.Undo(); + throw; + } + if (outcome != CultCommitOutcome.Committed) + { + foreach (var (_, idPlan) in idPlans) + idPlan.Undo(); return outcome; + } + + // A record whose ids exist only in memory stays marked until a write persists them (a flush of the whole store + // does, and so does any write of the record: a new entry is unmarked). + if (source != null) + { + foreach (var (stored, idPlan) in idPlans) + stored.IdsInMemoryOnly = idPlan.Count > 0; + } + var applied = Apply(plan, evicted, source); foreach (var change in applied) _held.Changes.Add((change, source != null)); @@ -2485,6 +2971,52 @@ namespace GameCult.Caching // if an observer or an OnUpdate handler throws; then the exception (an AggregateException for several, whatever their // origin: a journal, an observer, a second database) is rethrown. A writer looking for one type unwraps InnerExceptions. // If the body threw, its exception wins. + // Every cache gate entry takes the gate here: a thread that holds a store's gate may not enter a cache's gate it does not + // already hold. User code a cacheless call or an attach runs (a document's getter, a constructor) is the way in; entering a cache + // from it could wait on a thread that holds that cache and waits for this store, so it is refused instead. + private object Gate() + { + if (_storeHeld != null && !Monitor.IsEntered(_gate)) + throw new InvalidOperationException( + "A cache's gate was entered inside a store's hold; user code run by a cacheless call or an attach may not enter another cache."); + return _gate; + } + + // The one lock of a store's gate. A thread holds one store's gate at a time; a store call may be entered again from inside it, + // except a cacheless write: it owns its store outright, so it is the outermost hold of the store's gate. + // A cacheless write that re-enters its own store's hold is refused by whichever fact is true: Cache is the one attachment fact, so + // a store with a cache published (an attach's pull included) gets the attached refusal, and one without gets the re-entry refusal. + internal static T InStoreHold(CacheBackingStore store, Func body, bool cacheless = false) + { + var storeGate = store._storeGate; + if (_storeHeld != null) + { + if (ReferenceEquals(_storeHeld, storeGate)) + { + if (cacheless) + throw new InvalidOperationException(store.Cache != null + ? CacheBackingStore.AttachedRefusal + : "A cacheless write was made inside a call that already holds this store (a store call whose user code made it); write through the cache."); + return body(); + } + throw new InvalidOperationException( + "A store's gate was entered inside another store's hold; user code run by a cacheless call or an attach may not enter a second store."); + } + + lock (storeGate) + { + _storeHeld = storeGate; + try + { + return body(); + } + finally + { + _storeHeld = null; + } + } + } + internal T Held(Func body) { if (Monitor.IsEntered(_gate)) @@ -2501,7 +3033,7 @@ namespace GameCult.Caching var result = default(T)!; try { - lock (_gate) + lock (Gate()) result = body(); } catch (Exception exception) @@ -2590,30 +3122,34 @@ namespace GameCult.Caching private CacheBackingStore? Validate( IReadOnlyList admitted, IReadOnlyList evicted, - CacheBackingStore? source) + CacheBackingStore? source, + IReadOnlyCollection? removed) { + var removedKeys = removed?.ToHashSet(StringComparer.Ordinal) ?? new HashSet(StringComparer.Ordinal); var homes = new List(); var globals = new Dictionary(_globals); + var evictedKeys = evicted.Select(stored => stored.Key.Value).ToHashSet(StringComparer.Ordinal); foreach (var stored in evicted) { var type = stored.Descriptor.DocumentType; if (globals.TryGetValue(type, out var key) && key == stored.Key.Value) globals.Remove(type); if (source == null) - Claim(stored); + Claim(stored, upsert: false); } foreach (var stored in admitted) { var descriptor = stored.Descriptor; if (source == null) - Claim(stored); + Claim(stored, upsert: !removedKeys.Contains(stored.Key.Value)); else if (Home(descriptor.DocumentType) is var home && home != source) throw new InvalidOperationException( $"{descriptor.SchemaName} record {stored.Key.Value} was loaded from {source} but its home is {home?.ToString() ?? "no store"}."); - // One key, one store: a key never moves between homes, and two stores never both deliver it. - if (_entries.TryGetValue(stored.Key.Value, out var present) && + // One key, one store: a key never moves between homes, and two stores never both deliver it. An evicted key is absent + // here, as it is staged apart; a retype across two home stores is then the batch spanning both (below): one rule, one text. + if (!evictedKeys.Contains(stored.Key.Value) && _entries.TryGetValue(stored.Key.Value, out var present) && Home(present.Descriptor.DocumentType) is var held && Home(descriptor.DocumentType) is var incoming && held != incoming) @@ -2632,22 +3168,27 @@ namespace GameCult.Caching var distinct = homes.Distinct().ToArray(); if (distinct.Length > 1) throw new InvalidOperationException( - $"The batch spans {distinct[0]} and {distinct[1]}; a commit lands in one home store."); + $"The batch spans {distinct[0]} and {distinct[1]}; a commit lands in one home store. " + + "To move a key between stores, stage its removal and its add apart (two commits, not one atomic commit)."); return distinct.FirstOrDefault(); - void Claim(CultStoredDocument stored) + void Claim(CultStoredDocument stored, bool upsert) { var home = Home(stored.Descriptor.DocumentType); if (home == null) { if (_stores.Count > 0) throw new InvalidOperationException($"No backing store is home to {stored.Descriptor.SchemaName}."); + // No store, no file to judge, but the same rule: a record is replaced only by its own type's write. + if (upsert && _entries.TryGetValue(stored.Key.Value, out var inMemory) && !CacheBackingStore.UpsertOwned(inMemory, stored.Descriptor)) + throw CacheBackingStore.Retyped(inMemory, stored.Key.Value, stored.Descriptor); return; } if (home.IsReadOnly) throw new InvalidOperationException( $"{stored.Descriptor.SchemaName} record {stored.Key.Value} routes to read-only backing store {home}."); + home.RefuseUndeclared(stored.Key.Value, upsert ? stored.Descriptor : null); homes.Add(home); } } @@ -2758,23 +3299,21 @@ namespace GameCult.Caching // A variant is written only through the variant API: a plain record never lands on a variant's key, and the // reverse is a rebase or a flatten, never an accident. A load reports what is on disk and is not a write. + // A rebase onto a base of another type would retype the key; write ownership refused that in Claim before this runs. if (source == null) { foreach (var stored in admitted) { _entries.TryGetValue(stored.Key.Value, out var present); - if (stored.Variant == null && !stored.Flattens && present?.Variant != null) + if (gone.Contains(stored.Key.Value)) + present = null; + if (stored.Variant == null && !stored.Flattens && !stored.ReplacesFlattened && present?.Variant != null) throw new InvalidOperationException( $"Record {stored.Key.Value} is a variant of {present.Variant.BaseKey}; a plain write cannot replace it. " + "Change it through the variant API, or Flatten it."); if (stored.Variant != null && present != null && present.Variant == null) throw new InvalidOperationException( $"Record {stored.Key.Value} is a plain record; it cannot become a variant of {stored.Variant.BaseKey} in place. Use a new key."); - if (stored.Variant != null && present?.Variant != null && - present.Descriptor.DocumentType != stored.Descriptor.DocumentType) - throw new InvalidOperationException( - $"Variant {stored.Key.Value} is a {present.Descriptor.DocumentType.Name}; " + - $"rebasing it onto {stored.Variant.BaseKey} would make it a {stored.Descriptor.DocumentType.Name}."); } } @@ -2843,6 +3382,8 @@ namespace GameCult.Caching var type = descriptor.DocumentType; var kept = new List>(); + var overrides = new List(delta.Overrides.Count); + var minted = false; var seen = new HashSet(); foreach (var entry in delta.Overrides) { @@ -2858,9 +3399,10 @@ namespace GameCult.Caching if (member == null) throw new InvalidOperationException($"Variant {key} overrides slot {slot}, which {type.Name} does not have."); + object? value; try { - _codec.Deserialize(member.MemberType, type, entry.Value); + value = _codec.Deserialize(member.MemberType, type, entry.Value); } catch (Exception exception) { @@ -2870,7 +3412,19 @@ namespace GameCult.Caching exception.GetBaseException().Message, exception); } - kept.Add(new KeyValuePair(slot, entry.Value)); + // The override's value is minted like a plain record's members: ids the store never held are filled here, in the + // decoded copy, and the delta this hold lands carries them. + var bytes = entry.Value; + var idPlan = CultElementIds.Plan(member.MemberType, value, key + "." + slot.ToString(CultureInfo.InvariantCulture), deterministic: source != null); + if (idPlan.Count > 0) + { + idPlan.Apply(); + bytes = _codec.Serialize(value!, member.MemberType, type); + minted = true; + } + + overrides.Add(new CultVariantOverride(entry.Op, entry.Path, entry.Id, bytes)); + kept.Add(new KeyValuePair(slot, bytes)); } var payload = _codec.Serialize(baseStored.Document, type, type); @@ -2878,9 +3432,14 @@ namespace GameCult.Caching ?? throw new InvalidOperationException($"Variant {key} resolved to nothing."); stack.RemoveAt(stack.Count - 1); - return done[key] = isVariant - ? variant.Resolved(document) - : new CultStoredDocument(variant.Key, variant.StoredAt, variant.Descriptor, document) { Flattens = true }; + return done[key] = isVariant && !variant.Flattens + ? variant.Resolved(document, minted ? new CultVariantDelta(delta.BaseKey, overrides) : delta, variant.IdsInMemoryOnly || (minted && source != null), _codec) + : new CultStoredDocument(variant.Key, variant.StoredAt, variant.Descriptor, document) + { + Flattens = true, + StoredSchemaId = variant.StoredSchemaId, + StoredVersion = variant.StoredVersion + }; } } @@ -3056,7 +3615,7 @@ namespace GameCult.Caching private T? Single(string lookup, Func> keys) where T : class { - lock (_gate) + lock (Gate()) { var found = keys().Distinct().ToArray(); if (found.Length > 1) @@ -3171,6 +3730,9 @@ namespace GameCult.Caching protected CultDocumentRegistry Registry => _registry ?? throw new InvalidOperationException("Backing store is not attached to a CultDocumentRegistry."); + // Null for a store no registry was attached to (CultMesh's raw single-file write). + private protected CultDocumentRegistry? AttachedRegistry => _registry; + protected ConcurrentDictionary Entries { get; } = new(StringComparer.Ordinal); @@ -3182,48 +3744,163 @@ namespace GameCult.Caching public IReadOnlyList LastSchemaMigrationReports => _lastSchemaMigrationReports; + /// + /// The records this store's last load found and carries untouched, ordered by key: no registered type owns their schema id or + /// lists it as compatible. See . + /// + public IReadOnlyList ForeignRecords => _foreignRecords; + + private CultForeignRecord[] _foreignRecords = Array.Empty(); + // Set by the cache at attach. A pull hands over everything it loaded and dropped in one call before it adopts // any of it; if the cache refuses a record the call throws and the store keeps its previous view. protected internal Action, IReadOnlyList>? Loaded; - // Set by the cache at attach: the cache's own resolution (variants, their indexed values, their bases) run over a - // set of records a store is about to hold, mutating nothing. It throws the refusal the cache would give the same - // set arriving as a load. - protected internal Action, IReadOnlyList>? Judging; + internal readonly object _storeGate = new(); - private readonly object _detachedGate = new(); + // Gates the current thread has taken for store calls, read by tests (InternalsVisibleTo) to pin the two bounds of Gate: at + // most two under failing-attach churn (Cache read under the store's gate is then always null), at most three otherwise: a + // stale non-null guess (its gate), the store's gate, and the settled cache's gate. + [ThreadStatic] + internal static int GateTakes; + + internal const string AttachedRefusal = "The store is attached to a cache, write through the cache."; + // Written only by CultCache.AddBackingStore, holding the cache's gate and this store's: set before the pull, and set back to null + // in the same hold if the pull fails. Read under this store's gate it is settled, and a settled cache never changes, because + // nothing detaches. internal CultCache? Cache; - // Once attached, a hold on the cache's gate: the store and its cache share one lock, so a load calling back into - // the cache can never take the gate after the store lock, and a direct call publishes what it loaded when it - // returns. File locks are always taken inside it. - protected T Held(Func body) + private const string DirectoryFormatV4 = "cultcache.store.v4.directory-content-addressed-pages"; + private const string DirectoryFormatV5 = "cultcache.store.v5.directory-content-addressed-pages"; + + /// + /// The one header decision, for every writer of every store: the string a store file's format header must carry after + /// this write. + /// + /// + /// The marked header (v3, or the directory store's v5) says the store holds an element id: a reader older than ids refuses + /// it, because it would skip the id slots and rewrite the elements without them. A writer that rewrites the whole store + /// () decides by content, so a store drops back to unmarked when nothing in it holds an id. + /// A writer that touches only part of the store keeps a header already marked (), because + /// it cannot see what the rest holds. is about the records this write knows are on disk. + /// Unmarked, a single-file store is v2 exactly when it holds a variant () and otherwise v1; + /// the directory store refuses variants and is v4. Pass the header the file carries, or null when it has none, and refuse + /// a file whose header you cannot read before calling this. + /// + protected internal static string HeaderFor(bool holdsIds, string? existingHeader, bool wholeStore, bool directoryStore, bool holdsVariants = false) { - if (Cache is { } cache) - return cache.Held(body); - lock (_detachedGate) - return body(); + var marked = directoryStore ? DirectoryFormatV5 : CultPersistedStoreSnapshot.FormatV3; + if (holdsIds || (!wholeStore && string.Equals(existingHeader, marked, StringComparison.Ordinal))) + return marked; + return directoryStore ? DirectoryFormatV4 : holdsVariants ? CultPersistedStoreSnapshot.FormatV2 : CultPersistedStoreSnapshot.FormatV1; } + // The header a write leaves, for either store kind: HeaderFor decides. An element id is an ordinary keyed string, so only a + // reader with the record's type can tell from its bytes whether it holds one; the store header is how a reader without the types + // learns it. A write that copies any + // record forward keeps a marked header; a write that encodes every record the store will hold decides by content. An + // opaque payload (null) never marks an unmarked store, and keeps a marked one marked. A copied record the writer decodes + // under the store's lock holding an id marks the store too (SingleFileBackingStore.CopiedHoldIds): another runtime may + // have written ids under an unmarked header. Nothing a load or a decode records can unmark a store. + protected static string HeaderForWrite(string? durableHeader, IReadOnlyCollection written, bool copiesAny, bool copiedHoldIds, bool directoryStore, bool holdsVariants) => + HeaderFor( + holdsIds: copiedHoldIds || written.Any(value => value == true), + durableHeader, + wholeStore: !copiesAny && written.All(value => value != null), + directoryStore, + holdsVariants); + + // Every store call comes through here, and this is the one owner of which gate it holds. Cache is read once, unlocked, as a + // guess that only picks which gate to try. The store's gate then settles Cache: an attach publishes it and takes it back inside + // one hold of that gate, so under it Cache is what it will always be, and a settled cache is permanent because nothing detaches. + // The settled cache's gate therefore cannot miss. A call takes at most three gates: the guess's, the store's, the settled cache's. + // Attached, the call holds the cache's gate: the store and its cache share one lock, so a load calling back into the cache never + // takes the gate after the store's, and a direct call publishes what it loaded when it returns. Detached, it holds the store's + // gate; a cacheless call (WriteRecord, WriteDocument) that finds a cache there is refused in that same hold, and one that + // re-enters a hold of this store (a cacheless write's user code) is refused by InStoreHold before it reads Cache. A call during + // an attach, from the attaching thread, is the journal entering its cache and is refused. File locks are always taken inside + // either gate. + private T Gate(Func body, bool cacheless) + { + var guess = cacheless ? null : Volatile.Read(ref Cache); + if (guess != null) + { + GateTakes++; + var (ran, result) = guess.Held(() => Cache == guess ? (true, body()) : (false, default(T)!)); + if (ran) + return result; + } + + GateTakes++; + var (done, value, settled) = UnderStoreGate(() => + { + var cache = Cache; + if (cache == null) + return (true, body(), (CultCache?)null); + if (cacheless) + throw new InvalidOperationException(AttachedRefusal); + return (false, default(T)!, cache); + }, cacheless); + if (done) + return value; + + GateTakes++; + return settled!.Held(() => Cache == settled + ? body() + : throw new InvalidOperationException( + "A store's settled cache changed; the store's cache is written only by an attach, inside the store's gate.")); + } + + protected T Held(Func body) => Gate(body, cacheless: false); + protected void Held(Action body) => Held(() => { body(); return true; }); - internal void AttachRegistry(CultDocumentRegistry registry) - { - _registry = registry ?? throw new ArgumentNullException(nameof(registry)); - } + // A cacheless call. The registry is the call's argument, not store state: it is attached for the call and detached when the + // call ends, whatever it does. A cacheless store holds none between calls. + private protected void Cacheless(CultDocumentRegistry? registry, Action body) => + Gate(() => + { + UseRegistry(registry); + try + { + body(); + } + finally + { + UseRegistry(null); + } + return true; + }, cacheless: true); + + // The store's own gate, taken only here. Lock order: a cache's gate, then a store's; nothing takes a store's gate and then a cache's. + internal T UnderStoreGate(Func body, bool cacheless = false) => CultCache.InStoreHold(this, body, cacheless); + + // The one writer of the lent registry: a cacheless call for the call, an attach for as long as it holds the store, and a failed + // attach puts it back, so a cacheless store holds none between calls and a pull on one loads nothing. Nothing else is lent, and the + // pull reads the lent registry, not Cache. + internal void UseRegistry(CultDocumentRegistry? registry) => _registry = registry; public abstract void PullAll(); public abstract void Push(CultStoredDocument entry); public abstract void Delete(CultStoredDocument entry); public abstract void PushAll(); - // One durable step under the store's exclusive lock; conditions are evaluated against what is on disk then. - // A failure restores the store's staged view. + /// + /// One durable step under the store's exclusive lock: the batch and every staged write land onto the store as it is then, + /// and the batch's conditions are evaluated against it. A failure restores the store's staged view. + /// + /// + /// An implementation judges , and its own , under that + /// lock with the keys it writes: a key whose record is under an id no registered type declares refuses the commit, and a + /// checked key is never removed. The cache calls on every other store before this commit. Each + /// foreign record a store lists comes from with the version it read, so pass to + /// . + /// public abstract CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait); public virtual void Dispose() { - if (FlushOnDispose && IsDirty && !IsReadOnly) + if (FlushOnDispose && (IsDirty || HasStagedChecks) && !IsReadOnly) { PushAll(); } @@ -3252,15 +3929,18 @@ namespace GameCult.Caching // resolution shed. A plain record sheds a member its type dropped by never carrying it into the document; a variant // sheds the override of such a slot by never carrying it into the delta, so a later type reusing the slot number // cannot resurrect it and a flush needs no rule of its own. The report is the schema diff's ignored slots plus the - // overrides whose slot the type does not have, including one the persisted schema never listed. + // overrides whose slot the type does not have, including one the persisted schema never listed. The version is the reading + // store's: it read the bytes. protected CultStoredDocument ToStoredDocument( CultPersistedRecord record, + string version, IReadOnlyCollection catalog, Func deserializePayload, out CultSchemaMigrationReport report) { var resolution = Registry.ResolvePersistedSchemaDetailed(record.SchemaId, catalog); report = resolution.Report; + var readWhole = report.IgnoredExtraSlots.Length == 0; if (record.Variant != null) { var descriptor = resolution.Descriptor; @@ -3284,14 +3964,22 @@ namespace GameCult.Caching })).ToArray(); } - // The cache resolves it against its base when it admits the load. + // The store holds the delta and the cache resolves it against its base, with its own codec, when it admits the load. A + // codecless variant counts as holding ids, but a header reads HoldsIds only on staged entries, which are always the cache's + // pushed documents. return new CultStoredDocument( new CultRecordKey(record.Key), record.StoredAt, descriptor, new CultVariantDelta(record.Variant.BaseKey, record.Variant.Overrides.Where(Has).ToArray()), null, - record.SchemaId); + null) + { + StoredSchemaId = record.SchemaId, + StoredSchemaReadWhole = readWhole, + StoredPublishedBy = PublishedByIn(record.SchemaId, catalog), + StoredVersion = version + }; } object document; @@ -3310,36 +3998,371 @@ namespace GameCult.Caching new CultRecordKey(record.Key), record.StoredAt, resolution.Descriptor, - document, - record.SchemaId); + document) + { + StoredSchemaId = record.SchemaId, + StoredSchemaReadWhole = readWhole, + StoredPublishedBy = PublishedByIn(record.SchemaId, catalog), + StoredVersion = version + }; } + private static (string Id, CultSchemaCatalogEntry Entry)? PublishedByIn(string schemaId, IReadOnlyCollection catalog) => + CultDocumentRegistry.PublishedBy(schemaId, catalog) is { } entry ? (schemaId, entry) : null; + protected void SetLastSchemaMigrationReports(IEnumerable reports) { _lastSchemaMigrationReports = reports?.ToArray() ?? Array.Empty(); } - protected void MarkFlushSucceeded() + // The one test of whether a stored record is foreign: no registered type claims its schema id through the catalog it was + // stored with. It reads the record's header only, never its payload. Null for a record the registry resolves. + // The one constructor path for a foreign record, so every store kind, one outside this library included, lists it at the + // version it read. + protected CultForeignRecord? Foreign(CultPersistedRecord record, IReadOnlyCollection catalog, string storedVersion) => + Registry.Claimant(record.SchemaId, catalog, out var persisted) == null + ? new CultForeignRecord(record.Key, record.SchemaId, persisted!.SchemaName, record.StoredAt, storedVersion) + : null; + + protected void SetForeignRecords(IEnumerable foreign) { - IsDirty = false; + _foreignRecords = foreign.OrderBy(record => record.Key, StringComparer.Ordinal).ToArray(); + } + + // A cache never writes, relabels or removes a record it does not own: the staging half of rule 3. A removal or check of a key + // is refused when the store carries a foreign record at the key, or holds one that is not owned: its stored id is not + // declared by a registered type and the type that reads it drops persisted slots (read-only). A record read whole is owned; + // ToStoredDocument decided that from the same comparison Owns makes. + protected internal void RefuseUndeclared(string key) => RefuseUndeclared(key, null); + + // The same, for every record a cache write admits or evicts, before anything is staged: an upsert (written is its type) may + // replace only a record that type owns, WrittenOwns' rule (variants:ruling:write-ownership-by-written-type), asked over the + // catalog entry that published the held record's id when it was loaded, the question the store's judge asks of the file. + internal void RefuseUndeclared(string key, CultDocumentDescriptor? written) + { + var foreign = _foreignRecords.FirstOrDefault(record => string.Equals(record.Key, key, StringComparison.Ordinal)); + if (foreign != null) + throw Overwrites(null, new[] { (foreign.Key, foreign.SchemaId, foreign.SchemaName) }); + if (Entries.TryGetValue(key, out var held) && !HeldOwned(held, written)) + throw Overwrites(null, new[] { (key, held.StoredSchemaId, held.Descriptor.SchemaName) }); + } + + private bool HeldOwned(CultStoredDocument held, CultDocumentDescriptor? written) => + written == null + ? Registry.Declared(held.StoredSchemaId) || held.StoredSchemaReadWhole + : UpsertOwned(held, written); + + // Whether a write of the written type may replace the record a cache holds: WrittenOwns over the entry that published the held + // record's id when it was loaded (a record this cache wrote is published by its own type). The one answer for a store-backed + // cache's staging and a store-less cache's. + internal static bool UpsertOwned(CultStoredDocument held, CultDocumentDescriptor written) => + CultDocumentRegistry.WrittenOwns( + written.ToCatalogEntry(), + held.StoredSchemaId, + new[] { held.StoredPublishedBy is { } loaded && string.Equals(loaded.Id, held.StoredSchemaId, StringComparison.Ordinal) ? loaded.Entry : held.Descriptor.ToCatalogEntry() }); + + // The refusal of a store-less cache that would retype a key. + internal static CultSchemaConflictException Retyped(CultStoredDocument held, string key, CultDocumentDescriptor written) => new( + $"Record '{key}' is a {held.Descriptor.SchemaName} (schema id '{held.StoredSchemaId}'); writing a {written.SchemaName} there would retype the key. " + + "Remove the record first, in the same batch or an earlier commit, then add the other type's.", + held.StoredSchemaId, + new[] { held.Descriptor.SchemaName }, + key); + + /// + /// Called when the cache removes keys it does not hold, outside a batch that commits through this store. Such a removal + /// removes nothing, but a store that holds a record at one of these keys under an id no registered type declares must + /// refuse it rather than let the caller believe the record is gone. + /// + /// + /// + /// The default refuses now, from this store's view: the foreign records its last load listed and the records it holds + /// (). It reads nothing, so it cannot see a record another writer put there after that load. + /// + /// + /// A writable store kind that reads its durable state under a lock overrides this to call , and + /// judges at its next or as it judges + /// : under the lock, with the keys it writes, refused through the same refusal, never + /// removed. It calls when that step succeeds, which clears them. A read-only store has no + /// such step and keeps the default. Both shipped store kinds do this. A batch's own checks are judged through + /// . + /// + /// + protected internal virtual void RefuseRemovalOfUndeclared(IReadOnlyCollection keys) + { + foreach (var key in keys) + RefuseUndeclared(key); } /// - /// Records that this store's completed durable write stored under its descriptor, so its - /// is again. Call it - /// under Held, once the write is durable, for each record the write rewrote; a record whose bytes the write - /// kept is not marked. A rewrite left unmarked makes conditional commit compare the schema id the record was read - /// under, which fails closed until the next pull. + /// Called for a batch's checks on every attached store that is not the batch's home, before the home commits. It judges + /// only the keys it is given: it never writes, stages, clears or calls + /// . /// - /// is not the entry this store holds in - /// at its key: a store marks only its own rewrite. - protected void MarkWritten(CultStoredDocument entry) + /// + /// The default answers from this store's view () and reads nothing, which is right for a + /// read-only store. A writable store kind that reads its durable state under a lock overrides this to read under that + /// lock and refuse through , returning when it may not wait + /// and the lock is held. A refusal or contention here writes nothing anywhere, because the home has not yet committed. + /// + protected internal virtual CultCommitOutcome JudgeChecks(IReadOnlyCollection keys, bool wait) { - if (entry == null) - throw new ArgumentNullException(nameof(entry)); - if (!Entries.TryGetValue(entry.Key.Value, out var held) || !ReferenceEquals(held, entry)) - throw new ArgumentException($"Record '{entry.Key.Value}' is not the entry this store holds; a store marks only what it wrote.", nameof(entry)); - entry.MarkWritten(); + foreach (var key in keys) + RefuseUndeclared(key); + return CultCommitOutcome.Committed; + } + + // Keys a removal named that this store's cache does not hold, waiting for this store's next flush or commit to judge them. + // A refusal takes back the keys it refused; a successful flush or commit clears them. + private readonly HashSet _checks = new(StringComparer.Ordinal); + + /// + /// The keys staged for this store's next flush or commit to judge. They are not writes: + /// does not count them. + /// + protected IReadOnlyCollection StagedChecks => _checks; + + /// + /// Stages keys for this store's next flush or commit to judge without removing them. See + /// . + /// + protected void StageChecks(IEnumerable keys) => Held(() => + { + foreach (var key in keys) + _checks.Add(key); + }); + + internal bool HasStagedChecks => _checks.Count > 0; + + // Keys this store's cache removed here since its last flush. A write of the same key after that is a removal and an add + // (variants:ruling:cache-refuses-retype): the key is judged as the removal it names, not as an upsert over the record it held. + // Cleared like the checks, by a refusal of the key or a successful flush or commit. + private readonly HashSet _removed = new(StringComparer.Ordinal); + + internal void NoteRemoved(string key) => Held(() => _removed.Add(key)); + + // Every key a removal named since the last flush (held or not), which this store judges as a removal even when a record is + // staged there again. + internal IReadOnlyCollection StagedRemovals => _removed.Concat(_checks).ToArray(); + + // A stored record under an id the writer does not own, named by the catalog entry that publishes the id. Header only: no + // payload is decoded. Null for a record under an owned id. + protected static (string Key, string SchemaId, string SchemaName)? Undeclared( + CultPersistedRecord record, IReadOnlyCollection catalog, Func, bool> owns) => + owns(record.SchemaId, catalog) + ? null + : (record.Key, record.SchemaId, CultDocumentRegistry.PublishedBy(record.SchemaId, catalog)?.SchemaName ?? string.Empty); + + // The one per-key judge of a write over a durable catalog, for every store kind: the stored record at each key the write names + // is owned by the entry the write puts at that key (WrittenOwns), or, when the write only removes or checks the key, by the + // removal rule. heldAt gives the durable record at a key and writtenAt the catalog entry the write puts there (null for a + // removal or a check). Memoised per written schema id and stored id, so a large store pays one comparison per pair, and an + // answer for one written type is never reused for another. + internal static Func UndeclaredAt( + Func heldAt, + IReadOnlyCollection catalog, + Func writtenAt, + Func, bool> removalOwns) + { + var answers = new Dictionary<(string? Written, string Stored), bool>(); + return key => + { + if (heldAt(key) is not { } held) + return null; + var written = writtenAt(key); + return Undeclared(held, catalog, (storedId, entries) => + { + var asked = (written?.SchemaId, storedId); + if (!answers.TryGetValue(asked, out var answer)) + answers[asked] = answer = written == null + ? removalOwns(storedId, entries) + : CultDocumentRegistry.WrittenOwns(written, storedId, entries); + return answer; + }); + }; + } + + /// + /// The per-key ownership judge of a write, for a store kind outside this library: the rule the shipped stores apply under their + /// lock, as one call. Each key in and in is judged against the durable + /// record gives for it. A key the write puts a record at ( gives the + /// catalog entry of the record written there) is owned only by that type: a record under its own schema id, an id it lists + /// compatible, or an older id of its own schema name it reads whole; another registered type's record is refused. A key the + /// write removes or only checks, and every key this store's cache removed since the last flush or commit (the removal half of + /// a retype, whose add then lands at it), is owned by any registered type that declares or reads the record whole. A refusal is + /// : it reloads what it refuses, unstages it through , and throws. + /// + /// Every key the write names. + /// The schema catalog of the durable store. + /// The durable record at a key, or null. + /// The catalog entry of the record the write puts at a key, or null when it puts none. + /// The keys the write removes or checks. + /// See . + /// See . + /// See . + /// True for a write no cache made: its refusal is the cacheless one. + protected void RefuseUndeclaredOnDisk( + IEnumerable written, + IReadOnlyCollection catalog, + Func heldAt, + Func writtenAt, + IEnumerable removed, + Func durableAt, + Func foreignAt, + Action unstage, + bool cacheless = false) + { + var keys = written.Distinct(StringComparer.Ordinal).ToArray(); + var removedKeys = removed.Concat(StagedRemovals).ToHashSet(StringComparer.Ordinal); + // A cacheless call has no registry that decides a removal: the entries it writes do (a call that writes no entry owns + // nothing, variants:ruling:mesh-writes-bound-by-declaration). + var entries = keys.Select(writtenAt).OfType().ToArray(); + Func, bool> removalOwns = Cache != null + ? Registry.Owns + : (id, entriesInCatalog) => entries.Any(entry => CultDocumentRegistry.WrittenOwns(entry, id, entriesInCatalog)); + RefuseUndeclaredOnDisk( + keys, + UndeclaredAt(heldAt, catalog, key => removedKeys.Contains(key) ? null : writtenAt(key), removalOwns), + durableAt, + foreignAt, + unstage, + cacheless); + } + + // The apply half of rule 3, for every store kind, checked where the write lands under the store's lock. undeclaredAt is the + // store's per-key judge: for each key the write names it says whether the durable record there is one the write may not + // replace, remove or relabel. The rule it must apply is per key and per written type, never one predicate per stored id: a + // key the write puts a record at is owned only by the type written there (its own id, an id it lists, or an older id of its + // own schema name it reads whole), so another registered type's record is refused; a key the write only removes or checks + // is owned by any registered type that declares or reads it whole; and a key the cache removed and the write then adds again + // is judged as the removal it names. Every key at once is refused through Refuse, which reloads it as the store holds it: + // listed foreign when no type reads it, held read-only when one does. The shipped stores call the overload that builds + // this judge; this one takes a judge a store kind outside the library built itself. + protected void RefuseUndeclaredOnDisk( + IEnumerable written, + Func undeclaredAt, + Func durableAt, + Func foreignAt, + Action unstage, + bool cacheless = false) + { + var refused = written.Distinct(StringComparer.Ordinal) + .Select(undeclaredAt) + .OfType<(string Key, string SchemaId, string SchemaName)>() + .OrderBy(record => record.Key, StringComparer.Ordinal) + .ToArray(); + if (refused.Length == 0) + return; + Refuse(refused.Select(record => record.Key), durableAt, foreignAt, unstage, inner => Overwrites(inner, refused, cacheless)); + } + + /// + /// The one refusal of a write, for every store kind: it reloads what it refuses, or changes nothing. For each key, + /// gives the durable record decoded as a load would (null when the store holds none, or holds + /// it as a foreign record), and the foreign record it holds there, if any. What differs from this + /// store's view is handed to , the path a pull uses; only when that succeeds does the store forget its + /// staged change at each key (), take the loaded and dropped records into its view, and list the + /// keys' foreign records as a pull lists them. If the reload throws, nothing has changed and the refusal carries that + /// exception. Always throws what returns. + /// + protected void Refuse( + IEnumerable keys, + Func durableAt, + Func foreignAt, + Action unstage, + Func refusal) + { + var refused = keys.Distinct(StringComparer.Ordinal).OrderBy(key => key, StringComparer.Ordinal).ToArray(); + var loaded = new List(); + var dropped = new List(); + var foreign = new List(); + try + { + foreach (var key in refused) + { + if (foreignAt(key) is { } carried) + foreign.Add(carried); + Entries.TryGetValue(key, out var held); + var durable = durableAt(key); + if (durable == null) + { + if (held != null) + dropped.Add(held); + } + else if (held == null || !SameStoredIdentity(held.StoredVersion, durable.StoredVersion)) + loaded.Add(durable); + } + + if (loaded.Count > 0 || dropped.Count > 0) + Loaded?.Invoke(loaded, dropped); + } + catch (Exception ex) + { + throw refusal(ex); + } + + foreach (var key in refused) + { + unstage(key); + _checks.Remove(key); + _removed.Remove(key); + } + foreach (var held in dropped) + Entries.TryRemove(held.Key.Value, out _); + foreach (var stored in loaded) + Entries[stored.Key.Value] = stored; + SetForeignRecords(_foreignRecords.Where(record => !refused.Contains(record.Key, StringComparer.Ordinal)).Concat(foreign)); + throw refusal(null); + } + + // Two stored records are the same stored bytes when their versions are equal. The one place a pull's reload filter, the + // staleness check and a refusal's reload decide it. + protected static bool SameStoredIdentity(string? version, string? otherVersion) => + string.Equals(version, otherVersion, StringComparison.Ordinal); + + // The one spelling of a version in both store kinds: the lowercase hex of the SHA-256 of a record's persisted encoding. + protected static string VersionOf(byte[] sha256) + { + var hex = new StringBuilder(sha256.Length * 2); + foreach (var value in sha256) + hex.Append(value.ToString("x2", CultureInfo.InvariantCulture)); + return hex.ToString(); + } + + // The one re-mint, for every store kind (storedat-on-change): a record written over one the store holds is stored at a storedAt + // later than the one it replaces, whoever wrote that. The staged storedAt stands only when it is already later (both parsed + // RoundtripKind; an unparsable one counts as not later); otherwise it is minted from the replaced one. + protected string StoredAtOver(string storedAt, string replacedAt) => + StoredLater(storedAt, replacedAt) ? storedAt : CultCache.MintStoredAt(replacedAt, (Cache?.Clock ?? (() => DateTimeOffset.UtcNow))()); + + private static bool StoredLater(string storedAt, string than) => + DateTimeOffset.TryParse(storedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var later) && + DateTimeOffset.TryParse(than, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var earlier) && + later > earlier; + + // The one refusal of a write onto records the writer does not own, carried foreign or held read-only. + // It names each record's key, stored id and schema name, never its payload. When the reload that refusal owes failed, the + // refusal carries that failure as its inner exception. + // A cacheless writer (a CultMesh single-file write) owns only what the catalog entry it writes declares or reads whole, and has nothing to reload. + protected CultSchemaConflictException Overwrites(Exception? inner, (string Key, string SchemaId, string SchemaName)[] refused, bool cacheless = false) => new( + $"{(refused.Length == 1 ? "Record" : "Records")} {string.Join(", ", refused.Select(record => $"'{record.Key}' (schema id '{record.SchemaId}', '{record.SchemaName}')"))} in {this}: " + + (cacheless + ? "the catalog entry this write publishes neither declares this schema id nor reads it whole, so the write never relabels or removes it; nothing was written and nothing was reloaded. " + + "List the id as compatible on the schema that should own it to accept the loss of its unread slots." + : "no registered type owns or declares this schema id, or the type that reads it would drop persisted slots, so this cache never writes, relabels or removes it. " + + "Declare the id compatible on the type that should own it to accept the loss.") + + (inner == null ? string.Empty : " Reloading what the write refused failed, so the cache is unchanged."), + refused[0].SchemaId, + refused.Select(record => record.SchemaName).Distinct(StringComparer.Ordinal).ToArray(), + refused[0].Key, + inner); + + // A flush or commit succeeded: nothing staged is left, writes or checks. + protected void MarkFlushSucceeded() + { + IsDirty = false; + _checks.Clear(); + _removed.Clear(); } } @@ -3352,9 +4375,89 @@ namespace GameCult.Caching } protected FileInfo FileInfo { get; } - protected abstract byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot); - protected abstract CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data); - protected abstract byte[] SerializePayload(object document); + + // Keys this cache has written or removed here since the store last wrote them: the only keys a foreign record can refuse. + // A key is staged exactly while the store is dirty with it, and a pull runs only on a clean store. + private readonly HashSet _staged = new(StringComparer.Ordinal); + + // The version of every record the store held when this store last read it, as this store last wrote it for keys it wrote. + // A write into a store holding a variant lands only while the durable versions still equal these. + private readonly Dictionary _lastRead = new(StringComparer.Ordinal); + + /// + /// A store file from its parts, each already encoded: a catalog entry or record this store carries forward is the bytes its + /// file held, one it writes is or of the object. + /// + protected abstract byte[] SerializeStore(string formatVersion, IReadOnlyList catalogEntries, IReadOnlyList records); + + // One record's persisted encoding: the bytes a version names, and the bytes a written record is stored as. + protected abstract byte[] SerializeRecord(CultPersistedRecord record); + + // One catalog entry's persisted encoding, for an entry the writer built. + protected abstract byte[] SerializeCatalogEntry(CultSchemaCatalogEntry entry); + + /// + /// This store's one reader of a non-empty store file, and so its one verdict on whether the file may be replaced: open, + /// flush and commit all ask it. It must throw for anything that is not exactly one + /// store this runtime reads (a truncated file, bytes after the store, a missing or extra slot, a format or record it does + /// not know), so a rewrite never overwrites a file it cannot see. It returns the decoded snapshot and, parallel to the + /// snapshot's catalog and records, the exact bytes the file holds for each: the store keeps those, never the snapshot's objects. + /// + protected abstract (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) ReadStore(byte[] data); + + // Test seam: how many record encodings this store has hashed, to prove what a write hashes. + internal int RecordsHashed; + + // Test seam: how many copied records a write decoded to ask whether they hold an element id. + internal int CopiedRecordsDecoded; + + private string VersionOfBytes(byte[] bytes) + { + RecordsHashed++; + using var sha256 = SHA256.Create(); + return VersionOf(sha256.ComputeHash(bytes)); + } + + // What the store's file holds, as the store owns it: the snapshot as read, and keyed by record key the exact slice the file + // holds for each record, whose version is the SHA-256 of that slice, hashed when a comparison first asks. Catalog entries + // keep their slices by the entry object this read produced. A write copies a record it did not stage from its slice here + // and re-encodes every record it was handed, so no object a caller edits is ever mistaken for the bytes in the file. + private sealed class Durable + { + private readonly SingleFileBackingStore _owner; + private readonly Dictionary _records; + private readonly Dictionary _versions = new(StringComparer.Ordinal); + private readonly Dictionary _catalog = new(); + + internal Durable(SingleFileBackingStore owner, (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) read) + { + _owner = owner; + Snapshot = read.Snapshot; + _records = new Dictionary(StringComparer.Ordinal); + for (var index = 0; index < Snapshot.Records.Length; index++) + _records[Snapshot.Records[index].Key] = read.RecordBytes[index]; + for (var index = 0; index < Snapshot.SchemaCatalog.Length; index++) + _catalog.Add(Snapshot.SchemaCatalog[index], read.CatalogBytes[index]); + } + + internal CultPersistedStoreSnapshot Snapshot { get; } + + internal byte[] Slice(string key) => _records[key]; + + internal string Version(string key) + { + if (!_versions.TryGetValue(key, out var version)) + _versions[key] = version = _owner.VersionOfBytes(_records[key]); + return version; + } + + // The bytes to write for a catalog entry the write chose: the slice the file held when it is one of this read's entries, + // otherwise an encoding of the entry the writer built. + internal byte[] CatalogBytes(CultSchemaCatalogEntry entry) => + _catalog.TryGetValue(entry, out var bytes) ? bytes : _owner.SerializeCatalogEntry(entry); + } + + protected abstract byte[] SerializePayload(object document, Type documentType); protected abstract object DeserializePayload(Type documentType, byte[] payload); public override string ToString() => FileInfo.FullName; @@ -3368,26 +4471,37 @@ namespace GameCult.Caching if (IsDirty) return; - var snapshot = ReadSnapshot(); - if (snapshot == null) + var durable = ReadSnapshot(); + if (durable == null) { + _lastRead.Clear(); SetLastSchemaMigrationReports(Array.Empty()); + SetForeignRecords(Array.Empty()); return; } + var snapshot = durable.Snapshot; var reports = new List(snapshot.Records.Length); var persisted = new Dictionary(StringComparer.Ordinal); + var foreign = new List(); + var versions = new Dictionary(StringComparer.Ordinal); foreach (var record in snapshot.Records) { - var stored = ToStoredDocument(record, snapshot.SchemaCatalog, DeserializePayload, out var report); + var version = versions[record.Key] = durable.Version(record.Key); + if (Foreign(record, snapshot.SchemaCatalog, version) is { } carried) + { + foreign.Add(carried); + continue; + } + + var stored = ToStoredDocument(record, version, snapshot.SchemaCatalog, DeserializePayload, out var report); reports.Add(report); persisted[stored.Key.Value] = stored; } var loaded = persisted.Values .Where(stored => !Entries.TryGetValue(stored.Key.Value, out var existing) || - existing.StoredAt != stored.StoredAt || - existing.PersistedSchemaId != stored.PersistedSchemaId) + !SameStoredIdentity(existing.StoredVersion, stored.StoredVersion)) .ToArray(); var dropped = Entries.Values.Where(existing => !persisted.ContainsKey(existing.Key.Value)).ToArray(); if (loaded.Length > 0 || dropped.Length > 0) @@ -3397,7 +4511,11 @@ namespace GameCult.Caching Entries.TryRemove(stored.Key.Value, out _); foreach (var stored in loaded) Entries[stored.Key.Value] = stored; + _lastRead.Clear(); + foreach (var (key, version) in versions) + _lastRead[key] = version; SetLastSchemaMigrationReports(reports); + SetForeignRecords(foreign); } public override void Push(CultStoredDocument entry) @@ -3406,6 +4524,7 @@ namespace GameCult.Caching Held(() => { Entries[entry.Key.Value] = entry; + _staged.Add(entry.Key.Value); IsDirty = true; }); } @@ -3416,12 +4535,16 @@ namespace GameCult.Caching Held(() => { Entries.TryRemove(entry.Key.Value, out _); + _staged.Add(entry.Key.Value); IsDirty = true; }); } - // A plain flush writes this store's whole view under the lock: two writers never interleave bytes, but the last - // one wins. Processes sharing a store must use conditional commit. + // A flush applies this store's staged writes and removals onto what the file holds now, under the lock: two writers never + // interleave bytes, every record this cache did not stage stays as the file has it, and a plain flush is last-writer-wins + // only per staged key. A key whose durable record is under an id this cache does not declare refuses the write, and in a store + // holding a variant it lands only onto the store as this cache last read it (both ApplyWriteSet). + // A file it cannot read (truncated, trailing bytes, not a store, a format it does not read) is refused and left as it is. public override void PushAll() { ThrowIfReadOnly(); @@ -3429,13 +4552,12 @@ namespace GameCult.Caching { using (AcquireLock(wait: true)) { - WriteSnapshot( - Entries.Values.Select(entry => ToPersistedRecord(entry, SerializePayload)), - Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry())); + var disk = ReadSnapshot(); + var (upserts, removals) = StagedWrites(Array.Empty(), Array.Empty()); + Wrote(ApplyWriteSet(disk, upserts, removals, Array.Empty())); } - foreach (var entry in Entries.Values) - MarkWritten(entry); + _staged.Clear(); MarkFlushSucceeded(); }); } @@ -3446,88 +4568,449 @@ namespace GameCult.Caching return Held(() => CommitBatchCore(request, wait)); } + // Conditions are checked against the file under the lock, and only the condition differs between a conditional and an + // unconditional commit: both apply the batch and every staged write onto the file as it is. private CultCommitOutcome CommitBatchCore(CultCommitRequest request, bool wait) { using var fileLock = AcquireLock(wait); if (fileLock == null) return CultCommitOutcome.Contended; - var disk = ReadSnapshot() ?? new CultPersistedStoreSnapshot(); - if (!request.ConditionsHold(disk.Records, Entries.Values)) + var disk = ReadSnapshot(); + if (request.HasConditions && !request.ConditionsHold( + (disk?.Snapshot.Records ?? Array.Empty()).ToDictionary(record => record.Key, record => disk!.Version(record.Key), StringComparer.Ordinal), + Entries.Values, + ForeignRecords)) return CultCommitOutcome.Mismatch; - // An unconditional commit writes what a flush would, this store's whole view plus the batch: last-writer-wins, - // and staged single writes land with it. A conditional commit (store clean) lands the batch onto the file as it is. - var ontoDisk = request.HasConditions && !IsDirty; - var records = ontoDisk - ? disk.Records.ToDictionary(record => record.Key, StringComparer.Ordinal) - : Entries.Values.Select(entry => ToPersistedRecord(entry, SerializePayload)).ToDictionary(record => record.Key, StringComparer.Ordinal); - var catalog = ontoDisk - ? disk.SchemaCatalog - : Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry()); - // The committer judged the batch against its own view; the file may have moved since. A merge is judged on the - // set the file will hold, exactly as that set would be judged loading: what other processes wrote (arriving), - // what they removed (departing), and this batch. - if (ontoDisk && Judging != null && MergeInvolvesVariants(request, disk)) - JudgeMerge(request, disk); - foreach (var entry in request.Deletes) - records.Remove(entry.Key.Value); - foreach (var entry in request.Upserts) - records[entry.Key.Value] = ToPersistedRecord(entry, SerializePayload); - - WriteSnapshot(records.Values, catalog.Concat(request.Upserts.Select(entry => entry.Descriptor.ToCatalogEntry()))); + var (upserts, removals) = StagedWrites(request.Upserts, request.Deletes); + // A key the batch deletes and also upserts is a retype: the upsert wins in the write set, so the deletion travels as a check, + // and the judge reads the key as the removal it names. + var written = ApplyWriteSet(disk, upserts, removals, request.Checks.Concat(request.Deletes.Select(entry => entry.Key.Value))); foreach (var entry in request.Deletes) Entries.TryRemove(entry.Key.Value, out _); foreach (var entry in request.Upserts) Entries[entry.Key.Value] = entry; - // The whole view was written, each record under its own descriptor; onto the disk, the rest kept their bytes. - if (!ontoDisk) - { - foreach (var entry in Entries.Values) - MarkWritten(entry); - } - + Wrote(written); + _staged.Clear(); MarkFlushSucceeded(); return CultCommitOutcome.Committed; } - // Only a variant makes a merge more than the committer's own judgement: a variant it lands, one another writer wrote - // (read from the record's header, never its payload), or one this cache holds. A plain-only store never decodes - // another writer's records, so a schema this cache does not register cannot fail a commit that involves no variant. - private bool MergeInvolvesVariants(CultCommitRequest request, CultPersistedStoreSnapshot disk) => - Cache?.HoldsVariants == true || - request.Upserts.Any(entry => entry.Variant != null) || - disk.Records.Any(record => record.Variant != null); - - private void JudgeMerge(CultCommitRequest request, CultPersistedStoreSnapshot disk) + // What a write puts in the store and takes out of it: the staged keys as this cache holds them now (a key it no longer holds + // is a removal), and a commit's batch, which wins over a staged write of the same key. Records it did not stage are not here. + private (List Upserts, List Removals) StagedWrites( + IReadOnlyList batchUpserts, + IReadOnlyList batchDeletes) { - var landing = request.Upserts.Select(entry => entry.Key.Value).ToHashSet(StringComparer.Ordinal); - var removing = request.Deletes.Select(entry => entry.Key.Value).ToHashSet(StringComparer.Ordinal); - var onDisk = disk.Records.Select(record => record.Key).ToHashSet(StringComparer.Ordinal); - var arriving = new List(request.Upserts); - foreach (var record in disk.Records) + var documents = new Dictionary(StringComparer.Ordinal); + var removals = new HashSet(StringComparer.Ordinal); + foreach (var key in _staged) { - if (landing.Contains(record.Key) || removing.Contains(record.Key)) - continue; - if (Entries.TryGetValue(record.Key, out var known) && known.StoredAt == record.StoredAt && known.PersistedSchemaId == record.SchemaId) - continue; - arriving.Add(ToStoredDocument(record, disk.SchemaCatalog, DeserializePayload, out _)); + if (Entries.TryGetValue(key, out var held)) + documents[key] = held; + else + removals.Add(key); } - var departing = new List(request.Deletes); - departing.AddRange(Entries.Values.Where(known => - !onDisk.Contains(known.Key.Value) && !landing.Contains(known.Key.Value) && !removing.Contains(known.Key.Value))); - Judging!(arriving, departing); + foreach (var entry in batchDeletes) + { + documents.Remove(entry.Key.Value); + removals.Add(entry.Key.Value); + } + + foreach (var entry in batchUpserts) + { + documents[entry.Key.Value] = entry; + removals.Remove(entry.Key.Value); + } + + var catalogEntries = new Dictionary(); + var upserts = documents.Values.Select(document => + { + if (!catalogEntries.TryGetValue(document.Descriptor, out var catalogEntry)) + catalogEntries[document.Descriptor] = catalogEntry = document.Descriptor.ToCatalogEntry(); + return new CultStagedRecord(ToPersistedRecord(document, payload => SerializePayload(payload, payload.GetType())), catalogEntry, document.HoldsIds); + }).ToList(); + return (upserts, removals.ToList()); } - private CultPersistedStoreSnapshot? ReadSnapshot() + /// + /// Writes one record to the store file without a cache. Under the store's lock the file is read by the same reader a cache's + /// store uses, replaces the record at its key, and every other record and catalog entry is copied + /// forward as stored. The file is replaced atomically, and its format header is decided as a flush decides it: + /// true marks the store as holding element ids, false leaves it unmarked, and null (a payload + /// the caller cannot read) keeps whatever the file already says. A write that finds the file unreadable, or a record at + /// the key under a schema id does not declare, is refused and leaves the file as it was. + /// + /// The record to store. + /// The schema catalog entry for the record's schema id. + /// Whether the record's payload holds element ids, or null when the caller cannot tell. + /// The registry the payload was encoded with. Given, the write decides the header for copied records that registry can read. It never decides ownership: the answer is the same lent or not. alone owns the record at the key when it declares the stored id (its own or one it lists compatible), or when it is the type that reads the stored entry and reads it whole, so an older id of its own schema name it reads whole is its own and is restamped. Everything else is refused with the file left as it was: a record of another registered type included, whatever the registry holds, and a record it would read only by dropping persisted slots. Retyping a key is a removal and an add. It serves this call only: the store keeps no registry. + /// The call holds the store's gate while it runs. User code it runs must not enter a cache or another store; that throws . + /// The store is attached to a cache; write through the cache. + /// The file at the path is not a store this runtime can read. + public void WriteRecord(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds = null, CultDocumentRegistry? registry = null) { - FileInfo.Refresh(); - if (!FileInfo.Exists) + if (record == null) throw new ArgumentNullException(nameof(record)); + if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry)); + Cacheless(registry, () => WriteOne(record, catalogEntry, holdsIds)); + } + + /// + /// Writes one typed document to the store file without a cache, as a cache write would. Element ids are decided before anything + /// is written: an unset id is minted, a list a cache write refuses refuses the write, and the file is marked as holding element + /// ids when the document holds one. A write that fails gives the minted ids back. Then the document is encoded as + /// , the type its schema id and ids are taken from, with , and stored as + /// stores a record: every other record and catalog entry is copied forward as stored, under the store's + /// lock, and the file is replaced atomically. + /// + /// The registered document type. A derived instance is stored as this type. + /// The record key the document is stored under. + /// The document to store. + /// The stored-at stamp, or null for now. + /// The registry that describes and encodes the document, or null for . The document type's entry is taken from it, and the registry decides the header for copied records it can read. It never decides ownership, so the answer is the same for any registry that holds the type: the document's entry owns a record whose id it declares (its own or one it lists compatible), and a record of an older id of its own schema name that it reads whole; everything else is refused, another registered type's record included, and a record it would read only by dropping persisted slots. Retyping a key is a removal and an add. It serves this call only: the store keeps no registry. + /// The call holds the store's gate while it runs. User code it runs (property getters while the document is encoded, constructors while a copied record is read) must not enter a cache or another store; that throws , carried by the encoder's wrapper when the code ran inside it. + /// The store is attached to a cache; write through the cache. + /// The file at the path is not a store this runtime can read. + /// The document holds an element id or list a cache write refuses. + public void WriteDocument(CultRecordKey key, TDocument document, string? storedAt = null, CultDocumentRegistry? registry = null) + where TDocument : class + { + if (document == null) throw new ArgumentNullException(nameof(document)); + var resolvedRegistry = registry ?? CultDocumentRegistry.Shared; + var catalogEntry = resolvedRegistry.GetRequired().ToCatalogEntry(); + Cacheless(resolvedRegistry, () => + { + var ids = CultElementIds.Plan(typeof(TDocument), document, key.Value, deterministic: false); + ids.Apply(); + try + { + WriteOne( + new CultPersistedRecord + { + Key = key.Value, + SchemaId = catalogEntry.SchemaId, + StoredAt = string.IsNullOrWhiteSpace(storedAt) ? DateTimeOffset.UtcNow.ToString("O") : storedAt!, + Payload = SerializePayload(document, typeof(TDocument)) + }, + catalogEntry, + CultElementIds.Holds(typeof(TDocument), document)); + } + catch + { + ids.Undo(); + throw; + } + }); + } + + private void WriteOne(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds) + { + using (AcquireLock(wait: true)) + _ = ApplyWriteSet(ReadSnapshot(), new[] { new CultStagedRecord(record, catalogEntry, holdsIds) }, Array.Empty(), Array.Empty()); + } + + // Flush, commit and Mesh's write all end here, with the durable store they read under the lock they hold. First, a key the write + // names or checks whose durable record is under an id the writer does not declare refuses the write (RefuseUndeclaredOnDisk); a + // write that then has nothing to write but keys it checked writes no file. Then a store that holds + // a variant, or would after this write, is written only while its record versions are what this store last read: a variant + // resolves against other records, and a write decodes none it did not stage, so it cannot judge another writer's. Versions + // only: a write hashes other records' bytes, never decodes them. That holds for the variant gate; the one decode of a copied + // record is the element-id check (CopiedHoldIds), which runs only on an unmarked store and can only mark it. Otherwise the whole write is refused and what it refused is + // reloaded (Refuse). Every record the writer staged is encoded from its object now, and its version is the hash of that + // encoding; every other record is copied as the slice the file holds. Returns the records written, each with the version of + // the bytes written. + private IReadOnlyCollection<(CultStagedRecord Staged, string Version)> ApplyWriteSet( + Durable? durable, IReadOnlyCollection upserts, IReadOnlyCollection removals, IEnumerable checks) + { + RefuseUndeclaredOnDisk(durable, upserts, removals, checks); + if (upserts.Count == 0 && removals.Count == 0 && (checks.Any() || StagedChecks.Count > 0)) + return Array.Empty<(CultStagedRecord, string)>(); + RefuseWriteIntoMovedVariantStore(durable, upserts, removals); + var durableRecords = durable?.Snapshot.Records ?? Array.Empty(); + // A record written over one the store holds is stored at a storedAt later than the one it replaces, whoever wrote that. + var replaced = durableRecords.ToDictionary(record => record.Key, record => record.StoredAt, StringComparer.Ordinal); + foreach (var staged in upserts) + { + if (replaced.TryGetValue(staged.Record.Key, out var durableAt)) + staged.Record.StoredAt = StoredAtOver(staged.Record.StoredAt, durableAt); + } + + var written = upserts.Select(staged => staged.Record.Key).ToHashSet(StringComparer.Ordinal); + var removed = removals.ToHashSet(StringComparer.Ordinal); + var copied = durableRecords + .Where(record => !written.Contains(record.Key) && !removed.Contains(record.Key)) + .ToArray(); + var encoded = upserts.Select(staged => (Staged: staged, Bytes: SerializeRecord(staged.Record))).ToArray(); + var records = copied.Concat(upserts.Select(staged => staged.Record)).ToArray(); + var fileRecords = copied.Select(record => (record.Key, Bytes: durable!.Slice(record.Key))) + .Concat(encoded.Select(pair => (pair.Staged.Record.Key, pair.Bytes))) + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => pair.Bytes) + .ToArray(); + var catalog = CultSchemaCatalogEntry.Derive( + records, + upserts.Select(staged => staged.Entry).Distinct().ToArray(), + durable?.Snapshot.SchemaCatalog ?? Array.Empty()) + .OrderBy(entry => entry.SchemaName, StringComparer.Ordinal) + .ThenBy(entry => entry.SchemaId, StringComparer.Ordinal) + .Select(entry => durable?.CatalogBytes(entry) ?? SerializeCatalogEntry(entry)) + .ToArray(); + var format = HeaderForWrite( + durable?.Snapshot.FormatVersion, + upserts.Select(staged => staged.HoldsIds).ToArray(), + copiesAny: copied.Length > 0, + copiedHoldIds: CopiedHoldIds(durable, copied, upserts), + directoryStore: false, + holdsVariants: records.Any(record => record.Variant != null)); + + var versions = encoded.Select(pair => (pair.Staged, VersionOfBytes(pair.Bytes))).ToArray(); + Directory.CreateDirectory(FileInfo.DirectoryName!); + WriteSnapshotAtomically(FileInfo.FullName, SerializeStore(format, catalog, fileRecords)); + foreach (var key in removed) + _lastRead.Remove(key); + foreach (var (staged, version) in versions) + _lastRead[staged.Record.Key] = version; + return versions; + } + + // Whether a record this write copies forward holds an element id, decided from the bytes the file holds under the store's + // lock, so every C# writer with a type for the record decides the same from the same bytes. Positive-only: it runs only + // while the header on disk is unmarked and can only mark the store, so a narrower type that decodes no ids changes nothing. + // It never refuses a write: a record that does not resolve or decode is copied as its bytes and decides nothing. Gates, in + // order, so the decode runs only where it can change the answer. + private bool CopiedHoldIds(Durable? durable, CultPersistedRecord[] copied, IReadOnlyCollection upserts) + { + if (durable == null || + string.Equals(durable.Snapshot.FormatVersion, CultPersistedStoreSnapshot.FormatV3, StringComparison.Ordinal) || + upserts.Any(staged => staged.HoldsIds == true) || + AttachedRegistry is not { } registry) + return false; + var catalog = durable.Snapshot.SchemaCatalog; + foreach (var record in copied) + { + if (record.Variant != null) + continue; + try + { + if (registry.Claimant(record.SchemaId, catalog, out _) == null) + continue; + var type = registry.ResolvePersistedSchemaDetailed(record.SchemaId, catalog).Descriptor.DocumentType; + if (!CultElementIds.CanHoldIds(type)) + continue; + CopiedRecordsDecoded++; + if (CultElementIds.Holds(type, DeserializePayload(type, record.Payload))) + return true; + } + catch (Exception e) when (e is not OperationCanceledException) + { + // Copied as its bytes: no type, or no decode, is no fact. + } + } + + return false; + } + + private void RefuseWriteIntoMovedVariantStore(Durable? durable, IReadOnlyCollection upserts, IReadOnlyCollection removals) + { + var records = durable?.Snapshot.Records ?? Array.Empty(); + if (records.All(record => record.Variant == null) && upserts.All(staged => staged.Record.Variant == null)) + return; + var changed = new HashSet(StringComparer.Ordinal); + var versions = records.ToDictionary(record => record.Key, record => durable!.Version(record.Key), StringComparer.Ordinal); + foreach (var (key, version) in versions) + { + if (!_lastRead.TryGetValue(key, out var read) || !SameStoredIdentity(read, version)) + changed.Add(key); + } + + var durableKeys = records.Select(record => record.Key).ToHashSet(StringComparer.Ordinal); + foreach (var key in _lastRead.Keys) + { + if (!durableKeys.Contains(key)) + changed.Add(key); + } + + if (changed.Count == 0) + return; + var recordKeys = _staged.Concat(upserts.Select(staged => staged.Record.Key)).Concat(removals) + .Distinct(StringComparer.Ordinal).OrderBy(key => key, StringComparer.Ordinal).ToArray(); + var changedKeys = changed.OrderBy(key => key, StringComparer.Ordinal).ToArray(); + Refuse( + recordKeys.Concat(changedKeys), + key => Cache == null ? null : DurableAt(durable, key), + key => ForeignAt(durable, key), + key => + { + _staged.Remove(key); + IsDirty = _staged.Count > 0; + if (versions.TryGetValue(key, out var version)) + _lastRead[key] = version; + else + _lastRead.Remove(key); + }, + inner => new CultWriteConflictException( + Cache == null + ? $"{this} holds a variant and was written by a writer with no CultCache, which cannot resolve variants; nothing was written. " + + $"Records it would have written or removed: {string.Join(", ", recordKeys.Select(key => $"'{key}'"))}; records that differ from what the writer has read: {string.Join(", ", changedKeys.Select(key => $"'{key}'"))}." + : $"{this} changed since this cache last read it, and it holds a variant, which resolves against other records; nothing was written. " + + $"Records this write would have written or removed: {string.Join(", ", recordKeys.Select(key => $"'{key}'"))}; records that changed: {string.Join(", ", changedKeys.Select(key => $"'{key}'"))}. " + + (inner == null + ? "The cache reloaded them; retry the write against what the store holds now." + : "Reloading them failed, so the cache is unchanged."), + recordKeys, + changedKeys, + inner)); + } + + // A writable store judges a removal of a key its cache does not hold at its next flush or commit, under the lock (StageChecks); + // a read-only store has neither, so it answers from its view and never creates the lock file. + protected internal override void RefuseRemovalOfUndeclared(IReadOnlyCollection keys) + { + if (IsReadOnly) + base.RefuseRemovalOfUndeclared(keys); + else + StageChecks(keys); + } + + // A read-only store answers a batch's checks from its view and never creates the lock file; a writable one reads the file + // under its lock. + protected internal override CultCommitOutcome JudgeChecks(IReadOnlyCollection keys, bool wait) + { + if (IsReadOnly) + return base.JudgeChecks(keys, wait); + return Held(() => + { + using var fileLock = AcquireLock(wait); + if (fileLock == null) + return CultCommitOutcome.Contended; + RefuseUndeclaredOnDisk(ReadSnapshot(), keys, Array.Empty(), Array.Empty()); + return CultCommitOutcome.Committed; + }); + } + + // Every key staged, written, removed or checked is judged, each by the one per-key judge (UndeclaredAt). + private void RefuseUndeclaredOnDisk(Durable? durable, IReadOnlyCollection upserts, IReadOnlyCollection removals, IEnumerable checks) => + RefuseUndeclaredOnDisk( + durable, + _staged.Concat(upserts.Select(staged => staged.Record.Key)).Concat(removals).Concat(checks).Concat(StagedChecks), + upserts, + checks); + + // A key the write also names as a removal or check (the request's, or one the cache removed here since its last flush) is a + // retype's removal half: it is judged by the removal rule, and the record staged there then lands. A refused key's durable + // version is what this store has now read of it. + private void RefuseUndeclaredOnDisk(Durable? durable, IEnumerable written, IReadOnlyCollection upserts, IEnumerable checks) + { + var keys = written.Distinct(StringComparer.Ordinal).ToArray(); + var wanted = keys.ToHashSet(StringComparer.Ordinal); + var held = (durable?.Snapshot.Records ?? Array.Empty()) + .Where(record => wanted.Contains(record.Key)) + .ToDictionary(record => record.Key, StringComparer.Ordinal); + var removed = checks.ToHashSet(StringComparer.Ordinal); + var writtenEntries = upserts.ToDictionary(staged => staged.Record.Key, staged => staged.Entry, StringComparer.Ordinal); + RefuseUndeclaredOnDisk( + keys, + durable?.Snapshot.SchemaCatalog ?? Array.Empty(), + key => held.TryGetValue(key, out var record) ? record : null, + key => writtenEntries.TryGetValue(key, out var entry) ? entry : null, + removed, + key => Cache == null ? null : DurableAt(durable, key), + key => ForeignAt(durable, key), + key => + { + _staged.Remove(key); + IsDirty = _staged.Count > 0; + _lastRead[key] = durable!.Version(key); + }, + cacheless: Cache == null); + } + + // The durable record at a key as a load lists it when no registered type claims its id; null otherwise. + private CultForeignRecord? ForeignAt(Durable? durable, string key) + { + if (durable?.Snapshot.Records.FirstOrDefault(candidate => string.Equals(candidate.Key, key, StringComparison.Ordinal)) is not { } record || + Foreign(record, durable.Snapshot.SchemaCatalog, durable.Version(key)) is not { } carried) + return null; + return carried; + } + + // The durable record at a key decoded as a load decodes it, at its durable version: null when the store holds none there or + // holds it foreign. The one decode both refusals reload through. + private CultStoredDocument? DurableAt(Durable? durable, string key) + { + var record = durable?.Snapshot.Records.FirstOrDefault(candidate => string.Equals(candidate.Key, key, StringComparison.Ordinal)); + if (record == null || Foreign(record, durable!.Snapshot.SchemaCatalog, durable.Version(key)) != null) + return null; + return ToStoredDocument(record, durable.Version(key), durable.Snapshot.SchemaCatalog, DeserializePayload, out _); + } + + // The file now holds every record written, under the id and at the storedAt the write gave it, and the version of those bytes: + // the store's entries and the cache's say so. A copied record is on disk as the file had it, so nothing learns anything new about it: in particular the + // ids it minted at load are still in memory only. + private void Wrote(IEnumerable<(CultStagedRecord Staged, string Version)> written) + { + var staged = written.ToArray(); + foreach (var (write, version) in staged) + { + if (!Entries.TryGetValue(write.Record.Key, out var entry)) + continue; + entry.StoredSchemaId = write.Record.SchemaId; + entry.StoredAt = write.Record.StoredAt; + entry.StoredVersion = version; + } + + Cache?.Wrote(staged.Select(write => (write.Staged.Record, write.Version))); + } + + // Open, flush and commit read the file through this one call, so they agree on which files are stores. A file that is + // gone is an empty store: nothing to read, nothing to lose. A zero-byte file is not a store; it is refused. + private Durable? ReadSnapshot() + { + var bytes = ReadDisk(); + if (bytes == null) return null; try { - return DeserializeSnapshot(ReadAllBytesShared(FileInfo.FullName)); + return new Durable(this, ReadStore(bytes)); + } + catch (CultStoreUnreadableException ex) when (ex.Path == null) + { + throw new CultStoreUnreadableException(ex.Message, FileInfo.FullName, ex.InnerException); + } + } + + /// + /// Reads the store file once, under the store's gate, with the reader a cache's store opens with, and returns what is + /// durable at the path: the records, the schema catalog and the format stamp. Returns null when nothing is at the path. + /// CultMesh's single-file document reads are built on this, so they share the store's one reader and its stamp rule. + /// + /// The file is empty, truncated, or stamped with a format this runtime does not read. + public CultPersistedStoreSnapshot? ReadStoreSnapshot() => Held(() => ReadSnapshot()?.Snapshot); + + private byte[]? ReadDisk() + { + FileInfo.Refresh(); + // Only nothing at the path is an empty store: a path with nothing at it reports no attributes at all (-1). A link whose + // target is gone is a store that cannot be reached, and a directory is not a store file. + var attributes = FileInfo.Attributes; + if ((int)attributes == -1) + return null; + if ((attributes & FileAttributes.Directory) != 0 && (attributes & FileAttributes.ReparsePoint) == 0) + throw new IOException("The store path is a directory, not a store file."); + try + { + return ReadAllBytesShared(FileInfo.FullName); + } + catch (FileNotFoundException ex) when (PathIsALink()) + { + throw new IOException("The store path is a link whose target cannot be reached.", ex); + } + // A Windows junction is a directory, not a file, so opening one that dangles fails with an access error, not a missing file. + catch (UnauthorizedAccessException ex) when (PathIsALink() && (FileInfo.Attributes & FileAttributes.Directory) != 0) + { + throw new IOException("The store path is a link whose target cannot be reached.", ex); } catch (FileNotFoundException) { @@ -3535,27 +5018,13 @@ namespace GameCult.Caching } } - private void WriteSnapshot(IEnumerable records, IEnumerable catalog) + // Something is at the path even when the path cannot be opened: a link keeps the reparse-point attribute whether or not its + // target is there. A path with nothing at it reports no attributes at all (-1). + private bool PathIsALink() { - var ordered = records.OrderBy(record => record.Key, StringComparer.Ordinal).ToArray(); - var used = new HashSet(ordered.Select(record => record.SchemaId), StringComparer.Ordinal); - var snapshot = new CultPersistedStoreSnapshot - { - // v2 exactly when the store holds a variant: a store without one stays byte-identical v1. - FormatVersion = ordered.Any(record => record.Variant != null) - ? CultPersistedStoreSnapshot.FormatV2 - : CultPersistedStoreSnapshot.FormatV1, - SchemaCatalog = catalog - .GroupBy(entry => entry.SchemaId, StringComparer.Ordinal) - .Select(group => group.First()) - .Where(entry => used.Contains(entry.SchemaId)) - .OrderBy(entry => entry.SchemaName, StringComparer.Ordinal) - .ToArray(), - Records = ordered - }; - - Directory.CreateDirectory(FileInfo.DirectoryName!); - WriteSnapshotAtomically(FileInfo.FullName, SerializeSnapshot(snapshot)); + FileInfo.Refresh(); + var attributes = FileInfo.Attributes; + return (int)attributes != -1 && (attributes & FileAttributes.ReparsePoint) != 0; } // The lock is a sidecar opened exclusively, which excludes other handles in this process and in others alike. diff --git a/src/GameCult.Caching/CultDocumentContracts.cs b/src/GameCult.Caching/CultDocumentContracts.cs index 442f5c4f..b439ac9f 100644 --- a/src/GameCult.Caching/CultDocumentContracts.cs +++ b/src/GameCult.Caching/CultDocumentContracts.cs @@ -18,6 +18,14 @@ namespace GameCult.Caching public string SchemaName { get; } public string SchemaVersion { get; } + + /// + /// Schema ids other than this type's own that a store may hold its records under, typically the ids an earlier + /// version of the schema had. The registered catalog entry lists them, and a cache resolves a record stored under + /// one of them to this type (its own id is tried first) and restamps it to the type's own id on load. TypeScript + /// and Python name the same declaration compatibleSchemaIds. + /// + public string[] CompatibleSchemaIds { get; set; } = Array.Empty(); } [AttributeUsage(AttributeTargets.Class, Inherited = false)] diff --git a/src/GameCult.Caching/CultElementIds.cs b/src/GameCult.Caching/CultElementIds.cs new file mode 100644 index 00000000..38dfaaae --- /dev/null +++ b/src/GameCult.Caching/CultElementIds.cs @@ -0,0 +1,684 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Runtime.Serialization; +using System.Security.Cryptography; +using System.Text; +using MessagePack; + +namespace GameCult.Caching +{ + // The identity of one element of an object list. A string member, keyed like any other, on every object type that + // sits in a list of a registered document type (union bases included: every concrete subtype carries the member). + // [CultElementId(nameof(Offset))] declares the id derived, never random: an unset id becomes the invariant-culture text of + // that member (a keyed string or number on the same type, unique within its list). Content-addressed elements use it so the + // same content always encodes to the same bytes. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)] + public sealed class CultElementIdAttribute : Attribute + { + public CultElementIdAttribute(string? derivedFrom = null) + { + DerivedFrom = derivedFrom; + } + + public string? DerivedFrom { get; } + } + + // A record the id rule refuses: a duplicate id in one list, a random id that is not 12 lowercase hex characters, or a derived + // id whose source member is null or empty. It names the record, the list (record key, member slot, dictionary key and + // element index) and, where there is one, the element id and the source member. + public sealed class CultElementIdException : InvalidOperationException + { + public CultElementIdException(string message, string recordKey, string listPath, string? elementId = null, string? member = null) + : base(message) + { + RecordKey = recordKey; + ListPath = listPath; + ElementId = elementId; + Member = member; + } + + public string RecordKey { get; } + public string ListPath { get; } + public string? ElementId { get; } + public string? Member { get; } + } + + // The one owner of element identity: which types need an id, and how unset ids are filled. + // A random id is 12 lowercase hex characters (48 bits); a derived id is the invariant text of its source and only has to be + // non-empty. Either need only be unique within one list. A write mints a random id; a load of a store written before ids + // existed mints from SHA-256(record key, element path), so every reader of the same store mints the same id and a reload is + // identical to what was read. A write and a load refuse the same things. + internal static class CultElementIds + { + private const int IdBytes = 6; + + /// The element ids a pass would fill, decided before anything changes. sets them; gives back what Apply replaced. + internal sealed class IdPlan + { + private readonly List<(object Item, Shape Shape, string? Before, string Id)> _fills = new(); + + public int Count => _fills.Count; + + internal void Add(object item, Shape shape, string? before, string id) => _fills.Add((item, shape, before, id)); + + public void Apply() + { + foreach (var (item, shape, _, id) in _fills) + shape.SetId!(item, id); + } + + public void Undo() + { + foreach (var (item, shape, before, _) in _fills) + shape.SetId!(item, before!); + } + } + + internal sealed class Shape + { + public Func? GetId; + public Action? SetId; + public Func? Derive; + public string? DerivedFrom; + public (int Slot, Type Type, Func Get)[] Walk = Array.Empty<(int, Type, Func)>(); + } + + private static readonly ConcurrentDictionary Shapes = new(); + + // ---- registration: which element types must carry an id ---- + + internal sealed class Survey + { + public List Problems { get; } = new(); + + // Every type whose bytes carry an element id: the types a walk descends through to reach an object list, the list's + // element types and every concrete type under them. A serialization codec must write all of these by their [Key] + // layout, so it checks them against the formatters it composed. + public List IdPath { get; } = new(); + + // The concrete element types already judged in this document, so each is refused at most once however many lists hold it. + internal HashSet Judged { get; } = new(); + } + + // What the walk saw, settled once it is complete: a type reaches a list only through types reached later, and a type + // can sit in a cycle, so the path is a fixed point over these edges and not a stack. + private sealed class Graph + { + public List<(Type Owner, MemberInfo? Member, Type Child, Type[] Through)> Edges { get; } = new(); + public List<(Type Owner, MemberInfo Member, Type Element, List Concrete, Type[] Through)> Lists { get; } = new(); + + // Keyed types that also enumerate objects: the walk reads them by key, so the codec must confirm MessagePack does too. + public List Enumerating { get; } = new(); + } + + private static readonly ConcurrentDictionary IdCapable = new(); + + // A document type whose walk reaches no object list can hold no element id, so a write never decodes one to ask. + internal static bool CanHoldIds(Type documentType) => IdCapable.GetOrAdd(documentType, type => Inspect(type).IdPath.Count > 0); + + // Every reason this document type cannot be registered under the id rule, one per offending concrete element type. + internal static Survey Inspect(Type documentType) + { + var survey = new Survey(); + var graph = new Graph(); + VisitObject(documentType, documentType, new HashSet(), survey, graph); + Settle(documentType, survey, graph); + return survey; + } + + private static void VisitObject(Type documentType, Type type, HashSet seen, Survey survey, Graph graph) + { + if (!seen.Add(type)) + return; + if (EnumeratesObjects(type)) + graph.Enumerating.Add(type); + foreach (var (member, memberType) in KeyedMembers(type)) + VisitMemberType(documentType, type, member, memberType, Array.Empty(), seen, survey, graph); + foreach (var union in type.GetCustomAttributes(true)) + { + graph.Edges.Add((type, null, union.SubType, Array.Empty())); + VisitObject(documentType, union.SubType, seen, survey, graph); + } + } + + private static void VisitMemberType(Type documentType, Type owner, MemberInfo member, Type memberType, Type[] through, HashSet seen, Survey survey, Graph graph) + { + memberType = Nullable.GetUnderlyingType(memberType) ?? memberType; + // A sequence or map whose element is itself (an unkeyed Node : IEnumerable) ends the type walk here; without + // this it would recurse until the stack overflows. + if (Array.IndexOf(through, memberType) >= 0) + return; + var (kind, inner) = Classify(memberType); + switch (kind) + { + case Kind.Leaf: + // A shape the walk does not own is one whole value, never refused (q3 amended). + return; + case Kind.Map: + VisitMemberType(documentType, owner, member, inner, through.Append(memberType).ToArray(), seen, survey, graph); + return; + case Kind.Sequence when IsElement(inner): + var concretes = ConcreteTypes(inner).ToList(); + graph.Lists.Add((owner, member, inner, concretes, through.Append(memberType).ToArray())); + foreach (var concrete in concretes) + { + if (!survey.Judged.Add(concrete)) + continue; + var problem = IsElement(concrete) ? IdProblem(concrete) : UnkeyedElementProblem; + if (problem != null) + survey.Problems.Add($"Cult document {documentType.Name}: list element type {concrete.Name} (in {owner.Name}.{member.Name}) {problem}"); + } + + VisitObject(documentType, inner, seen, survey, graph); + return; + case Kind.Sequence: + VisitMemberType(documentType, owner, member, inner, through.Append(memberType).ToArray(), seen, survey, graph); + return; + default: + graph.Edges.Add((owner, member, memberType, through)); + VisitObject(documentType, memberType, seen, survey, graph); + return; + } + } + + private const string UnkeyedElementProblem = + "is written by a formatter the cache does not own, so its element id would never reach disk; mark it [MessagePackObject] with [Key] members, or keep it out of lists."; + + // The id path: every type that reaches an object list through members MessagePack writes by key, the lists' element and + // concrete types, and the keyed types that enumerate objects. The codec verifies that MessagePack writes each of them the + // way Classify reads it; it never decides ownership. + private static void Settle(Type documentType, Survey survey, Graph graph) + { + var lists = graph.Lists; + var reaching = new HashSet(lists.Select(list => list.Owner)); + var through = new List(lists.SelectMany(list => list.Through)); + for (var grew = true; grew;) + { + grew = false; + foreach (var (owner, _, child, via) in graph.Edges) + { + if (!reaching.Contains(child)) + continue; + through.AddRange(via); + grew |= reaching.Add(owner); + } + } + + var path = new List(); + void Add(Type type) + { + if (!path.Contains(type)) + path.Add(type); + } + + foreach (var type in reaching) + Add(type); + foreach (var type in through) + Add(type); + foreach (var list in lists) + { + Add(list.Element); + foreach (var concrete in list.Concrete) + Add(concrete); + } + + // A keyed type the walk reads by key but whose enumeration holds objects is on the path: the codec asks whether + // MessagePack writes it by key too. + foreach (var type in graph.Enumerating) + Add(type); + + survey.IdPath.AddRange(path); + } + + // The types an element can actually be: the element type itself when it can be instantiated, and every union subtype + // under it. Shapes are built from these, so registration judges exactly what minting will meet. + private static IEnumerable ConcreteTypes(Type element) + { + var seen = new HashSet(); + return Expand(element).ToList(); + + IEnumerable Expand(Type type) + { + if (!seen.Add(type)) + yield break; + if (!type.IsAbstract && !type.IsInterface) + yield return type; + foreach (var union in type.GetCustomAttributes(true)) + { + foreach (var subtype in Expand(union.SubType)) + yield return subtype; + } + } + } + + private static string? IdProblem(Type element) + { + // Candidates come from every instance member up the base chain, public or not, so an id MessagePack does not write + // is named as such instead of being reported missing. An override or a hiding member is one member, the most derived. + var ids = AllInstanceMembers(element).Where(member => Declares(member, typeof(CultElementIdAttribute))) + .GroupBy(member => member.Name).Select(group => group.First()).ToArray(); + if (ids.Length == 0) + return "has no [CultElementId] member; add a public string member with [Key] and [CultElementId] so each element has an identity."; + if (ids.Length > 1) + return $"has two [CultElementId] members ({ids[0].Name}, {ids[1].Name}); an element has one identity."; + var id = ids[0]; + if (Declares(id, typeof(MessagePackFormatterAttribute))) + return $"declares [MessagePackFormatter] on its [CultElementId] member {id.Name}, so the id would not be written by its [Key]."; + if (TypeOf(id) != typeof(string)) + return $"declares [CultElementId] on {id.Name}, which is not a string."; + if (KeyOf(id) is not { } idKey) + return $"declares [CultElementId] on {id.Name}, which has no integer [Key], so it is never persisted."; + if (MembersOf(element).FirstOrDefault(member => member.Name == id.Name && member.MemberType == id.MemberType) is not { } written) + return $"declares [CultElementId] on {id.Name}, which MessagePack does not write ([IgnoreMember], or non-public on a type without [MessagePackObject(AllowPrivate = true)]); the id would never reach disk."; + id = written; + var sharing = KeyedMembers(element).FirstOrDefault(entry => entry.Member != id && KeyOf(entry.Member) == idKey); + if (sharing.Member != null) + return $"declares [CultElementId] on {id.Name} with [Key({idKey})], which {sharing.Member.Name} also uses; an id needs a key of its own."; + if (id is PropertyInfo { SetMethod: null } or FieldInfo { IsInitOnly: true }) + return $"declares [CultElementId] on {id.Name}, which cannot be assigned; the cache mints into it."; + if (id is PropertyInfo { SetMethod.IsPublic: false } && !AllowsPrivate(element)) + return $"declares [CultElementId] on {id.Name}, whose setter is not public: MessagePack writes the id but does not read it back, so every load would mint a different one. Make the setter public (init is fine)."; + var source = id.GetCustomAttribute(true)!.DerivedFrom; + if (source != null) + { + var from = MembersOf(element).FirstOrDefault(member => member.Name == source); + if (from == null || KeyOf(from) == null) + return $"derives its [CultElementId] from {source}, which is not a keyed member of the type."; + var kind = Nullable.GetUnderlyingType(TypeOf(from)) ?? TypeOf(from); + if (kind != typeof(string) && !kind.IsPrimitive) + return $"derives its [CultElementId] from {source}, which is not a string or a number."; + } + + return null; + } + + // ---- write: what the stored form holds ---- + + // True when some element under root holds a non-empty id: the one fact a store marks its header by. It reads the value + // as it is now, refuses nothing and changes nothing, so a writer asks it of exactly what it is about to write. The walk + // follows declared types, as registration does; a value's own type picks only a union arm. + /// True when some element under holds a non-empty id: the one fact a single-file store marks its header by. Reads the value as it is, refuses nothing and changes nothing. + /// The declared type of the root; the walk follows declared types, as registration does. + /// The document about to be written. + public static bool Holds(Type declared, object? root) + { + if (root == null) + return false; + var (kind, inner) = Classify(declared); + switch (kind) + { + case Kind.Leaf: + return false; + case Kind.Map: + return MapEntries(declared)(root).Any(entry => Holds(inner, entry.Value)); + case Kind.Sequence: + var elements = IsElement(inner); + if (!elements && Classify(inner).Kind == Kind.Leaf) + return false; + foreach (var item in (IEnumerable)root) + { + if (item == null) + continue; + if (elements && OwnerShape(inner, item).GetId is { } getId && !string.IsNullOrEmpty(getId(item))) + return true; + if (Holds(inner, item)) + return true; + } + + return false; + default: + return OwnerShape(declared, root).Walk.Any(member => Holds(member.Type, member.Get(root))); + } + } + + // ---- write and load: mint and check ---- + + // Decides every unset element id under root, and refuses what the rule refuses, without changing anything: a random id + // that is not 12 lowercase hex characters, a duplicate id in one list, a derived id whose source is null or empty. + // rootPath (the record key) seeds deterministic minting and names the record in a refusal. An element object that sits + // twice in one list is refused too: planning would give it two ids and it would then be a duplicate on load. + /// Decides every unset element id under and refuses what a cache write refuses (a malformed or duplicate id, an unusable derived source), without changing anything until . A cacheless single-file writer plans before it serializes, so the ids are in the bytes. + /// The declared type of the root. + /// The document being written or loaded. + /// The record key: it seeds deterministic minting and names the record in a refusal. + /// True on a load, which mints from the key and path so every reader of one store mints the same ids; false on a write, which mints random ones. + public static IdPlan Plan(Type declared, object? root, string rootPath, bool deterministic) + { + var plan = new IdPlan(); + Visit(declared, root, rootPath); + return plan; + + void Visit(Type type, object? value, string path) + { + if (value == null) + return; + var (kind, inner) = Classify(type); + switch (kind) + { + case Kind.Map: + foreach (var entry in MapEntries(type)(value)) + Visit(inner, entry.Value, path + "{" + Convert.ToString(entry.Key, CultureInfo.InvariantCulture) + "}"); + break; + case Kind.Sequence when IsElement(inner): + Elements(inner, (IEnumerable)value, path); + break; + case Kind.Sequence: + if (Classify(inner).Kind == Kind.Leaf) + break; + var index = 0; + foreach (var item in (IEnumerable)value) + Visit(inner, item, path + "/" + (index++).ToString(CultureInfo.InvariantCulture)); + break; + case Kind.Owner: + foreach (var (slot, memberType, get) in OwnerShape(type, value).Walk) + Visit(memberType, get(value), path + "." + slot.ToString(CultureInfo.InvariantCulture)); + break; + } + } + + void Elements(Type element, IEnumerable list, string path) + { + var items = list.Cast().ToArray(); + var taken = new HashSet(StringComparer.Ordinal); + var objects = new HashSet(ReferenceComparer.Instance); + foreach (var item in items) + { + if (item == null || OwnerShape(element, item) is not { GetId: { } getId } shape) + continue; + if (!objects.Add(item)) + throw new CultElementIdException( + $"Record {rootPath}: the same {item.GetType().Name} object appears twice in the list at {path}; an element is one object with one id.", + rootPath, path); + var id = getId(item); + if (string.IsNullOrEmpty(id)) + continue; + if (shape.Derive == null && !IsRandomId(id!)) + throw new CultElementIdException( + $"Record {rootPath}: element id '{id}' in the list at {path} is not 12 lowercase hex characters. An id you write by hand must be 12 lowercase hex; for readable ids, derive them from a member with [CultElementId(nameof(Member))].", + rootPath, path, id); + if (!taken.Add(id!)) + throw Duplicate(id!, path); + } + + for (var index = 0; index < items.Length; index++) + { + var item = items[index]; + if (item == null) + continue; + var itemPath = path + "/" + index.ToString(CultureInfo.InvariantCulture); + if (OwnerShape(element, item) is { SetId: { } , GetId: { } get } itemShape && + string.IsNullOrEmpty(get(item))) + { + string id; + if (itemShape.Derive != null) + { + id = itemShape.Derive(item) ?? string.Empty; + if (id.Length == 0) + throw new CultElementIdException( + $"Record {rootPath}: the element at {itemPath} derives its id from {itemShape.DerivedFrom}, which is null or empty.", + rootPath, itemPath, member: itemShape.DerivedFrom); + if (!taken.Add(id)) + throw Duplicate(id, path); + } + else + id = Mint(itemPath, deterministic, rootPath, taken); + plan.Add(item, itemShape, get(item), id); + } + + Visit(element, item, itemPath); + } + } + + CultElementIdException Duplicate(string id, string path) => new( + $"Record {rootPath}: element id '{id}' appears twice in the list at {path}; an element id is unique within its list.", + rootPath, path, id); + } + + private sealed class ReferenceComparer : IEqualityComparer + { + public static readonly ReferenceComparer Instance = new(); + + bool IEqualityComparer.Equals(object? left, object? right) => ReferenceEquals(left, right); + + int IEqualityComparer.GetHashCode(object value) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(value); + } + + private static bool IsRandomId(string id) + { + if (id.Length != IdBytes * 2) + return false; + foreach (var character in id) + { + if (!(character is >= '0' and <= '9' or >= 'a' and <= 'f')) + return false; + } + + return true; + } + + private static string Mint(string itemPath, bool deterministic, string rootPath, HashSet taken) + { + for (var attempt = 0; ; attempt++) + { + var bytes = new byte[IdBytes]; + if (deterministic) + { + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(rootPath + "|" + itemPath + "|" + attempt.ToString(CultureInfo.InvariantCulture))); + Array.Copy(hash, bytes, IdBytes); + } + else + RandomBytes(bytes); + var id = Hex(bytes); + if (taken.Add(id)) + return id; + } + } + + private static void RandomBytes(byte[] bytes) + { + using var random = RandomNumberGenerator.Create(); + random.GetBytes(bytes); + } + + private static string Hex(byte[] bytes) + { + var text = new StringBuilder(bytes.Length * 2); + foreach (var value in bytes) + text.Append(value.ToString("x2", CultureInfo.InvariantCulture)); + return text.ToString(); + } + + // ---- shapes ---- + + private static Shape ShapeOf(Type type) => Shapes.GetOrAdd(type, Build); + + // The shape of an owner or element a walk holds under a declared type: a union base writes its arm, so the value's own + // type picks the arm; any other declared type is written by its own [Key] layout, whatever a subclass adds. + private static Shape OwnerShape(Type declared, object value) + { + declared = Nullable.GetUnderlyingType(declared) ?? declared; + return declared.IsDefined(typeof(UnionAttribute), false) ? ShapeOf(value.GetType()) : ShapeOf(declared); + } + + private static Shape Build(Type type) + { + var shape = new Shape(); + var idMember = MembersOf(type).FirstOrDefault(member => Declares(member, typeof(CultElementIdAttribute))); + if (idMember != null) + { + shape.GetId = idMember is FieldInfo field ? o => (string?)field.GetValue(o) : o => (string?)((PropertyInfo)idMember).GetValue(o); + shape.SetId = idMember is FieldInfo f ? (o, v) => f.SetValue(o, v) : (o, v) => ((PropertyInfo)idMember).SetValue(o, v); + if (idMember.GetCustomAttribute(true)!.DerivedFrom is { } source && + MembersOf(type).FirstOrDefault(member => member.Name == source) is { } from) + { + shape.DerivedFrom = source; + shape.Derive = o => Convert.ToString(from is FieldInfo ff ? ff.GetValue(o) : ((PropertyInfo)from).GetValue(o), CultureInfo.InvariantCulture); + } + } + + shape.Walk = KeyedMembers(type) + .Where(entry => Classify(entry.Type).Kind != Kind.Leaf) + .Select(entry => (Slot: KeyOf(entry.Member)!.Value, entry.Type, Get: entry.Member is FieldInfo fi + ? (Func)(o => fi.GetValue(o)) + : o => ((PropertyInfo)entry.Member).GetValue(o))) + .ToArray(); + return shape; + } + + // The members MessagePack's StandardResolver writes for an integer-keyed type, none of them [IgnoreMember]. Without + // AllowPrivate: public instance fields, and public instance properties with a public getter. With it, read from the most + // derived [MessagePackObject] declaration (a derived type without it is written without it, probe NoApDerived): every + // instance field and indexer-free property up the base chain at any visibility, the most derived of a name. + // TheWalkReadsTheMembersMessagePackWrites checks this set against MessagePack's own member discovery. + private static IEnumerable MembersOf(Type type) => + AllowsPrivate(type) + ? AllInstanceMembers(type).Where(member => !Declares(member, typeof(IgnoreMemberAttribute))) + .GroupBy(member => member.Name).Select(group => group.First()) + : PublicMembersOf(type); + + private static bool AllowsPrivate(Type type) => type.GetCustomAttribute(true)?.AllowPrivate == true; + + private static IEnumerable PublicMembersOf(Type type) => + type.GetFields(BindingFlags.Instance | BindingFlags.Public).Cast() + .Concat(type.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(property => property.GetIndexParameters().Length == 0 && property.GetMethod is { IsPublic: true })) + .Where(member => !Declares(member, typeof(IgnoreMemberAttribute))); + + // Every instance field and indexer-free property declared on the type or any base, most derived first. + private static IEnumerable AllInstanceMembers(Type type) + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; + for (var level = type; level != null && level != typeof(object); level = level.BaseType) + { + foreach (var field in level.GetFields(flags)) + yield return field; + foreach (var property in level.GetProperties(flags).Where(property => property.GetIndexParameters().Length == 0)) + yield return property; + } + } + + // The [Key] members MessagePack writes, for the oracle test that checks them against MessagePack. + internal static IEnumerable WrittenKeyedMembers(Type type) => MembersOf(type).Where(member => KeyOf(member) != null); + + private static IEnumerable<(MemberInfo Member, Type Type)> KeyedMembers(Type type) => + WrittenKeyedMembers(type).Where(member => !Declares(member, typeof(MessagePackFormatterAttribute))) + .Select(member => (member, TypeOf(member))); + + // An attribute a member declares, or inherits through an override: MemberInfo.IsDefined ignores inherit for properties, so an + // override of a base virtual member would not see its base's attribute. + private static bool Declares(MemberInfo member, Type attribute) => Attribute.IsDefined(member, attribute, inherit: true); + + // True when the walk reads the type by its [Key] members: the codec compares this with how MessagePack writes it. + internal static bool WalksAsOwner(Type type) => Classify(Nullable.GetUnderlyingType(type) ?? type).Kind == Kind.Owner; + + private static bool EnumeratesObjects(Type type) => + (MapTypes(type)?.Value ?? ElementTypeOf(type)) is { } inner && Classify(inner).Kind != Kind.Leaf; + + private static int? KeyOf(MemberInfo member) => member.GetCustomAttribute(true)?.IntKey; + + private static Type TypeOf(MemberInfo member) => member is FieldInfo field ? field.FieldType : ((PropertyInfo)member).PropertyType; + + // ---- type classification ---- + + private enum Kind { Leaf, Map, Sequence, Owner } + + private static readonly ConcurrentDictionary Classes = new(); + + // The walk's model of a declared type: registration, Plan, Holds and the codec's id path all go through it. A + // [MessagePackObject] or [Union] type is read by key even when it is also IEnumerable; the codec checks, for every type + // on the id path, that MessagePack writes it the way this model reads it, and refuses the document when it does not. + // Inner is a map's value type, a sequence's element type, or the type itself for an owner. + // The owned shapes are a closed list (Owner, Sequence, Map); every other shape is Leaf: one whole value with no element + // ids, replaced whole by a variant (q3 amended 2026-10-16). When in doubt the answer is Leaf. + // Leaf strings (an IEnumerable MessagePack writes whole), a type written whole (IsWrittenWhole: attribute- + // formatted, [DataContract], string-keyed), a struct or a class or interface with no [MessagePackObject] or + // [Union] and no collection shape (primitives, enums, float2, DateTime, ImmutableArray, ArraySegment, Memory, + // KeyValuePair, tuples, Lazy, unkeyed classes), and object, which is untyped on disk. + // Map a class or interface implementing IDictionary<,> or IReadOnlyDictionary<,>. + // Sequence an array or an IEnumerable class or interface. + // Owner a class, interface or struct MessagePack serializes by integer key. A keyed struct is walked as an owner; + // it is never an element (IsElement), since a boxed copy cannot carry a minted id. + private static (Kind Kind, Type Inner) Classify(Type declared) => Classes.GetOrAdd(declared, static type => + { + type = Nullable.GetUnderlyingType(type) ?? type; + if (type == typeof(string)) + return (Kind.Leaf, type); + if (IsWrittenWhole(type)) + return (Kind.Leaf, type); + if (IsKeyed(type)) + return (Kind.Owner, type); + if (type.IsValueType) + return (Kind.Leaf, type); + if (MapTypes(type) is { } map) + return (Kind.Map, map.Value); + if (ElementTypeOf(type) is { } element) + return (Kind.Sequence, element); + return (Kind.Leaf, type); + }); + + // A type MessagePack writes as one value the walk cannot look into: its own formatter ([MessagePackFormatter] on the type, + // inherited), [DataContract] on a type that is not [MessagePackObject] (the object resolver writes its members), or string + // keys (KeyAsPropertyName, or a [Key] with a string key on a member MessagePack writes: a private or [IgnoreMember] one is not written). Such a type is Leaf, even when it is also enumerable. + private static bool IsWrittenWhole(Type type) => + type.IsDefined(typeof(MessagePackFormatterAttribute), true) || + (type.IsDefined(typeof(DataContractAttribute), true) && !type.IsDefined(typeof(MessagePackObjectAttribute), true)) || + type.GetCustomAttribute(true)?.KeyAsPropertyName == true || + MembersOf(type).Any(member => member.GetCustomAttribute(true)?.StringKey != null); + + // A type MessagePack serializes by key: [MessagePackObject] or [Union]. + private static bool IsKeyed(Type type) => + type.IsDefined(typeof(MessagePackObjectAttribute), true) || type.IsDefined(typeof(UnionAttribute), true); + + // What an object list holds: a keyed class or interface. Never a struct. + private static bool IsElement(Type type) => !type.IsValueType && Classify(type).Kind == Kind.Owner; + + private static Type? ElementTypeOf(Type type) + { + if (type.IsArray) + return type.GetElementType(); + foreach (var candidate in type.GetInterfaces().Append(type)) + { + if (candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + return candidate.GetGenericArguments()[0]; + } + + return null; + } + + // The key and value types of a map MessagePack writes as a map: IDictionary<,> or IReadOnlyDictionary<,>. + private static (Type Key, Type Value)? MapTypes(Type type) + { + foreach (var candidate in type.GetInterfaces().Append(type)) + { + if (candidate.IsGenericType && + (candidate.GetGenericTypeDefinition() == typeof(IDictionary<,>) || candidate.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>))) + { + var arguments = candidate.GetGenericArguments(); + return (arguments[0], arguments[1]); + } + } + + return null; + } + + private static readonly ConcurrentDictionary>> MapReaders = new(); + + // A map's entries read through the generic interface Classify found, so no map shape is recognised twice. + private static Func> MapEntries(Type map) => MapReaders.GetOrAdd(map, static type => + { + var (key, value) = MapTypes(type)!.Value; + return (Func>)Delegate.CreateDelegate( + typeof(Func>), + typeof(CultElementIds).GetMethod(nameof(ReadMap), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(key, value)); + }); + + private static IEnumerable<(object? Key, object? Value)> ReadMap(object map) => + ((IEnumerable>)map).Select(entry => ((object?)entry.Key, (object?)entry.Value)); + } +} diff --git a/src/GameCult.Caching/CultSchemaConflictException.cs b/src/GameCult.Caching/CultSchemaConflictException.cs new file mode 100644 index 00000000..bbaff988 --- /dev/null +++ b/src/GameCult.Caching/CultSchemaConflictException.cs @@ -0,0 +1,34 @@ +using System; + +namespace GameCult.Caching +{ + /// + /// A write the catalog cannot describe: two schemas that share a schema id and disagree on the schema name (both arrived with the + /// records, neither registered), a record whose schema id no chosen entry publishes, or a write that would replace or remove a + /// . Nothing is written, so the store stays as it was. It names the id, the schema names involved + /// and a record key. + /// + public sealed class CultSchemaConflictException : InvalidOperationException + { + public CultSchemaConflictException(string message, string schemaId, string[] schemaNames, string recordKey) + : base(message) + { + SchemaId = schemaId; + SchemaNames = schemaNames; + RecordKey = recordKey; + } + + /// The same refusal when the reload it owes (what the store holds now, loaded into the cache) itself failed: that failure is the inner exception. + public CultSchemaConflictException(string message, string schemaId, string[] schemaNames, string recordKey, Exception? innerException) + : base(message, innerException) + { + SchemaId = schemaId; + SchemaNames = schemaNames; + RecordKey = recordKey; + } + + public string SchemaId { get; } + public string[] SchemaNames { get; } + public string RecordKey { get; } + } +} diff --git a/src/GameCult.Caching/CultStoreUnreadableException.cs b/src/GameCult.Caching/CultStoreUnreadableException.cs new file mode 100644 index 00000000..b3e43a4d --- /dev/null +++ b/src/GameCult.Caching/CultStoreUnreadableException.cs @@ -0,0 +1,22 @@ +using System; + +namespace GameCult.Caching +{ + /// + /// A store file this runtime cannot read: not exactly one complete store (truncated, bytes after it, a value that is not a + /// store, a missing or extra top-level slot), a format or record it does not know, or a body it cannot decode. Open, flush, + /// commit and CultMesh's single-file reads all refuse a file with this one exception, and a refused file is left as it was. + /// The underlying cause, where there is one, is the inner exception, and names the file. + /// + public sealed class CultStoreUnreadableException : NotSupportedException + { + public CultStoreUnreadableException(string message, string? path = null, Exception? innerException = null) + : base(message, innerException) + { + Path = path; + } + + /// The store file that was refused, or null when the bytes were read without a file. + public string? Path { get; } + } +} diff --git a/src/GameCult.Caching/CultVariants.cs b/src/GameCult.Caching/CultVariants.cs index ee5cc523..48909c19 100644 --- a/src/GameCult.Caching/CultVariants.cs +++ b/src/GameCult.Caching/CultVariants.cs @@ -28,6 +28,24 @@ namespace GameCult.Caching // (document payload, slot and encoded value pairs) public Func>, byte[]> Overlay { get; } + + /// + /// Decodes a document payload read from a store file, as a cache load decodes it: the ids a load mints are minted from the key + /// and their path, so every reader of one store mints the same ids, and a payload a cache write would refuse is refused here too. + /// It reads no file and holds no state. + /// + /// The registered document type. + /// The record key the payload was stored under. + /// The record's payload. + /// The payload holds an element id or list a cache load refuses. + public TDocument DecodeDocument(CultRecordKey key, byte[] payload) + where TDocument : class + { + if (payload == null) throw new ArgumentNullException(nameof(payload)); + var document = (TDocument)Deserialize(typeof(TDocument), typeof(TDocument), payload)!; + CultElementIds.Plan(typeof(TDocument), document, key.Value, deterministic: true).Apply(); + return document; + } } public enum CultOverrideOp diff --git a/src/GameCult.Caching/CultWriteConflictException.cs b/src/GameCult.Caching/CultWriteConflictException.cs new file mode 100644 index 00000000..61dd14e0 --- /dev/null +++ b/src/GameCult.Caching/CultWriteConflictException.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; + +namespace GameCult.Caching +{ + /// + /// A write refused because the store it would land in holds a variant and has changed since its writer last read it. A variant + /// resolves against other records, and a write never decodes a record it did not stage, so it cannot judge records another + /// writer changed: it writes only while every record header in the store equals what the writer last read or wrote. Nothing is + /// written. are the keys the write would have written or removed and the keys + /// whose durable header differs from what the writer last read, both ordinal-sorted. The cache has reloaded all of them as the + /// store holds them and forgotten the staged changes at those keys, so a retry is judged against that. When reloading failed, + /// nothing changed and is the cause. + /// + public sealed class CultWriteConflictException : InvalidOperationException + { + public CultWriteConflictException(string message, IReadOnlyList recordKeys, IReadOnlyList changedKeys, Exception? inner = null) + : base(message, inner) + { + RecordKeys = recordKeys; + ChangedKeys = changedKeys; + } + + public IReadOnlyList RecordKeys { get; } + public IReadOnlyList ChangedKeys { get; } + } +} diff --git a/src/GameCult.Caching/GameCult.Caching.csproj b/src/GameCult.Caching/GameCult.Caching.csproj index 7bc41ccc..9819a079 100644 --- a/src/GameCult.Caching/GameCult.Caching.csproj +++ b/src/GameCult.Caching/GameCult.Caching.csproj @@ -4,10 +4,14 @@ $(NoWarn);CS1591 + + + + + - \ No newline at end of file diff --git a/src/GameCult.Caching/README.md b/src/GameCult.Caching/README.md index e9edb63d..fc5cd308 100644 --- a/src/GameCult.Caching/README.md +++ b/src/GameCult.Caching/README.md @@ -188,6 +188,40 @@ for (var row = 0; row < table.Count; row++) cache-owned physical table for hot loops and diagnostics; it is not a second domain model callers have to maintain. +## Element ids + +A document type that holds a list of objects gives every object type in that list +(union arms included) a string member marked `[CultElementId]`, next to its +`[Key]`. The id is what lets a variant override one list element instead of the +whole list. Lists of strings, numbers and other non-object values need none. + +The rule covers the shapes the id walk owns: arrays, `IEnumerable` classes and +interfaces, dictionary values, and integer-keyed `[MessagePackObject]` and `[Union]` +types. Any other holder is one whole value: `ImmutableArray`, `ArraySegment`, +`Memory`, `KeyValuePair`, tuples, `Lazy`, a class without `[MessagePackObject]`, +a `[DataContract]` or string-keyed type, and a type or member declaring +`[MessagePackFormatter]`. Its elements carry no element ids: a `[CultElementId]` +member inside it is a plain string, never minted or checked, and a variant can only +replace the holder whole. + +```csharp +[MessagePackObject] +public sealed class Line +{ + [Key(0)] [CultElementId] public string Id = string.Empty; + [Key(1)] public string Text = string.Empty; +} +``` + +Leave `Id` empty and the cache mints a random id when the record is written. An +id you set yourself must be exactly 12 lowercase hex characters +(`0123456789ab`); anything else is refused with a `CultElementIdException`. + +For readable ids, derive them: `[CultElementId(nameof(Name))]` makes an unset id +the text of that member (a string or number on the same type, unique within its +list), and a derived id may be any text. A readable name set by hand on a +non-derived list id is refused; the same member outside a list is unconstrained. + ## Persistence discipline Persistence stays manual by default. Mutations mark the cache and attached diff --git a/src/GameCult.Geometry/CultGeometryDocuments.cs b/src/GameCult.Geometry/CultGeometryDocuments.cs index 1ce73bdb..169b9ae1 100644 --- a/src/GameCult.Geometry/CultGeometryDocuments.cs +++ b/src/GameCult.Geometry/CultGeometryDocuments.cs @@ -60,6 +60,10 @@ namespace GameCult.Geometry [MessagePackObject] public sealed class CultGeometryDomainNode { + [Key(7)] + [CultElementId] + public string Id { get; set; } = string.Empty; + [Key(0)] public string Name { get; set; } = string.Empty; @@ -105,6 +109,10 @@ namespace GameCult.Geometry [MessagePackObject] public sealed class CultGeometryFeatureClaim { + [Key(8)] + [CultElementId] + public string Id { get; set; } = string.Empty; + [Key(0)] public string Name { get; set; } = string.Empty; @@ -153,7 +161,6 @@ namespace GameCult.Geometry public string RequestId { get; set; } = string.Empty; [Key(1)] - [MessagePackFormatter(typeof(CultRecordRefFormatter))] public CultRecordRef DomainKey { get; set; } [Key(2)] @@ -228,7 +235,6 @@ namespace GameCult.Geometry public string CutId { get; set; } = string.Empty; [Key(1)] - [MessagePackFormatter(typeof(CultRecordRefFormatter))] public CultRecordRef RequestKey { get; set; } [Key(2)] @@ -261,6 +267,10 @@ namespace GameCult.Geometry [MessagePackObject] public sealed class CultGeometryContributionRow { + [Key(13)] + [CultElementId] + public string Id { get; set; } = string.Empty; + [Key(0)] public string DomainKey { get; set; } = string.Empty; @@ -313,7 +323,6 @@ namespace GameCult.Geometry public string ChunkId { get; set; } = string.Empty; [Key(1)] - [MessagePackFormatter(typeof(CultRecordRefFormatter))] public CultRecordRef CutKey { get; set; } [Key(2)] diff --git a/src/GameCult.Geometry/GameCult.Geometry.csproj b/src/GameCult.Geometry/GameCult.Geometry.csproj index cc66cba9..9736e1b7 100644 --- a/src/GameCult.Geometry/GameCult.Geometry.csproj +++ b/src/GameCult.Geometry/GameCult.Geometry.csproj @@ -15,6 +15,8 @@ + + diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index 0d7ebd16..e3f23430 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -2732,11 +2732,9 @@ namespace GameCult.Mesh // runtime does not recognize by any alias) its payload's own embedded schema stamp, // alias-matched against TDocument's descriptor. The parse step // (CultNetDocumentRegistry.TryReadSchemaVersion) is the one decode rule shared with - // DecodeSnapshotDocuments; the match itself stays a direct alias-match against the caller's - // own descriptor rather than a global "which registered type owns this schema string" lookup, - // because that lookup is ambiguous when two document types alias the same schema id (as - // MeshNoteDocument/MeshNoteAliasDocument do in tests/GameCult.Mesh.Tests). No re-filter of the - // server's answer by schema alone, and no fallback that returns a record under the wrong key. + // DecodeSnapshotDocuments; the match itself is a direct alias-match against the caller's own + // descriptor. No re-filter of the server's answer by schema alone, and no fallback that + // returns a record under the wrong key. private static TDocument ReadDocumentFromSnapshotResponse( CultNetSnapshotResponseRawMessage response, string schemaId, diff --git a/src/GameCult.Mesh/CultMeshBodies.cs b/src/GameCult.Mesh/CultMeshBodies.cs index 0c740c18..a5b37387 100644 --- a/src/GameCult.Mesh/CultMeshBodies.cs +++ b/src/GameCult.Mesh/CultMeshBodies.cs @@ -1,3 +1,4 @@ +using GameCult.Caching; using System; using System.IO; using System.IO.MemoryMappedFiles; @@ -34,6 +35,10 @@ namespace GameCult.Mesh [MessagePackObject] public sealed class CultMeshBodyDescriptor { + [Key(13)] + [CultElementId] + public string Id { get; set; } = string.Empty; + [Key(0)] public string BodyId { get; set; } = string.Empty; [Key(1)] public string SchemaId { get; set; } = string.Empty; [Key(2)] public int LayoutVersion { get; set; } diff --git a/src/GameCult.Mesh/CultMeshCdn.cs b/src/GameCult.Mesh/CultMeshCdn.cs index 6ea4696f..7f47f291 100644 --- a/src/GameCult.Mesh/CultMeshCdn.cs +++ b/src/GameCult.Mesh/CultMeshCdn.cs @@ -87,6 +87,11 @@ namespace GameCult.Mesh /// CultCache record key for the referenced chunk document. [Key(3)] public string RecordKey { get; set; } = string.Empty; + + /// Element id: the decimal text of , which is unique within a manifest and never random. + [Key(4)] + [CultElementId(nameof(Offset))] + public string Id { get; set; } = string.Empty; } /// @@ -266,7 +271,8 @@ namespace GameCult.Mesh ChunkHash = chunkHash, Offset = offset, SizeBytes = count, - RecordKey = CultMeshCdnArtifactChunk.CreateRecordKey(chunk).Value + RecordKey = CultMeshCdnArtifactChunk.CreateRecordKey(chunk).Value, + Id = offset.ToString(CultureInfo.InvariantCulture) }); } @@ -285,7 +291,8 @@ namespace GameCult.Mesh ChunkHash = chunkHash, Offset = 0, SizeBytes = 0, - RecordKey = CultMeshCdnArtifactChunk.CreateRecordKey(chunk).Value + RecordKey = CultMeshCdnArtifactChunk.CreateRecordKey(chunk).Value, + Id = "0" }); } diff --git a/src/GameCult.Mesh/CultMeshDiscoveryService.cs b/src/GameCult.Mesh/CultMeshDiscoveryService.cs index 3f108e03..f75a1181 100644 --- a/src/GameCult.Mesh/CultMeshDiscoveryService.cs +++ b/src/GameCult.Mesh/CultMeshDiscoveryService.cs @@ -417,6 +417,10 @@ namespace GameCult.Mesh [MessagePackObject] public sealed class CultMeshDiscoveryCandidateDocument { + [Key(5)] + [CultElementId] + public string Id { get; set; } = string.Empty; + [Key(0)] public CultMeshVerseDescriptorMessage Descriptor { get; set; } = new CultMeshVerseDescriptorMessage(); [Key(1)] public string SourceId { get; set; } = ""; [Key(2)] public string ObservedAtUtc { get; set; } = ""; diff --git a/src/GameCult.Mesh/CultMeshEntityPrefabs.cs b/src/GameCult.Mesh/CultMeshEntityPrefabs.cs index 194c9360..afb0b0bf 100644 --- a/src/GameCult.Mesh/CultMeshEntityPrefabs.cs +++ b/src/GameCult.Mesh/CultMeshEntityPrefabs.cs @@ -80,6 +80,10 @@ namespace GameCult.Mesh [MessagePackObject] public sealed class CultMeshEntityPrefabComponent { + [Key(2)] + [CultElementId] + public string Id { get; set; } = string.Empty; + /// Portable component kind or runtime adapter type. [Key(0)] public string Type { get; set; } = string.Empty; @@ -95,6 +99,10 @@ namespace GameCult.Mesh [MessagePackObject] public sealed class CultMeshEntityPrefabAssetRef { + [Key(6)] + [CultElementId] + public string Id { get; set; } = string.Empty; + /// Stable source asset id inside the prefab package. [Key(0)] public string AssetId { get; set; } = string.Empty; @@ -126,6 +134,10 @@ namespace GameCult.Mesh [MessagePackObject] public sealed class CultMeshEntityPrefabNode { + [Key(10)] + [CultElementId] + public string Id { get; set; } = string.Empty; + /// Stable node id inside the package. [Key(0)] public string NodeId { get; set; } = string.Empty; diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index 58723950..500f9ef3 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -910,13 +910,10 @@ namespace GameCult.Mesh /// Gets whether this handle can submit a client prediction for the underlying document value. bool CanSubmitPrediction { get; } - - /// Creates a same-schema alias presentation for another CLR document type. - CultMeshDocumentHandle AsSchemaAlias() where TAlias : class; } /// - /// Typed reactive document handle with schema-aware alias conversion. + /// Typed reactive document handle. /// public sealed class CultMeshDocumentHandle : ICultMeshDocumentHandle where TDocument : class @@ -1117,39 +1114,6 @@ namespace GameCult.Mesh return ObserveAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } - /// Creates a same-schema alias presentation for another CLR document type. - public CultMeshDocumentHandle AsSchemaAlias() where TAlias : class - { - var aliasDescriptor = CultDocumentRegistry.Shared.GetRequired(); - if (!string.Equals(Descriptor.SchemaName, aliasDescriptor.SchemaName, StringComparison.Ordinal) || - !string.Equals(Descriptor.SchemaVersion, aliasDescriptor.SchemaVersion, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Document type {typeof(TAlias).FullName} uses schema '{aliasDescriptor.SchemaName}' " + - $"version '{aliasDescriptor.SchemaVersion}', but handle '{DocumentId}' exposes " + - $"schema '{SchemaName}' version '{SchemaVersion}'."); - } - - var aliasFeed = new CultMeshLiveFeed( - DocumentId, - async (parameters, context) => ConvertDocument( - await Feed.SnapshotAsync(parameters, context).ConfigureAwait(false)), - (parameters, context) => Feed - .Watch(parameters, context) - .Select(ConvertDocument), - Sources, - RouteHint); - - Func? replace = _replace == null - ? null - : value => _replace(ConvertDocument(value)); - Func? submitPrediction = _submitPrediction == null - ? null - : value => _submitPrediction(ConvertDocument(value)); - - return new CultMeshDocumentHandle(aliasFeed.Bind(Context), replace, submitPrediction); - } - internal static TDocumentValue CloneDocument(TDocumentValue document) where TDocumentValue : class { @@ -1216,19 +1180,6 @@ namespace GameCult.Mesh "field"); } - internal static TTarget ConvertDocument(TSource document) - where TSource : class - where TTarget : class - { - if (document == null) throw new ArgumentNullException(nameof(document)); - if (document is TTarget alreadyTyped) - { - return alreadyTyped; - } - - var payload = CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TSource)); - return (TTarget)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TTarget), payload); - } } /// @@ -1912,11 +1863,12 @@ namespace GameCult.Mesh $"CultMesh document catalog does not expose a document for schema '{schema}'."); } - /// Looks up a typed document handle by CLR type or same-schema alias. + /// Looks up a typed document handle by CLR type. public bool TryGetDocument( out CultMeshDocumentHandle document) where TDocument : class { + // One type owns a schema, so the handle for TDocument's schema is the handle for TDocument. if (_documentsByType.TryGetValue(typeof(TDocument), out var untypedDocument) && untypedDocument is CultMeshDocumentHandle typedDocument) { @@ -1924,25 +1876,11 @@ namespace GameCult.Mesh return true; } - var descriptor = CultDocumentRegistry.Shared.GetRequired(); - if (_documentsBySchema.TryGetValue(descriptor.SchemaVersion, out var schemaDocument) || - _documentsBySchema.TryGetValue(descriptor.SchemaName, out schemaDocument!)) - { - if (schemaDocument is CultMeshDocumentHandle schemaTypedDocument) - { - document = schemaTypedDocument; - return true; - } - - document = schemaDocument.AsSchemaAlias(); - return true; - } - document = null!; return false; } - /// Looks up a typed document handle by CLR type or same-schema alias. + /// Looks up a typed document handle by CLR type. public CultMeshDocumentHandle Document() where TDocument : class { @@ -1953,70 +1891,70 @@ namespace GameCult.Mesh $"CultMesh document catalog does not expose a document for {typeof(TDocument).FullName}."); } - /// Reads one typed document snapshot by CLR type or same-schema alias. + /// Reads one typed document snapshot by CLR type. public Task LatestAsync() where TDocument : class { return Document().LatestAsync(); } - /// Reads one typed document synchronously by CLR type or same-schema alias. + /// Reads one typed document synchronously by CLR type. public TDocument Latest() where TDocument : class { return Document().Latest(); } - /// Gets whether one typed document can be replaced by CLR type or same-schema alias. + /// Gets whether one typed document can be replaced by CLR type. public bool CanReplace() where TDocument : class { return TryGetDocument(out var document) && document.CanReplace; } - /// Replaces one typed document by CLR type or same-schema alias. + /// Replaces one typed document by CLR type. public Task ReplaceAsync(TDocument value) where TDocument : class { return Document().ReplaceAsync(value); } - /// Gets whether one typed document can submit client predictions by CLR type or same-schema alias. + /// Gets whether one typed document can submit client predictions by CLR type. public bool CanSubmitPrediction() where TDocument : class { return TryGetDocument(out var document) && document.CanSubmitPrediction; } - /// Submits a client prediction for one typed document by CLR type or same-schema alias. + /// Submits a client prediction for one typed document by CLR type. public Task SubmitPredictionAsync(TDocument value) where TDocument : class { return Document().SubmitPredictionAsync(value); } - /// Gets an explicit authoritative writer by CLR type or same-schema alias. + /// Gets an explicit authoritative writer by CLR type. public CultMeshDocumentWriter AuthoritativeWriter() where TDocument : class { return Document().AuthoritativeWriter(); } - /// Gets an explicit prediction writer by CLR type or same-schema alias. + /// Gets an explicit prediction writer by CLR type. public CultMeshDocumentWriter PredictionWriter() where TDocument : class { return Document().PredictionWriter(); } - /// Watches one typed document by CLR type or same-schema alias. + /// Watches one typed document by CLR type. public Observable Watch() where TDocument : class { return Document().Watch(); } - /// Subscribes to one typed document by CLR type or same-schema alias. + /// Subscribes to one typed document by CLR type. public IDisposable Watch(Action onNext) where TDocument : class { @@ -2050,9 +1988,6 @@ namespace GameCult.Mesh /// Gets typed state sources this collection handle depends on, when known. IReadOnlyList Sources { get; } - - /// Creates a same-schema alias presentation for another CLR document type. - CultMeshCollectionHandle AsSchemaAlias() where TAlias : class; } /// @@ -2118,11 +2053,12 @@ namespace GameCult.Mesh $"CultMesh collection catalog does not expose a collection for schema '{schema}'."); } - /// Looks up a typed collection handle by CLR type or same-schema alias. + /// Looks up a typed collection handle by CLR type. public bool TryGetCollection( out CultMeshCollectionHandle collection) where TDocument : class { + // One type owns a schema, so the handle for TDocument's schema is the handle for TDocument. if (_collectionsByType.TryGetValue(typeof(TDocument), out var untypedCollection) && untypedCollection is CultMeshCollectionHandle typedCollection) { @@ -2130,26 +2066,11 @@ namespace GameCult.Mesh return true; } - var descriptor = CultDocumentRegistry.Shared.GetRequired(); - if (_collectionsBySchema.TryGetValue(descriptor.SchemaId, out var schemaCollection) || - _collectionsBySchema.TryGetValue(descriptor.SchemaVersion, out schemaCollection!) || - _collectionsBySchema.TryGetValue(descriptor.SchemaName, out schemaCollection!)) - { - if (schemaCollection is CultMeshCollectionHandle schemaTypedCollection) - { - collection = schemaTypedCollection; - return true; - } - - collection = schemaCollection.AsSchemaAlias(); - return true; - } - collection = null!; return false; } - /// Looks up a typed collection handle by CLR type or same-schema alias. + /// Looks up a typed collection handle by CLR type. public CultMeshCollectionHandle Collection() where TDocument : class { @@ -2160,21 +2081,21 @@ namespace GameCult.Mesh $"CultMesh collection catalog does not expose a collection for {typeof(TDocument).FullName}."); } - /// Reads one typed collection snapshot by CLR type or same-schema alias. + /// Reads one typed collection snapshot by CLR type. public Task> LatestAsync() where TDocument : class { return Collection().LatestAsync(); } - /// Watches one typed collection by CLR type or same-schema alias. + /// Watches one typed collection by CLR type. public Observable> WatchChanges() where TDocument : class { return Collection().WatchChanges(); } - /// Subscribes to one typed collection by CLR type or same-schema alias. + /// Subscribes to one typed collection by CLR type. public IDisposable WatchChanges(Action> onNext) where TDocument : class { @@ -2246,7 +2167,7 @@ namespace GameCult.Mesh } /// - /// Typed reactive collection handle with schema-aware alias conversion. + /// Typed reactive collection handle. /// public sealed class CultMeshCollectionHandle : ICultMeshCollectionHandle where TDocument : class @@ -2313,49 +2234,6 @@ namespace GameCult.Mesh if (onNext == null) throw new ArgumentNullException(nameof(onNext)); return WatchChanges().Subscribe(onNext); } - - /// Creates a same-schema alias presentation for another CLR document type. - public CultMeshCollectionHandle AsSchemaAlias() where TAlias : class - { - var aliasDescriptor = CultDocumentRegistry.Shared.GetRequired(); - if (!string.Equals(Descriptor.SchemaName, aliasDescriptor.SchemaName, StringComparison.Ordinal) || - !string.Equals(Descriptor.SchemaVersion, aliasDescriptor.SchemaVersion, StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Document type {typeof(TAlias).FullName} uses schema '{aliasDescriptor.SchemaName}' " + - $"version '{aliasDescriptor.SchemaVersion}', but collection '{CollectionId}' exposes " + - $"schema '{SchemaName}' version '{SchemaVersion}'."); - } - - return new CultMeshCollectionHandle( - CollectionId, - async () => (await LatestAsync().ConfigureAwait(false)) - .Select(ConvertDocument) - .ToArray(), - () => WatchChanges().Select(change => new CultMeshCollectionChange( - change.Kind, - change.Key, - change.SchemaId, - change.Document == null ? null : ConvertDocument(change.Document), - change.PreviousDocument == null ? null : ConvertDocument(change.PreviousDocument), - change.Message)), - Sources, - RouteHint); - } - - private static TTarget ConvertDocument(TSource document) - where TSource : class - where TTarget : class - { - if (document == null) throw new ArgumentNullException(nameof(document)); - if (document is TTarget alreadyTyped) - { - return alreadyTyped; - } - - var payload = CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TSource)); - return (TTarget)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TTarget), payload); - } } /// diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs index 700b7a50..37de5226 100644 --- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs +++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs @@ -1,11 +1,9 @@ using System; -using System.Buffers; using System.IO; using System.Linq; using System.Security.Cryptography; using GameCult.Caching; using GameCult.Caching.MessagePack; -using MessagePack; namespace GameCult.Mesh { @@ -96,14 +94,9 @@ namespace GameCult.Mesh { if (document == null) throw new ArgumentNullException(nameof(document)); - var resolvedRegistry = registry ?? CultDocumentRegistry.Shared; - var descriptor = resolvedRegistry.GetRequired(); - WriteSingleFileDocumentPayload( - path, - key, - descriptor.ToCatalogEntry(), - storedAt, - CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument))); + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); + // The store decides element ids, the header and the copy of every other record, as a cache write does. + StoreAt(path).WriteDocument(key, document, storedAt, registry); } /// @@ -118,7 +111,8 @@ namespace GameCult.Mesh var resolvedRegistry = registry ?? CultDocumentRegistry.Shared; var descriptor = resolvedRegistry.GetRequired(); var payload = ReadSingleFileDocumentPayload(path, key, descriptor.SchemaId); - return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), payload); + // A load refuses what a write refuses, and mints the ids a cache load of the same file would mint. + return CultCacheMessagePack.CreateCodec(resolvedRegistry).DecodeDocument(key, payload); } /// @@ -132,7 +126,7 @@ namespace GameCult.Mesh byte[] payload) { if (schema == null) throw new ArgumentNullException(nameof(schema)); - WriteSingleFileDocumentPayload(path, key, schema.ToCatalogEntry(payload), storedAt, payload); + WriteSingleFileDocumentPayload(path, key, schema.ToCatalogEntry(payload), storedAt, payload, holdsIds: null, registry: null); } /// @@ -146,7 +140,7 @@ namespace GameCult.Mesh if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); if (string.IsNullOrWhiteSpace(expectedSchemaId)) throw new ArgumentException("Value must be non-empty.", nameof(expectedSchemaId)); - var snapshot = ReadSingleFileSnapshot(path); + var snapshot = StoreAt(path).ReadStoreSnapshot() ?? throw new FileNotFoundException($"CultCache document '{path}' does not exist.", path); var record = snapshot.Records.SingleOrDefault(candidate => string.Equals(candidate.Key, key.Value, StringComparison.Ordinal)) ?? throw new InvalidDataException($"CultCache document '{path}' does not contain record '{key.Value}'."); @@ -177,7 +171,7 @@ namespace GameCult.Mesh if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); if (string.IsNullOrWhiteSpace(expectedSchemaId)) throw new ArgumentException("Value must be non-empty.", nameof(expectedSchemaId)); - var snapshot = ReadSingleFileSnapshot(path); + var snapshot = StoreAt(path).ReadStoreSnapshot() ?? throw new FileNotFoundException($"CultCache document '{path}' does not exist.", path); if (snapshot.Records.Length != 1) { throw new InvalidDataException( @@ -214,7 +208,9 @@ namespace GameCult.Mesh CultRecordKey key, CultSchemaCatalogEntry catalogEntry, string? storedAt, - byte[] payload) + byte[] payload, + bool? holdsIds, + CultDocumentRegistry? registry) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path)); if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry)); @@ -222,102 +218,25 @@ namespace GameCult.Mesh throw new ArgumentException("Catalog entry must include a schema id.", nameof(catalogEntry)); payload ??= Array.Empty(); - var snapshot = new CultPersistedStoreSnapshot - { - SchemaCatalog = new[] { catalogEntry }, - Records = new[] + // The write replaces this one key and copies every other record and catalog entry forward as the store holds them, under + // the store's lock. A typed write sees its document, so its ids decide the header; a raw payload is opaque (holdsIds null), + // so a file already marked stays marked. A typed write attaches the registry its payload was encoded with, so the write + // decides for copied records that registry can read; a raw payload attaches none and decides nothing. A store this runtime + // cannot read refuses the write instead of being overwritten. + StoreAt(path).WriteRecord( + new CultPersistedRecord { - new CultPersistedRecord - { - Key = key.Value, - SchemaId = catalogEntry.SchemaId, - StoredAt = string.IsNullOrWhiteSpace(storedAt) ? DateTimeOffset.UtcNow.ToString("O") : storedAt!, - Payload = payload - } - } - }; - - var directory = Path.GetDirectoryName(path); - if (!string.IsNullOrWhiteSpace(directory)) - Directory.CreateDirectory(directory); - - WriteFileAtomically(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + Key = key.Value, + SchemaId = catalogEntry.SchemaId, + StoredAt = string.IsNullOrWhiteSpace(storedAt) ? DateTimeOffset.UtcNow.ToString("O") : storedAt!, + Payload = payload + }, + catalogEntry, + holdsIds, + registry); } - private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path) - { - var bytes = File.ReadAllBytes(path); - CultPersistedStoreSnapshot snapshot; - try - { - snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); - } - catch (Exception ex) when (ex is MessagePackSerializationException or InvalidOperationException) - { - snapshot = ReadLegacySingleFileSnapshot(bytes); - } - - CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot); - return snapshot; - } - - private static CultPersistedStoreSnapshot ReadLegacySingleFileSnapshot(byte[] bytes) - { - var reader = new MessagePackReader(bytes); - var fieldCount = reader.ReadArrayHeader(); - var snapshot = new CultPersistedStoreSnapshot(); - if (fieldCount > 0) - snapshot.FormatVersion = reader.ReadString() - ?? throw new NotSupportedException("Store snapshot declares no format version."); - - if (fieldCount > 1) - { - var catalogCount = reader.ReadArrayHeader(); - snapshot.SchemaCatalog = new CultSchemaCatalogEntry[catalogCount]; - for (var index = 0; index < catalogCount; index++) - snapshot.SchemaCatalog[index] = ReadLegacySchemaCatalogEntry(ref reader); - } - - if (fieldCount > 2) - { - var recordCount = reader.ReadArrayHeader(); - snapshot.Records = new CultPersistedRecord[recordCount]; - for (var index = 0; index < recordCount; index++) - snapshot.Records[index] = CultDocumentMessagePackSerialization.ReadPersistedRecord(ref reader); - } - - for (var index = 3; index < fieldCount; index++) - reader.Skip(); - - return snapshot; - } - - private static CultSchemaCatalogEntry ReadLegacySchemaCatalogEntry(ref MessagePackReader reader) - { - var fieldCount = reader.ReadArrayHeader(); - var entry = new CultSchemaCatalogEntry(); - if (fieldCount > 0) entry.SchemaId = reader.ReadString() ?? string.Empty; - if (fieldCount > 1) entry.SchemaName = reader.ReadString() ?? string.Empty; - if (fieldCount > 2) entry.SchemaVersion = reader.ReadString() ?? string.Empty; - if (fieldCount > 3) entry.CanonicalSchemaJson = reader.ReadString() ?? string.Empty; - if (fieldCount > 4) reader.Skip(); - if (fieldCount > 5) entry.ContentHash = reader.ReadString() ?? string.Empty; - if (fieldCount > 6) - { - var memberCount = reader.ReadArrayHeader(); - for (var index = 0; index < memberCount; index++) - reader.Skip(); - } - - for (var index = 7; index < fieldCount; index++) - reader.Skip(); - - entry.CompatibleSchemaIds = string.IsNullOrWhiteSpace(entry.SchemaId) - ? Array.Empty() - : new[] { entry.SchemaId }; - entry.Members = Array.Empty(); - return entry; - } + private static SingleFileMessagePackBackingStore StoreAt(string path) => new SingleFileMessagePackBackingStore(path); private static bool PublishesSchema(CultSchemaCatalogEntry[] catalog, string schemaId) { @@ -325,14 +244,5 @@ namespace GameCult.Mesh string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal) || entry.CompatibleSchemaIds.Any(candidate => string.Equals(candidate, schemaId, StringComparison.Ordinal))); } - - private static void WriteFileAtomically(string path, byte[] bytes) - { - var tempPath = path + ".tmp"; - File.WriteAllBytes(tempPath, bytes); - if (File.Exists(path)) - File.Delete(path); - File.Move(tempPath, path); - } } } diff --git a/src/GameCult.Mesh/CultMeshSnapshots.cs b/src/GameCult.Mesh/CultMeshSnapshots.cs index 58aede9d..c76f82bc 100644 --- a/src/GameCult.Mesh/CultMeshSnapshots.cs +++ b/src/GameCult.Mesh/CultMeshSnapshots.cs @@ -979,11 +979,11 @@ namespace GameCult.Mesh var canDeserializeWithBinding = binding != null && typeof(TDocument).IsAssignableFrom(binding.DocumentType); - var canDeserializeAsSchemaAlias = + var canDeserializeByWireSchema = CultNetSchemaAliasMatching.Matches(record.SchemaId, descriptor) || (binding != null && CultNetSchemaAliasMatching.Matches(binding.SchemaId, descriptor)) || CultNetDocumentRegistry.PayloadMatchesSchema(record.Payload, descriptor); - if (!canDeserializeWithBinding && !canDeserializeAsSchemaAlias) + if (!canDeserializeWithBinding && !canDeserializeByWireSchema) continue; if (!string.Equals(record.PayloadEncoding, "messagepack", StringComparison.Ordinal)) diff --git a/src/GameCult.Mesh/docs/browser-verse-transport.md b/src/GameCult.Mesh/docs/browser-verse-transport.md index fcfde998..663acc06 100644 --- a/src/GameCult.Mesh/docs/browser-verse-transport.md +++ b/src/GameCult.Mesh/docs/browser-verse-transport.md @@ -12,19 +12,21 @@ over binary loopback CultNet WebSocket routes under an explicit `local-development` trust policy. It proves one canonical command effect, idempotent retry, shared observation, provider restart on a new physical route, browser rediscovery, retained lease resubscription, and a second -canonical command after reconnection. +canonical command after reconnection. Running the sample needs a sibling Eve +checkout (`--eve-root`); it is not part of `CultLib.sln` and does not build +standalone. The current substrate has a browser client boundary and local live proof, but not a complete public Verse proof: -- `cultmesh-ts` is a Node package and imports `node:dgram` at its root; -- `cultnet-ts/contracts` is the browser-safe contract entrypoint; the package +- `@gamecult/cultmesh-ts` is a Node package and imports `node:dgram` at its root; +- `@gamecult/cultnet-ts/contracts` is the browser-safe contract entrypoint; the package root still includes Node transport, filesystem, crypto, stream, and UDP modules and must not enter browser bundles; - C# and TypeScript now share `cultnet.database_subscribe.v0`, `cultnet.database_unsubscribe.v0`, and `cultnet.database_change_raw.v0`; - `cultmesh-browser` consumes those messages as explicit document leases; -- `cultmesh-browser` owns stable identity, route refresh, resubscription, and + `@gamecult/cultmesh-browser` consumes those messages as explicit document leases; +- `@gamecult/cultmesh-browser` owns stable identity, route refresh, resubscription, and typed operation correlation without exposing direct document writes; - `CultMeshBrowserOdinRendezvous` resolves stable Verse/provider identity with `cultmesh.verse_catalog_request.v0` and @@ -110,8 +112,8 @@ Shared paths: Deletion line: - the jsdom checkpoint remains labeled as a Node-hosted DOM proof; -- browser applications import `cultmesh-browser` and - `cultnet-ts/contracts`, never the Node-only package roots; +- browser applications import `@gamecult/cultmesh-browser` and + `@gamecult/cultnet-ts/contracts`, never the Node-only package roots; - subscription messages remain one shared schema contract across the C# and TypeScript catalogs; - the real provider adapter must consume the canonical WebSocket message lane; diff --git a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md index 5cb5b742..768a31a3 100644 --- a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md +++ b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md @@ -101,7 +101,7 @@ The real-process C# sample is then rebuilt from that same temporary NuGet feed. The source project references in its `.csproj` are contributor convenience only and are disabled by the checkpoint; they are not the consumer contract. -The first layer builds and packs `cultcache-ts`, `cultnet-ts`, `cultmesh-ts`, +The first layer builds and packs `cultcache-ts`, `@gamecult/cultnet-ts`, `@gamecult/cultmesh-ts`, `@gamecult/eve-contracts`, and `@gamecult/eve-browser-lowering`, installs the tarballs into an empty temporary consumer, and runs `samples/eve-two-runtime/sample.mjs`. Eve's generated runtime validators reject @@ -135,7 +135,9 @@ does **not** prove the network layer; the second program owns that evidence. The network program is [`samples/eve-browser-network`](../../../../samples/eve-browser-network/README.md). -Run it separately only while diagnosing that layer. A smoke against the +Running it needs a sibling Eve checkout (`--eve-root`); it is not part of +`CultLib.sln` and does not build standalone. Run it separately only while +diagnosing that layer. A smoke against the deployed Odin daemon remains a separate infrastructure gate. Its owner map and full acceptance contract are recorded in [`browser-verse-transport.md`](../browser-verse-transport.md). diff --git a/src/GameCult.Mesh/docs/getting-started/04-run-the-multiruntime-game.md b/src/GameCult.Mesh/docs/getting-started/04-run-the-multiruntime-game.md index ce18fdb3..581895a0 100644 --- a/src/GameCult.Mesh/docs/getting-started/04-run-the-multiruntime-game.md +++ b/src/GameCult.Mesh/docs/getting-started/04-run-the-multiruntime-game.md @@ -58,7 +58,8 @@ source-tree fallbacks into application code. The process harness lives in [`samples/eve-browser-network`](../../../../samples/eve-browser-network/README.md). -Application code should copy the typed state, operation, and Eve composition +Running it needs a sibling Eve checkout (`--eve-root`); it does not build +standalone. Application code should copy the typed state, operation, and Eve composition shape—not its benchmark flags or fixture orchestration. For a TypeScript provider daemon, continue with the diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index e9f19ab7..072e6c73 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -173,28 +173,22 @@ portable machinery that makes it feel native. the normal shard log. Callers can bind a projected live feed, a local `CultCache` record, a distributed `CultNetDatabase` record, or a `CultMeshNode` record through the same surface instead of walking transport - and projection layers themselves. Same-schema CLR aliases can be requested - directly from node/database/snapshot surfaces; CultMesh resolves the canonical - registered document by shared `[CultDocument(schemaName, schemaVersion)]` - identity and uses the shared CultCache MessagePack codec for - read/watch/replace/prediction conversion. `AsSchemaAlias()` remains - available when a caller already has a handle and wants to project it. + and projection layers themselves. One CLR type owns each + `[CultDocument(schemaName, schemaVersion)]` schema; the registry refuses a + second type for the same schema with `CultSchemaConflictException`. - `CultMeshDocumentCatalog` is the schema-aware lookup edge for a set of - document handles. It indexes handles by CLR document type, schema name, and - schema version, and resolves same-schema CLR aliases by delegating to - `CultMeshDocumentHandle.AsSchemaAlias()`. Domain facades - can expose `Document()`, `DocumentBySchema(...)`, `LatestAsync()`, and - `Watch()` without owning schema dictionaries or alias serializers. Mutable - facades can also call `ReplaceAsync(...)` or - `SubmitPredictionAsync(...)` directly on the catalog when they only need - the schema-aliased operation and not the intermediate handle. + document handles. It indexes handles by CLR document type, schema id, schema + name, and schema version. Domain facades can expose `Document()`, + `DocumentBySchema(...)`, `LatestAsync()`, and `Watch()` without owning + schema dictionaries. Mutable facades can also call `ReplaceAsync(...)` or + `SubmitPredictionAsync(...)` directly on the catalog when they do not need + the intermediate handle. - `CultMeshCollectionHandle` is the typed reactive edge for multi-record state. It exposes one `LatestAsync()` collection snapshot and a `WatchChanges()` stream while hiding whether the backing source is an in-process `CultCache`, a distributed `CultNetDatabase`, or a `CultMeshNode`. Collection handles can represent all records of a document type, a named - document view, or an indexed value view, and they support the same - same-schema CLR alias conversion as document handles. + document view, or an indexed value view. - `CultMeshCollectionCatalog` is the collection sibling of `CultMeshDocumentCatalog`. It indexes typed collection handles by CLR document type, schema id, schema name, and schema version, then exposes @@ -258,19 +252,17 @@ var objects = await visibleObjects.ExecuteAsync(viewportRequest); await movePilot.InvokeAsync(moveRequest, idempotencyKey); ``` -A single typed document can be surfaced the same way. The caller asks for the -typed view it wants; if that type is a same-schema alias of the registered -canonical document, CultMesh still opens the correct backing record: +A single typed document can be surfaced the same way: ```csharp -var stockForUi = CultMesh.Document( +var stockHandle = CultMesh.Document( node, new CultRecordKey("station:starbridge:stock"), verse); -var stock = await stockForUi.LatestAsync(); -using var watch = stockForUi.Watch(RenderStock); -await stockForUi.SetAsync(updatedStockFromUi); +var stock = await stockHandle.LatestAsync(); +using var watch = stockHandle.Watch(RenderStock); +await stockHandle.SetAsync(updatedStock); ``` Projected or derived documents can use the same handle shape with explicit @@ -291,8 +283,6 @@ var cockpit = CultMesh.Document( var current = await cockpit.LatestAsync(); using var subscription = cockpit.Watch(next => RenderCockpit(next)); - -var daemonAlias = cockpit.AsSchemaAlias(); ``` Remote CultNet snapshots use the same document handle surface. The caller names @@ -301,20 +291,18 @@ id, response filtering, raw MessagePack payload decode, route metadata, and polling watch fallback: ```csharp -var remoteHealth = CultMesh.DocumentFromPeerSnapshot( +var remoteHealth = CultMesh.DocumentFromPeerSnapshot( "cultnet://daemon.local:3075", "daemon:aetheria.health.v1", verse); -var uiHealth = await remoteHealth.LatestAsync(); +var health = await remoteHealth.LatestAsync(); ``` -Snapshot requests are record-key-first so same logical documents can still be -read when another runtime generated a different schema id for a compatible -schema alias. The decode path prefers exact schema id matches when present and -falls back to the requested record key. Ask for the runtime-facing alias type -directly; `AsSchemaAlias()` is for adapting handles that were already -constructed elsewhere. +Snapshot requests are record-key-first so the same logical document can still be +read when another runtime generated a different schema id for it. The decode +path prefers exact schema id matches when present and falls back to the +requested record key. A domain facade can still collect existing handles into one schema-aware catalog when it is composing pre-built surfaces: @@ -326,7 +314,7 @@ var documents = CultMesh.Documents( zoneContacts); var refit = await documents.LatestAsync(); -var uiDocking = documents.Document(); +var docking = documents.Document(); var bySchema = documents.DocumentBySchema("gamecult.aetheria.station_refit.v1"); ``` @@ -341,11 +329,9 @@ var allies = CultMesh.CollectionByIndex( var currentAllies = await allies.LatestAsync(); using var changes = allies.WatchChanges(change => UpdateRoster(change)); -var uiAllies = allies.AsSchemaAlias(); - var collections = CultMesh.Collections(allies); -var uiRoster = await collections.LatestAsync(); -using var uiRosterChanges = collections.WatchChanges(RenderRoster); +var roster = await collections.LatestAsync(); +using var rosterChanges = collections.WatchChanges(RenderRoster); ``` ```ts diff --git a/src/GameCult.Mesh/docs/reliability-migration-plan.md b/src/GameCult.Mesh/docs/reliability-migration-plan.md index 47e1b190..6763080f 100644 --- a/src/GameCult.Mesh/docs/reliability-migration-plan.md +++ b/src/GameCult.Mesh/docs/reliability-migration-plan.md @@ -574,7 +574,7 @@ registration, renewal, stable publication order, reconnect and replay, duplicate receipt reuse, explicit withdrawal, and the negative stop-during- registration timeline, durable receipt replay, observer isolation, conflicting command IDs, and exception-safe shutdown. The empty-consumer tarball smoke proves the session is -present in the installable `cultmesh-ts` package. A live Odin transport adapter +present in the installable `@gamecult/cultmesh-ts` package. A live Odin transport adapter and the VoidBot migration remain required for the production proof. ## Phase 3: Authority as a Mandatory Boundary diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index 668c088e..d9e7ec09 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -1015,11 +1015,16 @@ namespace GameCult.Networking } /// - /// Applies a raw document mutation after checking shard authority. + /// Applies a raw document mutation after checking shard authority. This is the host's own door (and replication's): + /// it asks no remote write authority. A peer's put goes through , which does. /// /// No shard owns the schema and key; nothing was written. /// The change committed and was published, but the shard log could not record it. - public async Task ApplyPutAsync(CultNetDocumentPutRawMessage message) + public Task ApplyPutAsync(CultNetDocumentPutRawMessage message) => ApplyPutAsync(message, authorize: null); + + // The peer door: the same apply, with the server's authorize callback asked once the schema is resolved and before the + // shard is. The callback throws to refuse. The public overload is the host's own door and asks no remote authority. + internal async Task ApplyPutAsync(CultNetDocumentPutRawMessage message, Action? authorize) { ThrowIfDisposed(); if (message == null) throw new ArgumentNullException(nameof(message)); @@ -1030,6 +1035,7 @@ namespace GameCult.Networking var key = new CultRecordKey(message.Document.RecordKey); var descriptor = _documents.ResolveDescriptorForRawDocument(message.Document); + authorize?.Invoke(descriptor.SchemaId, key); var shard = ResolveShardInternal(descriptor, key); EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch); var document = _documents.DeserializeRawDocument(message.Document); @@ -1039,17 +1045,22 @@ namespace GameCult.Networking } /// - /// Applies a raw document delete after checking shard authority. + /// Applies a raw document delete after checking shard authority. This is the host's own door: it asks no remote + /// write authority. A peer's delete goes through , which does. /// /// No shard owns the schema and key; nothing was written. /// The change committed and was published, but the shard log could not record it. - public Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message) + public Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message) => ApplyDeleteAsync(message, authorize: null); + + // The peer door; see the put overload. + internal Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message, Action? authorize) { ThrowIfDisposed(); if (message == null) throw new ArgumentNullException(nameof(message)); var key = new CultRecordKey(message.RecordKey); var descriptor = _documents.ResolveDescriptorForSchemaId(message.SchemaId); + authorize?.Invoke(descriptor.SchemaId, key); var shard = ResolveShardInternal(descriptor, key); EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch); var previous = _cache.Get(key); diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index 77b03070..149b9619 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -67,22 +67,6 @@ namespace GameCult.Networking /// public CultNetDatabase Database => _database; - /// - /// Applies a raw put message through the database shard policy. - /// - public Task ApplyPutAsync(CultNetDocumentPutRawMessage message) - { - return _database.ApplyPutAsync(message); - } - - /// - /// Applies a raw delete message through the database shard policy. - /// - public Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message) - { - return _database.ApplyDeleteAsync(message); - } - /// /// Creates a raw snapshot response from the database cache. /// @@ -304,11 +288,52 @@ namespace GameCult.Networking return Task.CompletedTask; } + // The one question asked of the write authority per received write. None configured refuses; an authority that throws + // refuses too (fail closed) and the fault is logged as an error. + private Action Authorize( + CultNetServerPeer peer, + CultNetRemoteWriteOperation operation, + string? shardId, + long? shardEpoch) + { + return (schemaId, key) => + { + var request = new CultNetRemoteWriteRequest(peer, operation, schemaId, key, shardId, shardEpoch); + var authority = _options.RemoteWriteAuthority; + if (authority == null) + { + throw new CultNetRemoteWriteRefusedException(request, "no remote write authority is configured"); + } + + CultNetRemoteWriteDecision decision; + try + { + decision = authority.Decide(request); + } + catch (Exception ex) + { + _server.Logger.LogError($"CultNet remote write authority failed: {ex.Message}"); + throw new CultNetRemoteWriteRefusedException(request, "the write authority failed"); + } + + if (decision == null || !decision.IsGranted) + { + throw new CultNetRemoteWriteRefusedException(request, decision?.Reason); + } + }; + } + private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, CultNetServerPeer peer) { try { - await ApplyPutAsync(message).ConfigureAwait(false); + await _database.ApplyPutAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch)) + .ConfigureAwait(false); + } + catch (CultNetRemoteWriteRefusedException ex) + { + _server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}"); + peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); } catch (CultNetShardAuthorityException ex) { @@ -339,7 +364,13 @@ namespace GameCult.Networking { try { - await ApplyDeleteAsync(message).ConfigureAwait(false); + await _database.ApplyDeleteAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch)) + .ConfigureAwait(false); + } + catch (CultNetRemoteWriteRefusedException ex) + { + _server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}"); + peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); } catch (CultNetShardAuthorityException ex) { diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index 62691a3e..58b7ca36 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -165,10 +165,31 @@ namespace GameCult.Networking } /// - /// Registers a document binding. + /// Registers a document binding. One binding owns a document type and a schema id: a binding claiming a type or a schema + /// id another binding already holds is refused with , 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 replaces + /// that binding. /// public CultNetDocumentRegistry Register(CultNetDocumentBinding binding) { + var holder = + _bindingsByType.TryGetValue(binding.DocumentType, out var sameType) && + !string.Equals(sameType.SchemaId, binding.SchemaId, StringComparison.Ordinal) + ? sameType + : _bindingsBySchemaId.TryGetValue(binding.SchemaId, out var sameSchema) && + sameSchema.DocumentType != binding.DocumentType + ? sameSchema + : null; + if (holder != null) + { + throw new CultSchemaConflictException( + $"CultNet schema id \"{holder.SchemaId}\" is already bound to type \"{holder.DocumentType.FullName}\"; " + + $"type \"{binding.DocumentType.FullName}\" (schema id \"{binding.SchemaId}\") cannot also be bound.", + binding.SchemaId, + new[] { holder.DocumentType.FullName ?? holder.DocumentType.Name, binding.DocumentType.FullName ?? binding.DocumentType.Name }, + string.Empty); + } + _bindingsBySchemaId[binding.SchemaId] = binding; _bindingsByType[binding.DocumentType] = binding; return this; diff --git a/src/GameCult.Networking/CultNetRemoteWriteAuthority.cs b/src/GameCult.Networking/CultNetRemoteWriteAuthority.cs new file mode 100644 index 00000000..b3083f19 --- /dev/null +++ b/src/GameCult.Networking/CultNetRemoteWriteAuthority.cs @@ -0,0 +1,113 @@ +using System; +using GameCult.Caching; + +namespace GameCult.Networking +{ + /// + /// Decides whether a connected peer may put or delete a record in the database a + /// serves. Transport admission (the connection key, a completed handshake) is + /// never a grant; this is the one owner of that decision. + /// + public interface ICultNetRemoteWriteAuthority + { + /// + /// Decides one peer write. Called once per received put or delete, after the schema is resolved and before the + /// shard is, so a refusal applies, publishes, logs and forwards nothing. + /// + CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request); + } + + /// + /// The kind of write a peer asked for. + /// + public enum CultNetRemoteWriteOperation + { + /// Put a record. + Put, + /// Delete a record. + Delete + } + + /// + /// One peer write, as the authority sees it. + /// + public sealed class CultNetRemoteWriteRequest + { + /// + /// Creates a request. + /// + public CultNetRemoteWriteRequest( + CultNetServerPeer peer, + CultNetRemoteWriteOperation operation, + string schemaId, + CultRecordKey recordKey, + string? shardId, + long? shardEpoch) + { + Peer = peer; + Operation = operation; + SchemaId = schemaId; + RecordKey = recordKey; + ShardId = shardId; + ShardEpoch = shardEpoch; + } + + /// Gets the peer connection the message arrived on (endpoint and transport only). + public CultNetServerPeer Peer { get; } + + /// Gets the operation. + public CultNetRemoteWriteOperation Operation { get; } + + /// Gets the canonical schema id the database resolved for the write, never the alias the peer sent. + public string SchemaId { get; } + + /// Gets the record key. + public CultRecordKey RecordKey { get; } + + /// Gets the shard id as the peer sent it. + public string? ShardId { get; } + + /// Gets the shard epoch as the peer sent it. + public long? ShardEpoch { get; } + } + + /// + /// An authority's answer to one . + /// + public sealed class CultNetRemoteWriteDecision + { + private CultNetRemoteWriteDecision(bool isGranted, string? reason) + { + IsGranted = isGranted; + Reason = reason; + } + + /// Gets whether the write may proceed. + public bool IsGranted { get; } + + /// Gets the refusal reason, when there is one. + public string? Reason { get; } + + /// Grants the write. + public static CultNetRemoteWriteDecision Grant() => new(true, null); + + /// Refuses the write. + public static CultNetRemoteWriteDecision Refuse(string? reason = null) => new(false, reason); + } + + // Thrown by the server's authorize callback through the database's remote apply when the authority refuses; the + // server's handlers are its one catcher. + internal sealed class CultNetRemoteWriteRefusedException : Exception + { + public CultNetRemoteWriteRefusedException(CultNetRemoteWriteRequest request, string? reason) + : base(reason ?? "the write was not authorized") + { + Request = request; + Reason = reason; + } + + public CultNetRemoteWriteRequest Request { get; } + + public string? Reason { get; } + } +} diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index e7f4cfd9..6b97c792 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -511,7 +511,7 @@ namespace GameCult.Networking [Key("routingHint")] public CultNetShardRoutingHint? RoutingHint { get; set; } /// /// Gets or sets the machine-readable refusal code (R-N), e.g. selection_invalid, - /// cursor_stale, cursor_invalid, reference_outside_target, unowned_schema. Additive to + /// cursor_stale, cursor_invalid, reference_outside_target, unowned_schema, write_not_authorized, unsupported_schema_version. Additive to /// cultnet.error.v0; a v0 peer that does not know this field ignores it. /// [Key("code")] public string? Code { get; set; } @@ -543,6 +543,30 @@ namespace GameCult.Networking Details = new CultNetErrorDetails { Field = "schemaId", Value = ex.SchemaId } }; + /// + /// Builds the wire error for a peer write its authority refused: code write_not_authorized, details + /// { field: "recordKey", value }. + /// + public static CultNetErrorMessage ForRemoteWriteRefused(CultNetRemoteWriteRequest request, string? reason) => + new CultNetErrorMessage + { + Error = string.IsNullOrWhiteSpace(reason) ? "write_not_authorized" : $"write_not_authorized: {reason}", + Code = "write_not_authorized", + Details = new CultNetErrorDetails { Field = "recordKey", Value = request.RecordKey.Value } + }; + + /// + /// Builds the wire error for a message whose schemaVersion this peer does not speak: code + /// unsupported_schema_version, details { field: "schemaVersion", value }. + /// + public static CultNetErrorMessage ForUnsupportedSchemaVersion(string? schemaVersion) => + new CultNetErrorMessage + { + Error = $"unsupported_schema_version: {schemaVersion}", + Code = "unsupported_schema_version", + Details = new CultNetErrorDetails { Field = "schemaVersion", Value = schemaVersion } + }; + /// /// Builds the wire error for a cursor refusal (R-N): code cursor_stale with details /// { asOf, current }, or code cursor_invalid with no details. diff --git a/src/GameCult.Networking/CultNetShardWriteForwarding.cs b/src/GameCult.Networking/CultNetShardWriteForwarding.cs index b2b9e7a2..7038b0a7 100644 --- a/src/GameCult.Networking/CultNetShardWriteForwarding.cs +++ b/src/GameCult.Networking/CultNetShardWriteForwarding.cs @@ -34,6 +34,13 @@ namespace GameCult.Networking /// Gets or sets the forwarder used when non-primary write forwarding is enabled. /// public ICultNetShardWriteForwarder? WriteForwarder { get; set; } + + /// + /// Gets or sets the authority asked for every peer put and delete. None configured refuses them all: a connection + /// key admits a peer to the transport, never to write. A write forwarded to a primary is judged here for the + /// original peer and again at the primary for the forwarder's connection. + /// + public ICultNetRemoteWriteAuthority? RemoteWriteAuthority { get; set; } } /// diff --git a/src/GameCult.Networking/CultWitnessArtifactBundle.cs b/src/GameCult.Networking/CultWitnessArtifactBundle.cs index 045ebfbb..6baa48bb 100644 --- a/src/GameCult.Networking/CultWitnessArtifactBundle.cs +++ b/src/GameCult.Networking/CultWitnessArtifactBundle.cs @@ -104,6 +104,10 @@ namespace GameCult.Networking [MessagePackObject] public sealed class CultWitnessContractPin { + [Key(4)] + [CultElementId] + public string Id { get; set; } = string.Empty; + /// /// Gets or sets the contract role inside the bundle. /// @@ -135,6 +139,10 @@ namespace GameCult.Networking [MessagePackObject] public sealed class CultWitnessArtifactEntry { + [Key(6)] + [CultElementId] + public string Id { get; set; } = string.Empty; + /// /// Gets or sets the artifact role inside the witness bundle. /// @@ -178,6 +186,10 @@ namespace GameCult.Networking [MessagePackObject] public sealed class CultWitnessTimingEntry { + [Key(5)] + [CultElementId] + public string Id { get; set; } = string.Empty; + /// /// Gets or sets the pipeline stage name. /// diff --git a/src/GameCult.Networking/README.md b/src/GameCult.Networking/README.md index de8496d3..dea0e411 100644 --- a/src/GameCult.Networking/README.md +++ b/src/GameCult.Networking/README.md @@ -197,7 +197,10 @@ reply. The first coherent shard policy is primary ownership. Each shard has one authoritative writer at a time. Clients may connect to any node; a non-owner node forwards a write to the owner or rejects it honestly when forwarding is -not available. Followers subscribe to committed shard mutations and apply them +not available. A node applies or forwards a peer's write only after its +`RemoteWriteAuthority` grants it; with none configured it refuses all of them, and a +write forwarded to a primary is judged again there for the forwarder's connection. +Followers subscribe to committed shard mutations and apply them through the same CultCache reconciliation path as local file changes. This should feel like the local-first parts of Firebase or RethinkDB without @@ -428,7 +431,7 @@ opt-in. Text frames and messages over the configured size bound are closed before schema dispatch. The matching `CultNetWebSocketSchemaClient` is suitable for C# headless and -service consumers. Browser applications use `cultmesh-browser`, which owns +service consumers. Browser applications use `@gamecult/cultmesh-browser`, which owns stable Verse/provider identity, document leases, resubscription, and operation correlation above the raw WebSocket route. diff --git a/src/GameCult.Unity/Assets/Caching/CHANGELOG.md b/src/GameCult.Unity/Assets/Caching/CHANGELOG.md index a5a0182a..4f533ecd 100644 --- a/src/GameCult.Unity/Assets/Caching/CHANGELOG.md +++ b/src/GameCult.Unity/Assets/Caching/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this package are documented in this file. +## [2.0.0] + +### Breaking + +- Requires `org.gamecult.cultlib` 2.0.0. Unity Package Manager resolves one + cultlib per project, so taking this release moves the project to cultlib 2.0.0. + +### Added + +- Studio grouping tree: types a document model groups appear under their group, + with create in group. +- Dragging a source record onto a reference field sets the reference. + ## [1.4.0] ### Added diff --git a/src/GameCult.Unity/Assets/Caching/package.json b/src/GameCult.Unity/Assets/Caching/package.json index dbd0815c..8c6fa91e 100644 --- a/src/GameCult.Unity/Assets/Caching/package.json +++ b/src/GameCult.Unity/Assets/Caching/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.caching.unity", "displayName": "GameCult CultCache Unity Tools", - "version": "1.4.0", + "version": "2.0.0", "unity": "2021.3", "description": "Unity editor tooling for inspecting and editing CultCache .cc stores.", "keywords": [ @@ -10,7 +10,7 @@ "Data" ], "dependencies": { - "org.gamecult.cultlib": "1.0.60" + "org.gamecult.cultlib": "2.0.0" }, "license": "MIT", "category": "Scripting" diff --git a/tests/GameCult.Caching.ConsumerBuild/Program.cs b/tests/GameCult.Caching.ConsumerBuild/Program.cs index b0f15973..60376068 100644 --- a/tests/GameCult.Caching.ConsumerBuild/Program.cs +++ b/tests/GameCult.Caching.ConsumerBuild/Program.cs @@ -1,10 +1,69 @@ using System.Collections.Generic; +using System.Linq; using GameCult.Caching; using MessagePack; // RefKeyedDictionaryCompilesInAConsumer: this project building is the test. System.Console.WriteLine(new ConsumerFaction().Standing.Count); +// An outside store kind versions its foreign records: this project has no InternalsVisibleTo, so building and running it pins that +// Foreign(record, catalog, version) is all a store kind outside the library needs for ExpectUnchanged to hold over them. +using var outside = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(ConsumerFaction) })); +outside.AddBackingStore(new OutsideStore(), typeof(ConsumerFaction)); +var committed = outside.Commit(batch => +{ + batch.ExpectUnchanged(); + batch.Upsert(new ConsumerFaction { Name = "a" }, new CultRecordHandle(new CultRecordKey("a"))); +}); +if (!committed) + return 1; + +// A store file is written and read without a cache through the public members: this project has no InternalsVisibleTo, so +// building it pins that ReadStoreSnapshot, WriteRecord and WriteDocument, and CultCodec.DecodeDocument, are public API. +var storePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "consumer-build-" + System.Guid.NewGuid().ToString("N") + ".cc"); +try +{ + var entry = new CultSchemaCatalogEntry { SchemaId = "outside.written", SchemaName = "outside.written" }; + var store = new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(storePath); + store.WriteRecord(new CultPersistedRecord { Key = "r", SchemaId = entry.SchemaId, StoredAt = "t", Payload = new byte[] { 0x90 } }, entry); + var snapshot = store.ReadStoreSnapshot(); + if (snapshot == null || snapshot.Records.Length != 1 || snapshot.Records[0].Key != "r") + return 1; + var typedRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(ConsumerFaction) }); + store.WriteDocument(new CultRecordKey("f"), new ConsumerFaction { Name = "typed" }, registry: typedRegistry); + var typed = store.ReadStoreSnapshot()!.Records.Single(record => record.Key == "f"); + if (GameCult.Caching.MessagePack.CultCacheMessagePack.CreateCodec(typedRegistry).DecodeDocument(new CultRecordKey("f"), typed.Payload).Name != "typed") + return 1; +} +finally +{ + System.IO.File.Delete(storePath); +} +return 0; + +// Lists one record under an id no type claims, at version "v1", and commits only when the conditions hold over it. +public sealed class OutsideStore : CacheBackingStore +{ + private static readonly CultSchemaCatalogEntry Entry = new() { SchemaId = "outside.unclaimed", SchemaName = "outside.unclaimed" }; + + public override void PullAll() + { + var record = new CultPersistedRecord { Key = "w", SchemaId = Entry.SchemaId, StoredAt = "t" }; + SetForeignRecords(new[] { Foreign(record, new[] { Entry }, "v1")! }); + } + + public override void Push(CultStoredDocument entry) { } + + public override void Delete(CultStoredDocument entry) { } + + public override void PushAll() { } + + public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => + request.ConditionsHold(new Dictionary { ["w"] = "v1" }, Entries.Values, ForeignRecords) + ? CultCommitOutcome.Committed + : CultCommitOutcome.Mismatch; +} + [CultDocument("tests.consumer_faction", "tests.consumer_faction.v1")] [MessagePackObject] public sealed class ConsumerFaction diff --git a/tests/GameCult.Caching.GeneratorConsumer/GameCult.Caching.GeneratorConsumer.csproj b/tests/GameCult.Caching.GeneratorConsumer/GameCult.Caching.GeneratorConsumer.csproj new file mode 100644 index 00000000..6d443a4f --- /dev/null +++ b/tests/GameCult.Caching.GeneratorConsumer/GameCult.Caching.GeneratorConsumer.csproj @@ -0,0 +1,25 @@ + + + Exe + net10.0 + enable + false + + + + true + true + true + + + + + + + + + + + + + diff --git a/tests/GameCult.Caching.GeneratorConsumer/Program.cs b/tests/GameCult.Caching.GeneratorConsumer/Program.cs new file mode 100644 index 00000000..2511ed80 --- /dev/null +++ b/tests/GameCult.Caching.GeneratorConsumer/Program.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; + +// The generator-on twin of ConsumerBuild: this project compiling is the first test, and running it checks that the generated +// formatters are the ones in use and that the bytes are the ones the dynamic formatter wrote. +var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(RefDoc).Assembly); +var target = new CultRecordRef(new CultRecordKey("p")); +var documents = new (Type Type, object Value)[] +{ + (typeof(MemberDoc), new MemberDoc { Name = "r", Target = target }), + (typeof(KeyedDoc), new KeyedDoc { Name = "r", Standing = { [target] = 0.5f } }), + (typeof(RefDoc), new RefDoc { Name = "r", Target = target, Standing = { [target] = 0.5f } }), +}; + +foreach (var (type, _) in documents) +{ + var formatter = options.Resolver.GetFormatterDynamic(type)!.GetType(); + if (formatter.Assembly.IsDynamic || !(formatter.FullName ?? "").Contains("GeneratedMessagePackResolver")) + return Fail($"{type.Name} is formatted by {formatter.FullName}, not a generated formatter"); +} + +var aot = !RuntimeFeature.IsDynamicCodeSupported; +if (aot) + Console.WriteLine("SKIP ref-keyed dictionary under NativeAOT: AOT cut 3 (map A6)"); +foreach (var (type, expected) in new[] { (typeof(MemberDoc), "92A172A170"), (typeof(RefDoc), "93A172A17081A170CA3F000000") }) +{ + if (aot && type != typeof(MemberDoc)) + continue; + var hex = Convert.ToHexString(MessagePackSerializer.Serialize(type, documents.First(d => d.Type == type).Value, options)); + Console.WriteLine(hex); + if (hex != expected) + return Fail($"{type.Name} payload is {hex}"); +} + +foreach (var (type, value) in documents) +{ + if (aot && type != typeof(MemberDoc)) + continue; + var path = Path.Combine(Path.GetTempPath(), "generator-consumer-" + Guid.NewGuid().ToString("N") + ".cc"); + try + { + var registry = CultDocumentRegistry.ForTypes(new[] { type, typeof(RefTarget) }); + using (var cache = new CultCache(registry)) + { + cache.AddBackingStore(new SingleFileMessagePackBackingStore(path)); + cache.UpsertAsync(type, value, new CultRecordKey("k")).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + } + using var reopened = new CultCache(registry); + reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path)); + reopened.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + var read = reopened.Get(new CultRecordKey("k")); + if (read == null || !Convert.ToHexString(MessagePackSerializer.Serialize(type, read, options)).Equals(Convert.ToHexString(MessagePackSerializer.Serialize(type, value, options)))) + return Fail($"{type.Name} did not round-trip through the store"); + } + finally + { + File.Delete(path); + } +} +return 0; + +static int Fail(string message) +{ + Console.Error.WriteLine(message); + return 1; +} + +[CultDocument("tests.generator_target", "tests.generator_target.v1")] +[MessagePackObject] +public sealed class RefTarget +{ + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; +} + +[CultDocument("tests.generator_member", "tests.generator_member.v1")] +[MessagePackObject] +public sealed class MemberDoc +{ + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public CultRecordRef Target { get; set; } +} + +[CultDocument("tests.generator_keyed", "tests.generator_keyed.v1")] +[MessagePackObject] +public sealed class KeyedDoc +{ + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public Dictionary, float> Standing { get; set; } = new(); +} + +[CultDocument("tests.generator_both", "tests.generator_both.v1")] +[MessagePackObject] +public sealed class RefDoc +{ + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public CultRecordRef Target { get; set; } + [Key(2)] public Dictionary, float> Standing { get; set; } = new(); +} diff --git a/tests/GameCult.Caching.InteropPeer/Program.cs b/tests/GameCult.Caching.InteropPeer/Program.cs index 20e2c62c..3c8eb92b 100644 --- a/tests/GameCult.Caching.InteropPeer/Program.cs +++ b/tests/GameCult.Caching.InteropPeer/Program.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; @@ -16,7 +17,7 @@ static async Task ProgramMainAsync(string[] args) { if (args.Length == 0) { - throw new InvalidOperationException("Expected mode: write | read | hold-lock | write-routed "); + throw new InvalidOperationException("Expected mode: write | read | rewrite | hold-lock | write-routed "); } var mode = args[0]; @@ -38,6 +39,18 @@ static async Task ProgramMainAsync(string[] args) case "read": await ReadAsync(file); return 0; + case "rewrite": + await RewriteAsync(file); + return 0; + case "write-deck": + await WriteDeckAsync(file); + return 0; + case "read-deck": + await ReadDeckAsync(file); + return 0; + case "edit-in-place": + await EditInPlaceAsync(file, RequireArg(options, "kind")); + return 0; case "hold-lock": HoldLock(file, int.Parse(RequireArg(options, "ms"), CultureInfo.InvariantCulture)); return 0; @@ -94,6 +107,113 @@ static async Task ReadAsync(string file) WriteJsonLine(note); } +// Puts the stored note back: the cache stamps it with the registered schema id, whatever id it was stored under. +static async Task RewriteAsync(string file) +{ + var cache = BuildCache(file); + await cache.PullAllBackingStoresAsync(); + var note = cache.AllEntries.OfType().Single(); + await cache.UpsertAsync(note, new CultRecordHandle(new CultRecordKey(note.DocumentId))); + cache.FlushAllBackingStores(); + WriteJsonLine(note); +} + +// A record with an object list: the cache mints each mark's element id, and every runtime reads it as an ordinary member. +static async Task WriteDeckAsync(string file) +{ + var cache = BuildCache(file); + await cache.PullAllBackingStoresAsync(); + var deck = new CultCacheInteropDeck + { + DocumentId = "deck:csharp", + Marks = [new CultCacheInteropMark { Label = "twin" }, new CultCacheInteropMark { Label = "twin" }, new CultCacheInteropMark { Label = "other" }] + }; + await cache.AddAsync(deck, new CultRecordHandle(new CultRecordKey(deck.DocumentId))); + cache.FlushAllBackingStores(); + WriteDeckJsonLine(deck); +} + +static async Task ReadDeckAsync(string file) +{ + var cache = BuildCache(file); + await cache.PullAllBackingStoresAsync(); + WriteDeckJsonLine(cache.AllEntries.OfType().Single()); +} + +static void WriteDeckJsonLine(CultCacheInteropDeck deck) => + Console.Out.WriteLine(JsonSerializer.Serialize(new { documentId = deck.DocumentId, ids = deck.Marks.Select(mark => mark.Id).ToArray() })); + +// Edits a stored record in place, as a consumer holding the object would, and writes it: the file must hold the edit, in whatever +// runtime reads it. payload and override go through the public snapshot API (a decoded record's bytes edited, then +// SerializeSnapshot); collection and object edit the document the cache holds and put the same instance back. +static async Task EditInPlaceAsync(string file, string kind) +{ + switch (kind) + { + case "payload": + { + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file)); + OverwriteInPlace(snapshot.Records.Single().Payload, "wrote", "WROTE"); + File.WriteAllBytes(file, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + break; + } + case "override": + { + var cache = CultCacheMessagePack.Create(file, new CultCacheOpenOptions { Registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultCacheInteropNote) }) }); + await cache.PullAllBackingStoresAsync(); + var note = cache.AllEntries.OfType().Single(); + await cache.UpsertVariantAsync( + new CultRecordKey("note:variant"), + new CultRecordKey(note.DocumentId), + new[] + { + cache.Override(nameof(CultCacheInteropNote.DocumentId), "note:variant"), + cache.Override(nameof(CultCacheInteropNote.Title), "variant title") + }); + cache.FlushAllBackingStores(); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file)); + var variant = snapshot.Records.Single(record => record.Variant != null).Variant!; + OverwriteInPlace(variant.Overrides.Single(entry => entry.Path[0].Slot == 3).Value, "variant", "VARIANT"); + File.WriteAllBytes(file, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + break; + } + case "collection": + { + var cache = BuildCache(file); + await cache.PullAllBackingStoresAsync(); + var note = cache.AllEntries.OfType().Single(); + note.Tags[0] = "EDITED"; + await cache.UpsertAsync(note, new CultRecordHandle(new CultRecordKey(note.DocumentId))); + cache.FlushAllBackingStores(); + break; + } + case "object": + { + var cache = BuildCache(file); + await cache.PullAllBackingStoresAsync(); + var deck = cache.AllEntries.OfType().Single(); + deck.Marks[0].Label = "EDITED"; + await cache.UpsertAsync(deck, new CultRecordHandle(new CultRecordKey(deck.DocumentId))); + cache.FlushAllBackingStores(); + break; + } + default: + throw new InvalidOperationException($"Unknown edit kind {kind}."); + } + + Console.Out.WriteLine(JsonSerializer.Serialize(new { kind })); +} + +static void OverwriteInPlace(byte[] bytes, string from, string to) +{ + var was = System.Text.Encoding.UTF8.GetBytes(from); + var now = System.Text.Encoding.UTF8.GetBytes(to); + var at = bytes.AsSpan().IndexOf(was); + if (at < 0 || now.Length != was.Length) + throw new InvalidOperationException($"Cannot overwrite '{from}' with '{to}' in place."); + now.CopyTo(bytes, at); +} + // Two routed single-file stores: each file is a complete single-store snapshot holding only its own type. static void WriteRouted(string catalogFile, string runFile) { @@ -172,7 +292,7 @@ static void WriteJsonLine(CultCacheInteropNote note) })); } -[CultDocument("cultcache.interop-note", "cultcache.interop_note.v1")] +[CultDocument("cultcache.interop-note", "cultcache.interop_note.v1", CompatibleSchemaIds = new[] { "cultcache.interop-note.legacy-id" })] [MessagePackObject] public sealed class CultCacheInteropNote { @@ -193,3 +313,18 @@ public sealed class CultCacheInteropRunNote [Key(2)] public string AuthorRuntimeId { get; set; } = string.Empty; [Key(3)] public string Body { get; set; } = string.Empty; } + +[CultDocument("cultcache.interop-deck", "cultcache.interop_deck.v1")] +[MessagePackObject] +public sealed class CultCacheInteropDeck +{ + [Key(0)] [CultName] public string DocumentId { get; set; } = string.Empty; + [Key(1)] public List Marks { get; set; } = new(); +} + +[MessagePackObject] +public sealed class CultCacheInteropMark +{ + [Key(0)] public string Label { get; set; } = string.Empty; + [Key(1)] [CultElementId] public string Id { get; set; } = string.Empty; +} diff --git a/tests/GameCult.Caching.OutsideStoreTests/GameCult.Caching.OutsideStoreTests.csproj b/tests/GameCult.Caching.OutsideStoreTests/GameCult.Caching.OutsideStoreTests.csproj new file mode 100644 index 00000000..c40c048d --- /dev/null +++ b/tests/GameCult.Caching.OutsideStoreTests/GameCult.Caching.OutsideStoreTests.csproj @@ -0,0 +1,22 @@ + + + + + net10.0 + false + enable + GameCult.Caching.OutsideStoreTests + + + + + + + + + + + + + + diff --git a/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs b/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs new file mode 100644 index 00000000..e47e4d5f --- /dev/null +++ b/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.OutsideStoreTests +{ + // variants:ruling:store-kind-per-key-judge: a store kind written outside the library applies the whole per-key ownership rule + // through the one protected judge (CacheBackingStore.RefuseUndeclaredOnDisk), using only public and protected members. This + // assembly has no InternalsVisibleTo, so a judge parameter type, or something it needs, turning internal breaks it here. + public class OutsideStoreJudgeTests + { + [CultDocument("tests.out_a", "tests.out_a.v1")] + [MessagePackObject] + public sealed class OutA + { + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public int Value { get; set; } + } + + [CultDocument("tests.out_b", "tests.out_b.v1")] + [MessagePackObject] + public sealed class OutB + { + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public int Value { get; set; } + } + + // RbX's schema id, which RbY lists; the test below asserts it, so a changed derivation fails there. + private const string RbXId = "sha256:25087e0d76fd5b9ac950723ccd47f3f3ac1428b53b49ca9bda9aae8b38a0220a"; + + [CultDocument("tests.rb_x", "tests.rb_x.v1")] + [MessagePackObject] + public sealed class RbX + { + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public int Value { get; set; } + } + + [CultDocument("tests.rb_y", "tests.rb_y.v1", CompatibleSchemaIds = new[] { RbXId })] + [MessagePackObject] + public sealed class RbY + { + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public int Value { get; set; } + } + + [CultDocument("tests.rb_z", "tests.rb_z.v1")] + [MessagePackObject] + public sealed class RbZ + { + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public int Value { get; set; } + } + + // What the "disk" holds: records by key and the catalog that publishes their ids. + private sealed class Disk + { + public readonly Dictionary Records = new(); + public readonly List Catalog = new(); + private int _version; + + public void Put(CultPersistedRecord record, CultSchemaCatalogEntry entry) + { + if (Catalog.All(known => known.SchemaId != entry.SchemaId)) + Catalog.Add(entry); + Records[record.Key] = (record, (++_version).ToString()); + } + + public string? SchemaIdAt(string key) => Records.TryGetValue(key, out var held) ? held.Record.SchemaId : null; + } + + // A store kind with no InternalsVisibleTo: an in-memory disk, flushed and committed under the protected judge. + private sealed class OutsideStore : CacheBackingStore + { + private readonly Disk _disk; + private readonly CultCodec _codec; + private readonly HashSet _staged = new(StringComparer.Ordinal); + // StoredVersion is internal to the library, so a store kind tracks the version it last took of each key itself. + private readonly Dictionary _versions = new(StringComparer.Ordinal); + + public OutsideStore(Disk disk, CultCodec codec) + { + _disk = disk; + _codec = codec; + } + + private object Decode(Type type, byte[] payload) => _codec.Deserialize(type, type, payload)!; + + private CultStoredDocument? DurableAt(string key) + { + if (!_disk.Records.TryGetValue(key, out var held)) + return null; + try + { + return ToStoredDocument(held.Record, held.Version, _disk.Catalog, Decode, out _); + } + catch (Exception) + { + return null; + } + } + + public override void PullAll() => Held(() => + { + var loaded = _disk.Records + .Where(pair => !_versions.TryGetValue(pair.Key, out var known) || !SameStoredIdentity(known, pair.Value.Version)) + .Select(pair => DurableAt(pair.Key)) + .OfType() + .ToArray(); + var dropped = Entries.Values.Where(known => !_disk.Records.ContainsKey(known.Key.Value)).ToArray(); + if (loaded.Length > 0 || dropped.Length > 0) + Loaded?.Invoke(loaded, dropped); + foreach (var stored in dropped) + Entries.TryRemove(stored.Key.Value, out _); + foreach (var stored in loaded) + { + Entries[stored.Key.Value] = stored; + _versions[stored.Key.Value] = _disk.Records[stored.Key.Value].Version; + } + }); + + public override void Push(CultStoredDocument entry) => Held(() => + { + Entries[entry.Key.Value] = entry; + _staged.Add(entry.Key.Value); + IsDirty = true; + }); + + public override void Delete(CultStoredDocument entry) => Held(() => + { + Entries.TryRemove(entry.Key.Value, out _); + _staged.Add(entry.Key.Value); + IsDirty = true; + }); + + public override void PushAll() => Held(() => Apply(Array.Empty(), Array.Empty(), Array.Empty())); + + public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => Held(() => + { + Apply(request.Upserts, request.Deletes, request.Checks); + foreach (var entry in request.Deletes) + Entries.TryRemove(entry.Key.Value, out _); + foreach (var entry in request.Upserts) + Entries[entry.Key.Value] = entry; + return CultCommitOutcome.Committed; + }); + + private void Apply(IReadOnlyList upserts, IReadOnlyList deletes, IEnumerable checks) + { + var writes = upserts.ToDictionary(entry => entry.Key.Value, StringComparer.Ordinal); + foreach (var key in _staged) + { + if (Entries.TryGetValue(key, out var pushed)) + writes.TryAdd(key, pushed); + } + + var removals = deletes.Select(entry => entry.Key.Value) + .Concat(_staged.Where(key => !writes.ContainsKey(key) && !Entries.ContainsKey(key))) + .ToArray(); + var judged = removals.Concat(checks).ToArray(); + var entries = writes.ToDictionary(pair => pair.Key, pair => pair.Value.Descriptor.ToCatalogEntry(), StringComparer.Ordinal); + + // The whole rule, from the protected judge: the same call both shipped stores make. + RefuseUndeclaredOnDisk( + writes.Keys.Concat(judged).Concat(StagedChecks), + _disk.Catalog, + key => _disk.Records.TryGetValue(key, out var held) ? held.Record : null, + key => entries.TryGetValue(key, out var entry) ? entry : null, + judged, + DurableAt, + _ => null, + key => + { + _staged.Remove(key); + IsDirty = _staged.Count > 0; + }); + + foreach (var (key, document) in writes) + { + var type = document.Descriptor.DocumentType; + _disk.Put( + new CultPersistedRecord + { + Key = key, + SchemaId = document.Descriptor.SchemaId, + StoredAt = document.StoredAt, + Payload = _codec.Serialize(document.Document, type, type) + }, + entries[key]); + _versions[key] = _disk.Records[key].Version; + } + + foreach (var key in removals.Where(key => !writes.ContainsKey(key))) + { + _disk.Records.Remove(key); + _versions.Remove(key); + } + _staged.Clear(); + MarkFlushSucceeded(); + } + } + + private static readonly CultDocumentRegistry AB = CultDocumentRegistry.ForTypes(new[] { typeof(OutA), typeof(OutB) }); + private static readonly CultDocumentRegistry Rb = CultDocumentRegistry.ForTypes(new[] { typeof(RbX), typeof(RbY), typeof(RbZ) }); + private static readonly CultRecordKey K = new("k"); + private static readonly CultRecordKey K2 = new("k2"); + + private static CultCache Open(Disk disk, CultDocumentRegistry registry) + { + var cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry)); + cache.AddBackingStore(new OutsideStore(disk, cache.Codec!)); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + return cache; + } + + // Another writer puts a record of this type at the key on the disk, as the cache's own store would have. + private static void PutOnDisk(Disk disk, CultDocumentRegistry registry, string key, T document, string? asSchemaId = null, CultSchemaCatalogEntry? entry = null) + where T : class + { + var codec = CultCacheMessagePack.CreateCodec(registry); + var descriptor = registry.GetRequired(); + disk.Put( + new CultPersistedRecord + { + Key = key, + SchemaId = asSchemaId ?? descriptor.SchemaId, + StoredAt = DateTimeOffset.UtcNow.ToString("O"), + Payload = codec.Serialize(document, typeof(T), typeof(T)) + }, + entry ?? descriptor.ToCatalogEntry()); + } + + private static Type HeldType(CultCache cache, CultRecordKey key) => cache.GetStored(key)!.Descriptor.DocumentType; + + // The written type's own id: a record of another registered type that this cache never loaded. + [Test] + public void AnotherRegisteredTypesUnheldRecordIsRefusedUnderTheLockAndReloaded() + { + var disk = new Disk(); + using var cache = Open(disk, AB); + PutOnDisk(disk, AB, "k", new OutB { Name = "k", Value = 2 }); + var before = disk.Records["k"]; + + Assert.That( + () => cache.Commit(batch => batch.Upsert(typeof(OutA), new OutA { Name = "k", Value = 1 }, K)), + Throws.InstanceOf()); + Assert.That(disk.Records["k"], Is.EqualTo(before), "the refused write changed nothing on the disk"); + Assert.That(HeldType(cache, K), Is.EqualTo(typeof(OutB)), "the refusal reloaded the record the store holds"); + } + + [Test] + public void AFlushOfAnotherRegisteredTypesUnheldRecordIsRefusedToo() + { + var disk = new Disk(); + using var cache = Open(disk, AB); + PutOnDisk(disk, AB, "k", new OutB { Name = "k", Value = 2 }); + + cache.UpsertAsync(typeof(OutA), new OutA { Name = "k", Value = 1 }, K).GetAwaiter().GetResult(); + Assert.That(() => cache.FlushAsync().GetAwaiter().GetResult(), Throws.InstanceOf()); + Assert.That(disk.SchemaIdAt("k"), Is.EqualTo(AB.GetRequired().SchemaId)); + Assert.That(HeldType(cache, K), Is.EqualTo(typeof(OutB))); + } + + // Claimant precedence, a listed id: the written type owns a record under an id it lists, and a type that lists nothing does not. + [Test] + public void ARecordUnderAnIdTheWrittenTypeListsIsOwnedAndOneItDoesNotListIsRefused() + { + Assert.That(Rb.GetRequired().SchemaId, Is.EqualTo(RbXId)); + var disk = new Disk(); + using var cache = Open(disk, Rb); + PutOnDisk(disk, Rb, "k", new RbX { Name = "k" }); + PutOnDisk(disk, Rb, "k2", new RbX { Name = "k2" }); + + Assert.That(cache.Commit(batch => batch.Upsert(typeof(RbY), new RbY { Name = "k", Value = 1 }, K)), Is.True); + Assert.That(disk.SchemaIdAt("k"), Is.EqualTo(Rb.GetRequired().SchemaId)); + + Assert.That( + () => cache.Commit(batch => batch.Upsert(typeof(RbZ), new RbZ { Name = "k2" }, K2)), + Throws.InstanceOf()); + Assert.That(disk.SchemaIdAt("k2"), Is.EqualTo(RbXId), "the type that lists nothing replaced nothing"); + } + + // Reads whole: an older id of the written type's own schema name, which the type reads whole, is its own and is restamped. + [Test] + public void AnOlderIdOfTheWrittenSchemaNameThatIsReadWholeIsOwnedAndRestamped() + { + var disk = new Disk(); + using var cache = Open(disk, AB); + var current = AB.GetRequired().ToCatalogEntry(); + var older = new CultSchemaCatalogEntry + { + SchemaId = "sha256:older-out-a", + SchemaName = current.SchemaName, + SchemaVersion = "tests.out_a.v0", + ContentHash = current.ContentHash, + CanonicalSchemaJson = current.CanonicalSchemaJson, + Members = current.Members + }; + PutOnDisk(disk, AB, "k", new OutA { Name = "k" }, asSchemaId: older.SchemaId, entry: older); + + Assert.That(cache.Commit(batch => batch.Upsert(typeof(OutA), new OutA { Name = "k", Value = 3 }, K)), Is.True); + Assert.That(disk.SchemaIdAt("k"), Is.EqualTo(current.SchemaId)); + } + + // Staged retype removals: a held key retypes as a removal then an add, in one batch and staged apart. + [TestCase(true)] + [TestCase(false)] + public void ARetypeOfAHeldKeyCommitsInOneBatchAndStagedApart(bool oneBatch) + { + var disk = new Disk(); + PutOnDisk(disk, AB, "k", new OutB { Name = "k", Value = 2 }); + using var cache = Open(disk, AB); + Assert.That(HeldType(cache, K), Is.EqualTo(typeof(OutB))); + + if (oneBatch) + Assert.That(cache.Commit(batch => + { + batch.Remove(K); + batch.Upsert(typeof(OutA), new OutA { Name = "k", Value = 1 }, K); + }), Is.True); + else + { + Assert.That(cache.Remove(K), Is.True); + Assert.That(cache.Commit(batch => batch.Upsert(typeof(OutA), new OutA { Name = "k", Value = 1 }, K)), Is.True); + } + + Assert.That(disk.SchemaIdAt("k"), Is.EqualTo(AB.GetRequired().SchemaId)); + Assert.That(HeldType(cache, K), Is.EqualTo(typeof(OutA))); + } + + [Test] + public void ARetypeOfAKeyTheCacheNeverLoadedIsJudgedByTheRemovalRule() + { + var disk = new Disk(); + using var cache = Open(disk, AB); + PutOnDisk(disk, AB, "k", new OutB { Name = "k", Value = 2 }); + + Assert.That(cache.Commit(batch => + { + batch.Remove(K); + batch.Upsert(typeof(OutA), new OutA { Name = "k", Value = 1 }, K); + }), Is.True, "a record a registered type reads may be removed, so the key retypes"); + Assert.That(disk.SchemaIdAt("k"), Is.EqualTo(AB.GetRequired().SchemaId)); + } + + // A removal is never licensed over a record no registered type owns, retype or not. + [Test] + public void ACommitNeverRemovesOrRetypesARecordNoRegisteredTypeOwns() + { + var disk = new Disk(); + using var cache = Open(disk, AB); + var unknown = new CultSchemaCatalogEntry { SchemaId = "tests.out_unknown", SchemaName = "tests.out_unknown" }; + disk.Put(new CultPersistedRecord { Key = "k", SchemaId = unknown.SchemaId, StoredAt = "t", Payload = new byte[] { 0x90 } }, unknown); + var before = disk.Records["k"]; + + Assert.That(() => cache.Commit(batch => + { + batch.Remove(K); + batch.Upsert(typeof(OutA), new OutA { Name = "k" }, K); + }), Throws.InstanceOf(), "retype"); + Assert.That(() => cache.Commit(batch => batch.Remove(K)), Throws.InstanceOf(), "removal"); + Assert.That(disk.Records["k"], Is.EqualTo(before)); + } + } +} diff --git a/tests/GameCult.Caching.Tests/AssemblyInfo.cs b/tests/GameCult.Caching.Tests/AssemblyInfo.cs index 0dd8b125..708f0a09 100644 --- a/tests/GameCult.Caching.Tests/AssemblyInfo.cs +++ b/tests/GameCult.Caching.Tests/AssemblyInfo.cs @@ -6,7 +6,10 @@ using MessagePack.Formatters; [assembly: CultCacheFormatterResolver(typeof(PairResolver))] [assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.StubArmResolver))] +[assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.ClaimArmResolver))] [assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.OrderResolver))] +[assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.ListClaimResolver))] +[assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.FbLaterClaimResolver))] namespace GameCult.Caching.Tests { diff --git a/tests/GameCult.Caching.Tests/C2aGateWriter.cs b/tests/GameCult.Caching.Tests/C2aGateWriter.cs new file mode 100644 index 00000000..9a018a3e --- /dev/null +++ b/tests/GameCult.Caching.Tests/C2aGateWriter.cs @@ -0,0 +1,140 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // The writer half of the C2a format gate. It writes a record that holds element ids through CultCacheMessagePack.Create, once + // as a single-file store and once as a directory store, and asserts the headers C2a stamps. old-reader-refusal.sh gate runs it + // with CULTLIB_GATE_OUT set, so the 2.0.1 reader probes the very bytes this tree writes; the committed vectors are one such run. + public class C2aGateWriter + { + private const string SingleName = "c2a-written-ids.msgpack"; + private const string DirectoryName = "c2a-written-ids-directory"; + private const string ManifestName = "store.cc"; + private const string DirectoryHeader = "cultcache.store.v5.directory-content-addressed-pages"; + + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(ElementIdTests.IdDeck) }); + + // Every id is the author's, so the written bytes and the decoded ids do not depend on minting. + private static readonly string[] Ids = { "00000000c2a1", "00000000c2a2", "00000000c2a3", "00000000c2a4" }; + + private static ElementIdTests.IdDeck Deck() => new() + { + Name = "gate", + Reels = + { + new ElementIdTests.IdReel + { + Label = "reel", + Id = Ids[0], + Marks = { new ElementIdTests.IdMark { Text = "m", Id = Ids[1] }, new ElementIdTests.IdMark { Text = "m", Id = Ids[2] } } + } + }, + Moves = { new ElementIdTests.IdSlash { Reach = 1, Id = Ids[3] } } + }; + + private static string[] IdsOf(ElementIdTests.IdDeck deck) => + deck.Reels.Select(reel => reel.Id) + .Concat(deck.Reels.SelectMany(reel => reel.Marks.Select(mark => mark.Id))) + .Concat(deck.Moves.Select(move => move.Id)) + .ToArray(); + + // The one instant every record is stamped with, so the bytes this writes are the same on every run and the committed vectors + // can be compared to them byte for byte. Regenerate the vectors with CULTLIB_GATE_OUT (see C2aStamps...) if C2a's format moves. + private static readonly DateTimeOffset StoredAt = new(2026, 10, 27, 0, 0, 0, TimeSpan.Zero); + + private static void Write(string path, bool directory) + { + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory }); + cache.Clock = () => StoredAt; + cache.Commit(batch => batch.Upsert(typeof(ElementIdTests.IdDeck), Deck(), new CultRecordKey("gate"))); + } + + private static string HeaderOf(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion; + + private static string[] ReadIds(string path) + { + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + return IdsOf(cache.Get(new CultRecordKey("gate"))!); + } + + private static string Vectors() + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var vectors = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c2a"); + if (Directory.Exists(vectors)) return vectors; + } + + throw new DirectoryNotFoundException($"tests/vectors/document-variants-c2a not found above {TestContext.CurrentContext.TestDirectory}."); + } + + // Every file under root by relative path and bytes; a store's lock files hold no state and are not compared. + private static (string Path, byte[] Bytes)[] Tree(string root) => + Directory.GetFiles(root, "*", SearchOption.AllDirectories) + .Where(file => !file.EndsWith(".lock", StringComparison.Ordinal)) + .Select(file => (Path.GetRelativePath(root, file).Replace(Path.DirectorySeparatorChar, '/'), File.ReadAllBytes(file))) + .OrderBy(entry => entry.Item1, StringComparer.Ordinal) + .ToArray(); + + private string _temp = string.Empty; + + [SetUp] + public void CreateTemp() + { + _temp = Path.Combine(Path.GetTempPath(), $"cultlib-c2a-gate-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_temp); + } + + [TearDown] + public void DeleteTemp() + { + if (Directory.Exists(_temp)) + Directory.Delete(_temp, recursive: true); + } + + [Test] + public void C2aStampsV3OnASingleFileStoreAndV5OnADirectoryStoreThatHoldIds() + { + var output = Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT"); + var root = string.IsNullOrEmpty(output) ? _temp : output; + Directory.CreateDirectory(root); + var single = Path.Combine(root, SingleName); + var directory = Path.Combine(root, DirectoryName); + File.Delete(single); + if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); + Directory.CreateDirectory(directory); + + Write(single, directory: false); + Write(Path.Combine(directory, ManifestName), directory: true); + + Assert.That(HeaderOf(single), Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); + Assert.That(HeaderOf(Path.Combine(directory, ManifestName)), Is.EqualTo(DirectoryHeader)); + Assert.That(ReadIds(single), Is.EqualTo(Ids)); + } + + [Test] + public void TheCommittedVectorsAreWhatC2aWrites() + { + var live = Path.Combine(_temp, "live"); + var liveDirectory = Path.Combine(live, DirectoryName); + Directory.CreateDirectory(liveDirectory); + Write(Path.Combine(live, SingleName), directory: false); + Write(Path.Combine(liveDirectory, ManifestName), directory: true); + + Assert.That(File.ReadAllBytes(Path.Combine(Vectors(), SingleName)), Is.EqualTo(File.ReadAllBytes(Path.Combine(live, SingleName))), SingleName); + + var committed = Tree(Path.Combine(Vectors(), DirectoryName)); + var written = Tree(liveDirectory); + Assert.That(committed.Select(entry => entry.Path), Is.EqualTo(written.Select(entry => entry.Path)), DirectoryName); + for (var i = 0; i < committed.Length; i++) + Assert.That(committed[i].Bytes, Is.EqualTo(written[i].Bytes), committed[i].Path); + } + } +} diff --git a/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs b/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs new file mode 100644 index 00000000..43754142 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs @@ -0,0 +1,1530 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; +using static GameCult.Caching.Tests.ElementIdTests; + +namespace GameCult.Caching.Tests +{ + // A single-file store file is read and written without a cache through ReadStoreSnapshot, WriteRecord and WriteDocument: the same reader, lock, + // atomic replace and header rule a cache's store uses. CultMesh's single-file document helpers are built on these members. + public class CachelessSingleFileTests + { + private static readonly CultSchemaCatalogEntry One = new() { SchemaId = "tests.cacheless.one", SchemaName = "tests.cacheless.one" }; + private static readonly CultSchemaCatalogEntry Two = new() { SchemaId = "tests.cacheless.two", SchemaName = "tests.cacheless.two" }; + private string _directory = ""; + private string _path = ""; + + [SetUp] + public void SetUp() + { + _directory = Path.Combine(Path.GetTempPath(), "cultlib-cacheless-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + _path = Path.Combine(_directory, "store.cc"); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, true); + } + + private static CultCodec Codec(CultDocumentRegistry? registry = null) => + CultCacheMessagePack.CreateCodec(registry ?? CultDocumentRegistry.Shared); + + [CultDocument("tests.cacheless.base", "tests.cacheless.base.v1")] + [MessagePackObject] + public class DeclaredBase + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + } + + [CultDocument("tests.cacheless.id_base", "tests.cacheless.id_base.v1")] + [MessagePackObject] + public class IdBase + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + } + + [MessagePackObject] + public sealed class IdDerived : IdBase + { + [Key(1)] public System.Collections.Generic.List Reels { get; set; } = new(); + } + + [MessagePackObject] + public sealed class UndeclaredDerived : DeclaredBase + { + [Key(1)] public string Extra { get; set; } = ""; + } + + // Reads the store file as a SingleFileMessagePackBackingStore does, and holds the first reader inside ReadStore so a test can + // see whether a second one gets in beside it. + private sealed class GatedStore : SingleFileBackingStore + { + private int _held; + private int _inside; + private int _mostInside; + public readonly ManualResetEventSlim Entered = new(); + public readonly ManualResetEventSlim Release = new(); + public CultDocumentRegistry? Attached => AttachedRegistry; + public CultDocumentRegistry? RegistryBeforeWait; + public CultDocumentRegistry? RegistryAfterWait; + + public GatedStore(string path) : base(path) { } + + public int MostInside => Volatile.Read(ref _mostInside); + + protected override byte[] SerializeStore(string formatVersion, System.Collections.Generic.IReadOnlyList catalogEntries, System.Collections.Generic.IReadOnlyList records) => + CultDocumentMessagePackSerialization.SerializeStore(formatVersion, catalogEntries, records); + + protected override byte[] SerializeRecord(CultPersistedRecord record) => + CultDocumentMessagePackSerialization.SerializePersistedRecord(record); + + protected override byte[] SerializeCatalogEntry(CultSchemaCatalogEntry entry) => + CultDocumentMessagePackSerialization.SerializeCatalogEntry(entry); + + protected override (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) ReadStore(byte[] data) + { + var inside = Interlocked.Increment(ref _inside); + int seen; + while ((seen = MostInside) < inside && Interlocked.CompareExchange(ref _mostInside, inside, seen) != seen) { } + Entered.Set(); + try + { + if (Interlocked.Exchange(ref _held, 1) == 0) + { + RegistryBeforeWait = AttachedRegistry; + Release.Wait(TimeSpan.FromSeconds(30)); + RegistryAfterWait = AttachedRegistry; + } + var store = CultDocumentMessagePackSerialization.ReadStore(data); + CultDocumentMessagePackSerialization.RequireSingleFileFormat(store.Snapshot); + return store; + } + finally + { + Interlocked.Decrement(ref _inside); + } + } + + protected override byte[] SerializePayload(object document, Type documentType) => + CultDocumentMessagePackSerialization.SerializeUntyped(document, documentType, Registry); + + protected override object DeserializePayload(Type documentType, byte[] payload) => + CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry); + } + + // ReadStoreSnapshot takes the store's gate: while one read is inside the reader, a second does not enter beside it. + [Test] + public async Task ReadStoreSnapshotTakesTheStoresGate() + { + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One); + var store = new GatedStore(_path); + + var first = Task.Run(() => store.ReadStoreSnapshot()); + Assert.That(store.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True, "the first read reached the reader"); + var second = Task.Run(() => store.ReadStoreSnapshot()); + await Task.Delay(500); + var mostInside = store.MostInside; + store.Release.Set(); + await Task.WhenAll(first, second); + + Assert.That(mostInside, Is.EqualTo(1), "a second read waits at the gate while the first is inside the reader"); + Assert.That(first.Result!.Records, Has.Length.EqualTo(1)); + Assert.That(second.Result!.Records, Has.Length.EqualTo(1)); + } + + // A call's registry is attached under the store's gate: a second call with another registry waits for the first, so the + // first sees its own registry from its start to its end. + [Test] + public async Task ACallKeepsItsRegistryWhileAnotherCallWaits() + { + var mine = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var other = CultDocumentRegistry.ForTypes(new[] { typeof(DeclaredBase) }); + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One); + var store = new GatedStore(_path); + + var first = Task.Run(() => store.WriteDocument(new CultRecordKey("first"), new IdDeck { Name = "first" }, registry: mine)); + Assert.That(store.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True, "the first call reached the reader"); + var second = Task.Run(() => store.WriteDocument(new CultRecordKey("second"), new DeclaredBase { Name = "second" }, registry: other)); + await Task.Delay(500); + store.Release.Set(); + await Task.WhenAll(first, second); + + Assert.That(store.RegistryBeforeWait, Is.SameAs(mine)); + Assert.That(store.RegistryAfterWait, Is.SameAs(mine), "the second call's registry did not replace the first's mid-call"); + Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.Records.Select(record => record.Key), Is.EquivalentTo(new[] { "a", "first", "second" })); + } + + // An attach during a cacheless call waits for it: the call finishes on the registry it was given, the store then gains the + // cache's registry, and the cache's next flush works. + [Test] + public async Task AnAttachDuringACachelessCallWaitsForIt() + { + var mine = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var cacheRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: mine); + var store = new GatedStore(_path); + + var write = Task.Run(() => store.WriteDocument(new CultRecordKey("first"), new IdDeck { Name = "first" }, registry: mine)); + Assert.That(store.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True, "the call reached the reader"); + using var cache = new CultCache(cacheRegistry); + var attach = Task.Run(() => cache.AddBackingStore(store)); + Assert.That(attach.Wait(TimeSpan.FromMilliseconds(500)), Is.False, "the attach waits while a cacheless call is inside the store"); + store.Release.Set(); + await write; + await attach; + + Assert.That(store.RegistryAfterWait, Is.SameAs(mine), "the call kept its own registry to its end"); + Assert.That(store.Attached, Is.SameAs(cacheRegistry), "the call's end did not detach the cache's registry"); + await cache.AddAsync(new IdDeck { Name = "b" }); + Assert.DoesNotThrow(() => cache.FlushAllBackingStores()); + } + + // A cacheless call that finds the store attached by the time it holds the gate is refused, and leaves the cache's registry. + [Test] + public void ACachelessCallOnAnAttachedStoreIsRefusedAndLeavesTheRegistry() + { + var cacheRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var store = new GatedStore(_path); + store.Release.Set(); + using var cache = new CultCache(cacheRegistry); + cache.AddBackingStore(store); + + Assert.Throws(() => store.WriteDocument(new CultRecordKey("x"), new IdDeck { Name = "x" })); + Assert.Throws(() => store.WriteRecord(Record("a", One, 1), One)); + Assert.That(store.Attached, Is.SameAs(cacheRegistry)); + } + + // A store whose hooks fire by the role of the thread inside it, so a test can hold an attach and a cacheless call at chosen + // points: the attach inside its pull (and make that pull fail), a call inside its read or inside its payload encoding. + private sealed class RoleStore : SingleFileBackingStore + { + [ThreadStatic] public static string? Role; + public bool FailAttachPull; + public readonly ManualResetEventSlim AttachInside = new(); + public readonly ManualResetEventSlim ReleaseAttach = new(); + public readonly ManualResetEventSlim CallInside = new(); + public readonly ManualResetEventSlim ReleaseCall = new(); + public readonly ManualResetEventSlim ReaderInside = new(); + public readonly ManualResetEventSlim ReleaseReader = new(); + // How many threads are inside ReadStore at once, and the most there ever were. + private int _inside, _mostInside; + public int MostInside => Volatile.Read(ref _mostInside); + // Run by the thread whose role is "hook", inside ReadStore: user code that runs under the store's gate. + [ThreadStatic] public static Action? RoleHook; + public CultDocumentRegistry? Attached => AttachedRegistry; + public bool IsAttachedToACache => Cache != null; + + public RoleStore(string path) : base(path) { } + + protected override byte[] SerializeStore(string formatVersion, System.Collections.Generic.IReadOnlyList catalogEntries, System.Collections.Generic.IReadOnlyList records) => + CultDocumentMessagePackSerialization.SerializeStore(formatVersion, catalogEntries, records); + + protected override byte[] SerializeRecord(CultPersistedRecord record) => + CultDocumentMessagePackSerialization.SerializePersistedRecord(record); + + protected override byte[] SerializeCatalogEntry(CultSchemaCatalogEntry entry) => + CultDocumentMessagePackSerialization.SerializeCatalogEntry(entry); + + protected override (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) ReadStore(byte[] data) + { + var now = Interlocked.Increment(ref _inside); + int seen; + while ((seen = Volatile.Read(ref _mostInside)) < now && Interlocked.CompareExchange(ref _mostInside, now, seen) != seen) { } + try + { + if (Role == "attach") + { + AttachInside.Set(); + ReleaseAttach.Wait(TimeSpan.FromSeconds(30)); + if (FailAttachPull) + throw new InvalidOperationException("the attach's pull fails"); + } + else if (Role == "call-in-read") + { + CallInside.Set(); + ReleaseCall.Wait(TimeSpan.FromSeconds(30)); + } + else if (Role == "reader") + { + ReaderInside.Set(); + ReleaseReader.Wait(TimeSpan.FromSeconds(30)); + } + else if (Role == "hook") + { + RoleHook?.Invoke(); + } + var store = CultDocumentMessagePackSerialization.ReadStore(data); + CultDocumentMessagePackSerialization.RequireSingleFileFormat(store.Snapshot); + return store; + } + finally + { + Interlocked.Decrement(ref _inside); + } + } + + protected override byte[] SerializePayload(object document, Type documentType) + { + if (Role == "call-in-payload") + { + CallInside.Set(); + ReleaseCall.Wait(TimeSpan.FromSeconds(30)); + } + return CultDocumentMessagePackSerialization.SerializeUntyped(document, documentType, Registry); + } + + protected override object DeserializePayload(Type documentType, byte[] payload) => + CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry); + } + + // Runs body on its own thread under a role, and returns the thread with whatever it threw. + private static (Thread Thread, Func Error) OnThread(string role, Action body) + { + Exception? error = null; + var thread = new Thread(() => + { + RoleStore.Role = role; + try { body(); } + catch (Exception e) { error = e; } + finally { RoleStore.Role = null; } + }) { IsBackground = true }; + thread.Start(); + return (thread, () => error); + } + + private static void WaitUntilBlocked(Thread thread) => + Assert.That(SpinWait.SpinUntil(() => thread.ThreadState.HasFlag(ThreadState.WaitSleepJoin), TimeSpan.FromSeconds(10)), Is.True, "the thread is blocked at a gate"); + + // A cacheless call that arrives during an attach waits for the attach to end, whatever the attach does. The call's thread blocks + // on the store's gate (it does not read Cache first and queue on the cache's gate), so when the pull fails the call runs, and the + // next attach waits for it; when the pull succeeds the call finds the cache and is refused, leaving the cache's registry. + [TestCase(true)] + [TestCase(false)] + public async Task ACallArrivingDuringAnAttachWaitsForTheAttachToEnd(bool theAttachFails) + { + var callRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var registry1 = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var registry2 = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: callRegistry); + var store = new RoleStore(_path) { FailAttachPull = theAttachFails }; + using var cache1 = new CultCache(registry1); + var attach = OnThread("attach", () => cache1.AddBackingStore(store)); + Assert.That(store.AttachInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the attach reached its pull"); + var call = OnThread("call-in-read", () => store.WriteDocument(new CultRecordKey("first"), new IdDeck { Name = "first" }, registry: callRegistry)); + WaitUntilBlocked(call.Thread); + Assert.That(store.CallInside.IsSet, Is.False, "the call waits while the attach is inside the store"); + + store.ReleaseAttach.Set(); + attach.Thread.Join(); + Assert.That(attach.Error(), theAttachFails ? Is.InstanceOf() : Is.Null); + + if (!theAttachFails) + { + call.Thread.Join(); + Assert.That(call.Error(), Is.InstanceOf(), "the attach succeeded, so the call is refused"); + Assert.That(store.CallInside.IsSet, Is.False); + Assert.That(store.Attached, Is.SameAs(registry1), "the refused call left the cache's registry"); + await cache1.AddAsync(new IdDeck { Name = "b" }); + Assert.DoesNotThrow(() => cache1.FlushAllBackingStores()); + return; + } + + Assert.That(store.CallInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the attach failed, so the call runs"); + using var cache2 = new CultCache(registry2); + var secondAttach = Task.Run(() => cache2.AddBackingStore(store)); + Assert.That(secondAttach.Wait(TimeSpan.FromMilliseconds(500)), Is.False, "the next attach waits while the call is inside the store"); + store.ReleaseCall.Set(); + call.Thread.Join(); + await secondAttach; + + Assert.That(call.Error(), Is.Null); + Assert.That(store.Attached, Is.SameAs(registry2), "the call's end did not detach the second cache's registry"); + await cache2.AddAsync(new IdDeck { Name = "c" }); + Assert.DoesNotThrow(() => cache2.FlushAllBackingStores()); + var keys = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.Records.Select(record => record.Key).ToArray(); + Assert.That(keys, Has.Length.EqualTo(3), "the seed, the call's record and the cache's record"); + Assert.That(keys, Does.Contain("a").And.Contain("first")); + } + + // The attach waits for a call that is already inside the store, wherever in the call it is: here inside the payload encoding, + // before the file is read. The call ends on its own registry, then the cache owns the store and its registry. + [Test] + public async Task AnAttachDuringACallEncodingItsPayloadWaitsForIt() + { + var callRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var cacheRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var store = new RoleStore(_path); + var call = OnThread("call-in-payload", () => store.WriteDocument(new CultRecordKey("first"), new IdDeck { Name = "first" }, registry: callRegistry)); + Assert.That(store.CallInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the call reached its payload encoding"); + using var cache = new CultCache(cacheRegistry); + var attach = Task.Run(() => cache.AddBackingStore(store)); + Assert.That(attach.Wait(TimeSpan.FromMilliseconds(500)), Is.False, "the attach waits while a call is inside the store"); + + store.ReleaseCall.Set(); + call.Thread.Join(); + await attach; + + Assert.That(call.Error(), Is.Null); + Assert.That(store.Attached, Is.SameAs(cacheRegistry), "the call's end did not detach the cache's registry"); + await cache.AddAsync(new IdDeck { Name = "b" }); + Assert.DoesNotThrow(() => cache.FlushAllBackingStores()); + } + + // An attach the cache refuses before it pulls leaves the store as it found it: no cache, no registry, and a cacheless call + // still runs. + [Test] + public void ARefusedAttachLeavesTheStoreCacheless() + { + var cacheRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + using var cache = new CultCache(cacheRegistry); + cache.AddBackingStore(new RoleStore(Path.Combine(_directory, "first.cc"))); + var second = new RoleStore(_path); + + Assert.Throws(() => cache.AddBackingStore(second), "a second untyped store is refused"); + Assert.DoesNotThrow(() => second.WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: cacheRegistry)); + + Assert.That(second.Attached, Is.Null); + Assert.That(second.ReadStoreSnapshot()!.Records, Has.Length.EqualTo(1)); + } + + // WriteDocument stores the document as T: the schema id, the id plan, the header and the bytes all come from the one + // declared type, as they did when CultMesh encoded typeof(T). + [Test] + public void WriteDocumentEncodesTheDeclaredTypeNotTheRuntimeType() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(DeclaredBase) }); + + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("d"), new UndeclaredDerived { Name = "n", Extra = "x" }, registry: registry); + + var record = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.Records.Single(); + var asBase = CultDocumentMessagePackSerialization.SerializeUntyped(new DeclaredBase { Name = "n" }, typeof(DeclaredBase), registry); + Assert.That(record.SchemaId, Is.EqualTo(registry.GetRequired().SchemaId)); + Assert.That(record.Payload, Is.EqualTo(asBase), "base bytes under the base schema: one slot, not the derived type's two"); + } + + // The header, like the bytes and the id plan, comes from the declared type: ids that only the derived type holds are not the + // stored document's. + [Test] + public void WriteDocumentMarksTheStoreByTheDeclaredTypeNotTheRuntimeType() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdBase) }); + var derived = new IdDerived { Name = "n", Reels = { new IdReel { Id = "0123456789ab", Label = "r" } } }; + + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("d"), derived, registry: registry); + + var snapshot = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!; + Assert.That(snapshot.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV1), "the stored IdBase holds no element id"); + Assert.That(snapshot.Records.Single().Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(new IdBase { Name = "n" }, typeof(IdBase), registry))); + } + + // The id plan comes from the declared type too: an unset id that only the derived type holds is not the stored document's, so + // it is not minted into the caller's object. + [Test] + public void WriteDocumentMintsIdsOfTheDeclaredTypeNotTheRuntimeType() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdBase) }); + var derived = new IdDerived { Name = "n", Reels = { new IdReel { Label = "r" } } }; + + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("d"), derived, registry: registry); + + Assert.That(derived.Reels.Single().Id, Is.Null.Or.Empty, "an id outside the declared type is left unset"); + } + + // No call leaves its registry on the store: after a write, a refused write and WriteRecord, the store holds none. + [Test] + public void NoCacheCallLeavesARegistryOnTheStore() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One); + var store = new GatedStore(_path); + store.Release.Set(); + + store.WriteDocument(new CultRecordKey("d"), new IdDeck { Name = "d" }, registry: registry); + Assert.That(store.Attached, Is.Null, "after WriteDocument"); + store.WriteRecord(Record("b", One, 2), One, holdsIds: false, registry: registry); + Assert.That(store.Attached, Is.Null, "after WriteRecord"); + + File.WriteAllBytes(_path, new byte[] { 0xC1 }); + Assert.Throws(() => store.WriteDocument(new CultRecordKey("e"), new IdDeck { Name = "e" }, registry: registry)); + Assert.That(store.Attached, Is.Null, "after a refused write"); + } + + // The registry is the call's argument. A typed call on a store instance leaves nothing behind, so a later WriteRecord that + // gives none decides the header as a fresh instance does. + [Test] + public async Task ATypedCallLeavesNoRegistryOnTheStore() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + using (var cache = new CultCache(registry)) + { + cache.AddBackingStore(new SingleFileMessagePackBackingStore(_path)); + await cache.AddAsync(new IdDeck { Name = "deck", Reels = { new IdReel { Label = "reel" } } }); + cache.FlushAllBackingStores(); + } + void Unmark() + { + var written = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path)); + written.FormatVersion = CultPersistedStoreSnapshot.FormatV1; + File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(written)); + } + Unmark(); + var store = new SingleFileMessagePackBackingStore(_path); + + store.WriteDocument(new CultRecordKey("again"), new IdDeck { Name = "again" }, registry: registry); + Assert.That(store.ReadStoreSnapshot()!.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3), "the typed call, given a registry, read the copied ids"); + Unmark(); + store.WriteRecord(Record("other", One, 1), One, holdsIds: false, registry: null); + + Assert.That(store.ReadStoreSnapshot()!.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV1), "a write given no registry decides nothing for copied records"); + } + + private static CultPersistedRecord Record(string key, CultSchemaCatalogEntry entry, params byte[] payload) => + new() { Key = key, SchemaId = entry.SchemaId, StoredAt = "2026-10-22T00:00:00.0000000+00:00", Payload = payload }; + + [Test] + public void WriteRecordKeepsAnotherWritersRecord() + { + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One); + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("b", Two, 2), Two); + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 3), One); + + var snapshot = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!; + + Assert.That(snapshot.Records, Has.Length.EqualTo(2)); + Assert.That(Array.Find(snapshot.Records, record => record.Key == "a")!.Payload, Is.EqualTo(new byte[] { 3 })); + Assert.That(Array.Find(snapshot.Records, record => record.Key == "b")!.Payload, Is.EqualTo(new byte[] { 2 })); + Assert.That(snapshot.SchemaCatalog, Has.Length.EqualTo(2)); + } + + [Test] + public void WriteRecordRefusesOnAStoreAttachedToACache() + { + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One); + var before = File.ReadAllBytes(_path); + var store = new SingleFileMessagePackBackingStore(_path); + using var cache = new CultCache(); + cache.AddBackingStore(store); + + var error = Assert.Throws(() => store.WriteRecord(Record("b", Two, 2), Two))!; + + Assert.That(error.Message, Does.Contain("write through the cache")); + Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before)); + } + + [Test] + public void ReadStoreSnapshotOfNothingIsNull() + { + Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot(), Is.Null); + } + + [Test] + public void ReadStoreSnapshotRefusesAV4Stamp() + { + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path)); + snapshot.FormatVersion = "cultcache.store.v4"; + File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + + var error = Assert.Throws(() => new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot())!; + + Assert.That(error.Message, Does.Contain("cultcache.store.v4")); + } + + [Test] + public void WriteRecordWithIdsMarksV3() + { + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One); + Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.FormatVersion, Is.Not.EqualTo(CultPersistedStoreSnapshot.FormatV3)); + + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("b", Two, 2), Two, holdsIds: true); + + Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); + } + + [Test] + public void WriteRecordRefusesNullArguments() + { + var store = new SingleFileMessagePackBackingStore(_path); + + Assert.Throws(() => store.WriteRecord(null!, One)); + Assert.Throws(() => store.WriteRecord(Record("a", One, 1), null!)); + Assert.That(File.Exists(_path), Is.False); + } + + [Test] + public void ARefusedWriteDocumentOnAnAttachedStoreNeverReachesTheRegistryItWasGiven() + { + var store = new SingleFileMessagePackBackingStore(_path); + using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) })); + cache.AddBackingStore(store); + var elsewhere = CultDocumentRegistry.ForTypes(new[] { typeof(StoreRoutingTests.RoutingNote) }); + + var error = Assert.Throws(() => store.WriteDocument(new CultRecordKey("deck"), new IdDeck { Name = "d" }, registry: elsewhere))!; + + Assert.That(error.Message, Does.Contain("write through the cache")); + } + + [Test] + public void WriteDocumentRefusesANullDocumentAndDecodeDocumentANullPayload() + { + var store = new SingleFileMessagePackBackingStore(_path); + Assert.Throws(() => store.WriteDocument(new CultRecordKey("deck"), null!)); + Assert.Throws(() => Codec().DecodeDocument(new CultRecordKey("deck"), null!)); + } + + [Test] + public void WriteDocumentStoresTheGivenStoredAtAndOtherwiseNow() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var store = new SingleFileMessagePackBackingStore(_path); + store.WriteDocument(new CultRecordKey("fixed"), new IdDeck { Name = "a" }, "2026-01-02T03:04:05.0000000+00:00", registry); + store.WriteDocument(new CultRecordKey("now"), new IdDeck { Name = "b" }, null, registry); + store.WriteDocument(new CultRecordKey("blank"), new IdDeck { Name = "c" }, " ", registry); + + var records = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.Records.ToDictionary(record => record.Key); + + Assert.That(records["fixed"].StoredAt, Is.EqualTo("2026-01-02T03:04:05.0000000+00:00")); + Assert.That(records["now"].StoredAt, Does.Match(@"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{7}\+00:00$"), "round-trip format"); + Assert.That(DateTimeOffset.Parse(records["now"].StoredAt), Is.EqualTo(DateTimeOffset.UtcNow).Within(TimeSpan.FromMinutes(5))); + Assert.That(DateTimeOffset.Parse(records["blank"].StoredAt), Is.EqualTo(DateTimeOffset.UtcNow).Within(TimeSpan.FromMinutes(5))); + } + + [Test] + public void WriteDocumentMintsRandomIdsAndDecodeDocumentMintsTheSameIdsEveryTime() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + string Written(string name) + { + var path = Path.Combine(_directory, name); + var deck = new IdDeck { Name = "deck", Reels = { new IdReel { Label = "reel" } } }; + new SingleFileMessagePackBackingStore(path).WriteDocument(new CultRecordKey("deck"), deck, registry: registry); + return deck.Reels[0].Id; + } + Assert.That(Written("one.cc"), Is.Not.EqualTo(Written("two.cc")), "a write mints random ids"); + + var unset = CultDocumentMessagePackSerialization.SerializeUntyped(new IdDeck { Name = "deck", Reels = { new IdReel { Label = "reel" } } }, typeof(IdDeck), registry); + var first = Codec(registry).DecodeDocument(new CultRecordKey("deck"), unset).Reels[0].Id; + var second = Codec(registry).DecodeDocument(new CultRecordKey("deck"), unset).Reels[0].Id; + Assert.That(first, Does.Match("^[0-9a-f]{12}$")); + Assert.That(second, Is.EqualTo(first), "a load mints from the key and path, so every reader mints the same id"); + } + + // The write is under the store's lock and waits for it: a write that finds the lock held lands after it is released, and + // never without it. + [Test] + public async Task WriteRecordWaitsForTheStoreLock() + { + var write = default(Task); + using (new FileStream(_path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)) + { + write = Task.Run(() => new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One)); + await Task.Delay(500); + Assert.That(write.IsCompleted, Is.False, "the write waits while another holder has the lock"); + Assert.That(File.Exists(_path), Is.False); + } + + await write; + + Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.Records, Has.Length.EqualTo(1)); + } + + // The registry the payload was encoded with lets the write decide the header for the records it copies: another runtime may + // have written ids under an unmarked header, and a write that can read them marks the store. Without a registry it decides + // nothing for them. + [TestCase(true, CultPersistedStoreSnapshot.FormatV3)] + [TestCase(false, CultPersistedStoreSnapshot.FormatV1)] + public async Task WriteRecordGivenARegistryMarksAStoreWhoseCopiedRecordHoldsIds(bool withRegistry, string expected) + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + using (var cache = new CultCache(registry)) + { + cache.AddBackingStore(new SingleFileMessagePackBackingStore(_path)); + await cache.AddAsync(new IdDeck { Name = "deck", Reels = { new IdReel { Label = "reel" } } }); + cache.FlushAllBackingStores(); + } + + var written = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path)); + Assert.That(written.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3), "the cache marked the store it wrote ids into"); + written.FormatVersion = CultPersistedStoreSnapshot.FormatV1; + File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(written)); + + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("other", One, 1), One, holdsIds: false, registry: withRegistry ? registry : null); + + Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.FormatVersion, Is.EqualTo(expected)); + } + + [Test] + public void WriteDocumentMintsIdsMarksV3AndKeepsTheOtherRecord() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("other", One, 1), One); + var deck = new IdDeck { Name = "deck", Reels = { new IdReel { Label = "reel" } } }; + + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("deck"), deck, registry: registry); + + var snapshot = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!; + Assert.That(snapshot.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); + Assert.That(snapshot.Records.Select(record => record.Key), Is.EquivalentTo(new[] { "other", "deck" })); + Assert.That(deck.Reels[0].Id, Does.Match("^[0-9a-f]{12}$"), "the id the file holds was minted on the document"); + var read = Codec(registry).DecodeDocument(new CultRecordKey("deck"), snapshot.Records.Single(record => record.Key == "deck").Payload); + Assert.That(read.Reels[0].Id, Is.EqualTo(deck.Reels[0].Id)); + } + + [Test] + public void WriteDocumentGivesMintedIdsBackWhenTheWriteIsRefused() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + File.WriteAllBytes(_path, new byte[] { 0xC1 }); + var deck = new IdDeck { Name = "deck", Reels = { new IdReel { Label = "reel" } } }; + + Assert.Throws(() => + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("deck"), deck, registry: registry)); + + Assert.That(deck.Reels[0].Id, Is.Null.Or.Empty, "a refused write leaves the document as it was"); + } + + [Test] + public void WriteDocumentRefusesOnAStoreAttachedToACache() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var store = new SingleFileMessagePackBackingStore(_path); + using var cache = new CultCache(registry); + cache.AddBackingStore(store); + + Assert.That(Assert.Throws(() => store.WriteDocument(new CultRecordKey("deck"), new IdDeck { Name = "d" }))!.Message, Does.Contain("write through the cache")); + Assert.That(File.Exists(_path), Is.False); + } + + // A document whose getter runs user code while MessagePack encodes it, under the store's gate. + [CultDocument("tests.cacheless.tap", "tests.cacheless.tap.v1")] + [MessagePackObject] + public class TapDoc + { + [ThreadStatic] public static Action? Hook; + [Key(0)] [CultName] public string Name { get; set; } = ""; + [Key(1)] public string Tap { get { var hook = Hook; Hook = null; hook?.Invoke(); return "t"; } set { } } + [Key(2)] public System.Collections.Generic.List Reels { get; set; } = new(); + } + + private static void Finishes(Thread thread, string what) => + Assert.That(thread.Join(TimeSpan.FromSeconds(10)), Is.True, what + " finished (a deadlock fails here, it does not hang)"); + + private static bool Refused(Exception? error) + { + for (var inner = error; inner != null; inner = inner.InnerException) + if (inner is InvalidOperationException) + return true; + return false; + } + + // While a thread holds a store's gate (a cacheless call, an attach's pull) it enters no cache it does not already hold, reads + // included, and no second store. The call is refused where the getter ran, the file is not written, and minted ids are given back. + [Test] + public void AGetterThatEntersACacheDuringACachelessWriteIsRefused() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) }); + using var unrelated = new CultCache(registry); + var doc = new TapDoc { Name = "d", Reels = { new IdReel() } }; + TapDoc.Hook = () => unrelated.Get(new CultRecordKey("x")); + + var error = Assert.Catch(() => new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("d"), doc, registry: registry)); + + Assert.That(Refused(error), Is.True, "the getter's read of a cache was refused"); + Assert.That(File.Exists(_path), Is.False); + Assert.That(doc.Reels[0].Id, Is.Null.Or.Empty, "the minted id was given back"); + } + + [Test] + public void AGetterThatWritesACacheDuringACachelessWriteIsRefused() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) }); + using var unrelated = new CultCache(registry); + TapDoc.Hook = () => _ = unrelated.AddAsync(new TapDoc { Name = "other" }); + + var error = Assert.Catch(() => new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("d"), new TapDoc { Name = "d" }, registry: registry)); + + Assert.That(Refused(error), Is.True, "the getter's write to a cache was refused"); + Assert.That(File.Exists(_path), Is.False); + Assert.That(unrelated.AllEntries, Is.Empty, "the refused write reached nothing"); + } + + // Soul P4: the writer holds the store's gate and its getter reads the cache that another thread is attaching the same store to. + // The attach holds the cache's gate and waits for the store's. The getter is refused instead of waiting on the cache's gate. + [Test] + public async Task AGetterThatEntersTheAttachingCacheIsRefusedNotDeadlocked() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) }); + var store = new RoleStore(_path); + var cache = new CultCache(registry); + var inside = new ManualResetEventSlim(); + var attachWaits = new ManualResetEventSlim(); + var write = OnThread("writer", () => + { + TapDoc.Hook = () => + { + inside.Set(); + attachWaits.Wait(TimeSpan.FromSeconds(30)); + _ = cache.Get(new CultRecordKey("anything")); + }; + store.WriteDocument(new CultRecordKey("d"), new TapDoc { Name = "d" }, registry: registry); + }); + Assert.That(inside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the getter ran under the store's gate"); + var attach = OnThread("attach-after", () => cache.AddBackingStore(store)); + WaitUntilBlocked(attach.Thread); + attachWaits.Set(); + + Finishes(write.Thread, "the writer"); + Finishes(attach.Thread, "the attach"); + Assert.That(Refused(write.Error()), Is.True, "the write was refused"); + Assert.That(attach.Error(), Is.Null); + await cache.AddAsync(new TapDoc { Name = "after" }); + Assert.DoesNotThrow(() => cache.FlushAllBackingStores()); + // Disposed only after every join passed: a deadlocked gate would make Dispose wait for it. + cache.Dispose(); + } + + // The attach holds the store's gate while it pulls; user code in the pull may not enter a second store. + [Test] + public void AnAttachPullThatEntersAnotherStoreIsRefused() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry); + var store = new RoleStore(_path); + var second = new SingleFileMessagePackBackingStore(Path.Combine(_directory, "second.cc")); + var cache = new CultCache(registry); + var attach = OnThread("hook", () => + { + RoleStore.RoleHook = () => second.WriteDocument(new CultRecordKey("b"), new IdDeck { Name = "b" }, registry: registry); + try { cache.AddBackingStore(store); } + finally { RoleStore.RoleHook = null; } + }); + Finishes(attach.Thread, "the attach"); + + Assert.That(Refused(attach.Error()), Is.True, "the pull's write to a second store was refused"); + var refusal = attach.Error()!; + while (refusal is not InvalidOperationException && refusal.InnerException != null) + refusal = refusal.InnerException; + while (refusal.InnerException is InvalidOperationException deeper) + refusal = deeper; + Assert.That(refusal.Message, Does.StartWith("A store's gate was entered inside another store's hold")); + Assert.That(File.Exists(Path.Combine(_directory, "second.cc")), Is.False); + Assert.That(cache.BackingStores, Is.Empty, "the failed attach left nothing behind"); + Assert.DoesNotThrow(() => store.WriteDocument(new CultRecordKey("c"), new IdDeck { Name = "c" }, registry: registry), "the store is cacheless after"); + // Disposed only after every join passed: a deadlocked gate would make Dispose wait for it. + cache.Dispose(); + } + + // Soul P4's window: a reader queued on a failed attach's cache gate must retry under the store's gate. Whichever of the reader and + // the cacheless writer queued behind the attach gets the store first is alone inside it, and a second cache's attach waits for it. + [TestCase("snapshot")] + [TestCase("pullall")] + public async Task AReaderQueuedOnAFailedAttachRunsUnderTheStoresGate(string how) + { + var callRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var registry1 = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var registry2 = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: callRegistry); + var store = new RoleStore(_path) { FailAttachPull = true }; + var cache1 = new CultCache(registry1); + var cache2 = new CultCache(registry2); + var attach1 = OnThread("attach", () => cache1.AddBackingStore(store)); + Assert.That(store.AttachInside.Wait(TimeSpan.FromSeconds(10)), Is.True); + CultPersistedStoreSnapshot? snapshot = null; + var reader = OnThread("reader", () => { if (how == "snapshot") snapshot = store.ReadStoreSnapshot(); else store.PullAll(); }); + WaitUntilBlocked(reader.Thread); + var writer = OnThread("call-in-read", () => store.WriteDocument(new CultRecordKey("first"), new IdDeck { Name = "first" }, registry: callRegistry)); + WaitUntilBlocked(writer.Thread); + + store.ReleaseAttach.Set(); + Finishes(attach1.Thread, "the failed attach"); + Assert.That(SpinWait.SpinUntil(() => store.ReaderInside.IsSet || store.CallInside.IsSet, TimeSpan.FromSeconds(10)), Is.True, "one of them is inside the store"); + var attach2 = OnThread("attach2", () => cache2.AddBackingStore(store)); + WaitUntilBlocked(attach2.Thread); + Assert.That(attach2.Thread.IsAlive, Is.True, "the second cache's attach waits while a reader or a call is inside the store"); + + store.ReleaseReader.Set(); + store.ReleaseCall.Set(); + Finishes(reader.Thread, "the reader"); + Finishes(writer.Thread, "the writer"); + Finishes(attach2.Thread, "the second attach"); + Assert.That(store.MostInside, Is.EqualTo(1), "never two threads inside the store's read at once"); + // A PullAll on a cacheless store with no registry (the cacheless writer that ran before it detached its own) is a usage error; + // being refused for entering a cache's hold is not. + Assert.That(reader.Error()?.Message, Is.Null.Or.Contain("not attached to a CultDocumentRegistry"), "the reader was not refused"); + if (how == "snapshot") + Assert.That(snapshot?.Records.Select(record => record.Key), Does.Contain("a"), "the retried read ran and returned the file's snapshot"); + Assert.That(attach2.Error(), Is.Null); + await cache2.AddAsync(new IdDeck { Name = "b" }); + Assert.DoesNotThrow(() => cache2.FlushAllBackingStores()); + // Disposed only after every join passed: a deadlocked gate would make Dispose wait for it. + cache1.Dispose(); + cache2.Dispose(); + } + + // After a failed attach left the store cacheless, the next cache attaches it, and a PullAll that queues on that attach (it guesses + // the second cache from Cache) runs under the second cache's gate once the attach ends: no refusal, and the cache holds the records. + [Test] + public void AFailedAttachThenPullAllLoadsIntoTheSecondCache() + { + var callRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: callRegistry); + var store = new RoleStore(_path) { FailAttachPull = true }; + var cache1 = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) })); + var cache2 = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) })); + var attach1 = OnThread("attach", () => cache1.AddBackingStore(store)); + Assert.That(store.AttachInside.Wait(TimeSpan.FromSeconds(10)), Is.True); + store.ReleaseAttach.Set(); + Finishes(attach1.Thread, "the failed attach"); + Assert.That(Refused(attach1.Error()), Is.True); + + store.AttachInside.Reset(); + store.ReleaseAttach.Reset(); + store.FailAttachPull = false; + var attach2 = OnThread("attach", () => cache2.AddBackingStore(store)); + Assert.That(store.AttachInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the second attach is inside its pull"); + var pull = OnThread("pull", () => store.PullAll()); + WaitUntilBlocked(pull.Thread); + + store.ReleaseAttach.Set(); + Finishes(attach2.Thread, "the second attach"); + Finishes(pull.Thread, "the queued PullAll"); + Assert.That(attach2.Error(), Is.Null); + Assert.That(pull.Error(), Is.Null, "the queued PullAll was not refused"); + Assert.That(cache2.Get(new CultRecordKey("a")), Is.Not.Null, "the second cache holds the file's records"); + // Disposed only after every join passed: a deadlocked gate would make Dispose wait for it. + cache1.Dispose(); + cache2.Dispose(); + } + + // A reader that guessed "no cache" and queued on the store's gate behind an attach finds the cache when it gets the gate, and runs + // under the cache's gate: while it is inside the store, the cache's own readers wait. + [Test] + public void AReaderThatQueuedBeforeAnAttachRunsUnderTheCacheGateAfterIt() + { + var callRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: callRegistry); + var store = new RoleStore(_path); + var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) })); + var call = OnThread("call-in-read", () => store.WriteDocument(new CultRecordKey("first"), new IdDeck { Name = "first" }, registry: callRegistry)); + Assert.That(store.CallInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the call holds the store's gate"); + var attach = OnThread("attach", () => cache.AddBackingStore(store)); + WaitUntilBlocked(attach.Thread); + var reader = OnThread("reader", () => store.ReadStoreSnapshot()); + WaitUntilBlocked(reader.Thread); + + store.ReleaseCall.Set(); + Assert.That(store.AttachInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the attach queued first and got the store's gate first"); + store.ReleaseAttach.Set(); + Assert.That(store.ReaderInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the reader retried and is inside the store"); + var probe = OnThread("probe", () => cache.Get(new CultRecordKey("a"))); + Assert.That(SpinWait.SpinUntil(() => !probe.Thread.IsAlive || probe.Thread.ThreadState.HasFlag(ThreadState.WaitSleepJoin), TimeSpan.FromSeconds(10)), Is.True); + Assert.That(probe.Thread.IsAlive, Is.True, "the cache's own reader waits while the retried reader is inside the store: it holds the cache's gate"); + + store.ReleaseReader.Set(); + Finishes(call.Thread, "the call"); + Finishes(attach.Thread, "the attach"); + Finishes(reader.Thread, "the reader"); + Finishes(probe.Thread, "the cache's reader"); + Assert.That(attach.Error(), Is.Null); + Assert.That(reader.Error(), Is.Null); + cache.Dispose(); + } + + // The gate refuses a cache the thread does not hold; it does not refuse the cache whose gate the thread already holds. + [Test] + public void AnAttachPullMayReadTheCacheItIsAttachingTo() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry); + var store = new RoleStore(_path); + var cache = new CultCache(registry); + var attach = OnThread("hook", () => + { + RoleStore.RoleHook = () => cache.Get(new CultRecordKey("a")); + try { cache.AddBackingStore(store); } + finally { RoleStore.RoleHook = null; } + }); + Finishes(attach.Thread, "the attach"); + + Assert.That(attach.Error(), Is.Null); + Assert.That(cache.BackingStores, Has.Count.EqualTo(1)); + cache.Dispose(); + } + + // A store call made from user code the same store's call is running is the same thread's own hold, not a second store. + [Test] + public void UserCodeInACachelessCallMayCallItsOwnStore() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) }); + var store = new SingleFileMessagePackBackingStore(_path); + var descriptor = registry.GetRequired(); + TapDoc.Hook = () => store.Delete(new CultStoredDocument(new CultRecordKey("x"), DateTimeOffset.UtcNow.ToString("O"), descriptor, new TapDoc { Name = "x" })); + + Assert.DoesNotThrow(() => store.WriteDocument(new CultRecordKey("d"), new TapDoc { Name = "d" }, registry: registry)); + } + + // A failed attach leaves the store as it found it: no cache and no registry. A cacheless PullAll after it is then a usage error + // that loads nothing, so the next cache to attach admits the file's records, and creating what it cannot find does not duplicate. + [Test] + public void AFailedAttachLeavesNoRegistryAndTheNextCacheSeesTheFilesRecords() + { + var callRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: callRegistry); + var store = new RoleStore(_path) { FailAttachPull = true }; + store.ReleaseAttach.Set(); + var cache1 = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) })); + var attach1 = OnThread("attach", () => cache1.AddBackingStore(store)); + Finishes(attach1.Thread, "the failed attach"); + Assert.That(Refused(attach1.Error()), Is.True); + Assert.That(store.IsAttachedToACache, Is.False); + Assert.That(store.Attached, Is.Null, "the failed attach took its registry back"); + + Assert.That(Assert.Throws(() => store.PullAll())!.Message, Does.Contain("not attached to a CultDocumentRegistry")); + store.FailAttachPull = false; + var cache2 = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) })); + var attach2 = OnThread("attach", () => cache2.AddBackingStore(store)); + Finishes(attach2.Thread, "the second attach"); + Assert.That(attach2.Error(), Is.Null); + + Assert.That(cache2.Get(new CultRecordKey("a")), Is.Not.Null, "the second cache holds the file's record"); + Assert.That(cache2.GetAll().Count(), Is.EqualTo(1)); + cache2.FlushAllBackingStores(); + Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.Records, Has.Length.EqualTo(1)); + cache1.Dispose(); + cache2.Dispose(); + } + + // Soul P4's load: cacheless writers and snapshot readers race an attach that fails (odd rounds) or succeeds. Whoever ends up + // owning the store owns its registry, and its cache writes and flushes. A call split into a refusal hold and a body hold fails here: + // a body that runs after the attach took the store leaves the call's registry (or none) on the cache's store. + [Test] + public async Task CachelessCallsRacingAnAttachNeverLeaveTheCachesStoreWithoutItsRegistry() + { + var bad = new System.Collections.Generic.List(); + for (var round = 0; round < 60; round++) + { + var path = Path.Combine(_directory, $"load{round}.cc"); + var mine = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var reg1 = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var reg2 = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var attachFails = round % 2 == 1; + var store = new RoleStore(path) { FailAttachPull = attachFails }; + store.ReleaseAttach.Set(); + store.WriteDocument(new CultRecordKey("seed"), new IdDeck { Name = "seed" }, registry: mine); + using var cache1 = new CultCache(reg1); + using var cache2 = new CultCache(reg2); + var go = new ManualResetEventSlim(); + var tasks = new System.Collections.Generic.List(); + for (var writer = 0; writer < 4; writer++) + { + var n = writer; + tasks.Add(Task.Run(() => + { + go.Wait(); + for (var k = 0; k < 8; k++) + { + try { store.WriteDocument(new CultRecordKey($"w{n}-{k}"), new IdDeck { Name = $"w{n}-{k}" }, registry: mine); } + catch (InvalidOperationException) { } + } + })); + } + for (var reader = 0; reader < 2; reader++) + tasks.Add(Task.Run(() => + { + go.Wait(); + for (var k = 0; k < 12; k++) + store.ReadStoreSnapshot(); + })); + tasks.Add(Task.Run(() => + { + go.Wait(); + Thread.SpinWait(1500 * round); + RoleStore.Role = "attach"; + try { cache1.AddBackingStore(store); } + catch (InvalidOperationException) { } + finally { RoleStore.Role = null; } + if (!store.IsAttachedToACache) + cache2.AddBackingStore(store); + })); + go.Set(); + Assert.That(Task.WhenAll(tasks).Wait(TimeSpan.FromSeconds(60)), Is.True, "the racing calls and the attach finished (a deadlock fails here, it does not hang)"); + var owner = attachFails ? cache2 : cache1; + var ownerRegistry = attachFails ? reg2 : reg1; + if (!ReferenceEquals(store.Attached, ownerRegistry)) + bad.Add($"round {round}: the owning cache's registry is not on its store"); + else + { + await owner.AddAsync(new IdDeck { Name = "after" }); + owner.FlushAllBackingStores(); + } + } + + Assert.That(bad, Is.Empty); + } + + // Every way into a cache's gate refuses a thread that holds a store's gate and not that cache's. + [TestCase("Get")] + [TestCase("GetWithSequence")] + [TestCase("GetStored")] + [TestCase("GetAll")] + [TestCase("IsDirty")] + [TestCase("BackingStores")] + [TestCase("AllEntries")] + [TestCase("AllStoredDocuments")] + [TestCase("TryGetHandle")] + public void EveryCacheGateEntryRefusesAGetterRunningUnderAStoresGate(string entry) + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) }); + using var unrelated = new CultCache(registry); + var key = new CultRecordKey("x"); + TapDoc.Hook = () => + { + switch (entry) + { + case "Get": unrelated.Get(key); break; + case "GetWithSequence": unrelated.GetWithSequence(key); break; + case "GetStored": unrelated.GetStored(key); break; + case "GetAll": unrelated.GetAll(); break; + case "IsDirty": _ = unrelated.IsDirty; break; + case "BackingStores": _ = unrelated.BackingStores; break; + case "AllEntries": _ = unrelated.AllEntries; break; + case "AllStoredDocuments": _ = unrelated.AllStoredDocuments; break; + default: unrelated.TryGetHandle(new TapDoc()); break; + } + }; + + var error = Assert.Catch(() => new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("d"), new TapDoc { Name = "d" }, registry: registry)); + + Assert.That(Refused(error), Is.True, entry + " was refused under the store's gate"); + Assert.That(File.Exists(_path), Is.False); + } + + // Calling its own store from user code does not end the store's hold for the rest of the call: a cache entered afterwards is + // still refused. + [Test] + public void UserCodeThatCalledItsOwnStoreStillCannotEnterACacheAfterwards() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) }); + var store = new SingleFileMessagePackBackingStore(_path); + var descriptor = registry.GetRequired(); + using var unrelated = new CultCache(registry); + TapDoc.Hook = () => + { + store.Delete(new CultStoredDocument(new CultRecordKey("x"), DateTimeOffset.UtcNow.ToString("O"), descriptor, new TapDoc { Name = "x" })); + unrelated.Get(new CultRecordKey("x")); + }; + + var error = Assert.Catch(() => store.WriteDocument(new CultRecordKey("d"), new TapDoc { Name = "d" }, registry: registry)); + + Assert.That(Refused(error), Is.True, "the read after the own-store call was refused"); + Assert.That(File.Exists(_path), Is.False); + } + + // The attach lends the pull its registry and publishes the cache before the pull: while the pull runs the store holds the loan and + // is attached, so every entry point agrees on what the store is for the whole attach. + [Test] + public void AnAttachLendsTheRegistryAndPublishesTheCacheForThePull() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry); + var codec = Codec(registry); + var store = new RoleStore(_path); + using var cache = new CultCache(registry, codec); + bool cacheDuringPull = true; + CultDocumentRegistry? registryDuringPull = null; + var attach = OnThread("hook", () => + { + RoleStore.RoleHook = () => + { + cacheDuringPull = store.IsAttachedToACache; + registryDuringPull = store.Attached; + }; + try { cache.AddBackingStore(store); } + finally { RoleStore.RoleHook = null; } + }); + Finishes(attach.Thread, "the attach"); + + Assert.That(attach.Error(), Is.Null); + Assert.That(cacheDuringPull, Is.True, "the store is attached for the pull"); + Assert.That(registryDuringPull, Is.SameAs(registry)); + Assert.That(store.IsAttachedToACache, Is.True, "still attached once the pull succeeded"); + } + + // A failed attach takes back both the registry and the cache, in its own hold of the store. + [Test] + public void AFailedAttachTakesBackTheRegistryAndTheCache() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry); + var store = new RoleStore(_path) { FailAttachPull = true }; + store.ReleaseAttach.Set(); + using var cache = new CultCache(registry, Codec(registry)); + var attach = OnThread("attach", () => cache.AddBackingStore(store)); + Finishes(attach.Thread, "the failed attach"); + + Assert.That(attach.Error(), Is.Not.Null); + Assert.That(store.IsAttachedToACache, Is.False); + Assert.That(store.Attached, Is.Null, "the registry was taken back"); + } + + [Test] + public void AttachingNoStoreIsRefusedBeforeAnythingIsHeld() + { + using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) })); + + Assert.Throws(() => cache.AddBackingStore(null!)); + } + + private const string ReentryRefusal = "A cacheless write was made inside a call that already holds this store"; + + private const string AttachedRefusal = "The store is attached to a cache, write through the cache."; + + private const string JournalRefusal = "A journal entered the cache it journals"; + + // Every store entry point, called by a journal the attach's pull runs on the attaching thread, through a real attach. The store is + // attached for its whole attach, so each call is refused as it is after the attach, and nothing reaches the file or the store's + // staging that the cache never admitted: after a flush the attaching cache and a fresh cache over the file hold the same keys. + [TestCase("single", "pullall")] + [TestCase("single", "push")] + [TestCase("single", "delete")] + [TestCase("single", "pushall")] + [TestCase("single", "push+pushall")] + [TestCase("single", "delete+pushall")] + [TestCase("single", "commit")] + [TestCase("single", "judge")] + [TestCase("single", "stage")] + [TestCase("single", "snapshot")] + [TestCase("single", "writedoc")] + [TestCase("single", "writerecord")] + [TestCase("directory", "pullall")] + [TestCase("directory", "push")] + [TestCase("directory", "delete")] + [TestCase("directory", "pushall")] + [TestCase("directory", "push+pushall")] + [TestCase("directory", "delete+pushall")] + [TestCase("directory", "commit")] + [TestCase("directory", "judge")] + [TestCase("directory", "stage")] + public async Task AJournalOnTheAttachingThreadCannotReachItsStore(string kind, string op) + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var descriptor = registry.GetRequired(typeof(IdDeck)); + var path = kind == "single" ? _path : Path.Combine(_directory, "dir"); + CacheBackingStore Open() => kind == "single" + ? new SingleFileMessagePackBackingStore(path) + : new DirectoryMessagePackBackingStore(path); + + using (var seed = new CultCache(registry, Codec(registry))) + { + seed.AddBackingStore(Open()); + await seed.UpsertAsync(typeof(IdDeck), new IdDeck { Name = "a" }, new CultRecordKey("a")); + seed.FlushAllBackingStores(); + } + CultPersistedRecord? recordA = null; + CultSchemaCatalogEntry? entryA = null; + if (kind == "single") + { + var snapshot = new SingleFileMessagePackBackingStore(path).ReadStoreSnapshot()!; + recordA = snapshot.Records.First(); + entryA = snapshot.SchemaCatalog.First(); + } + + var store = Open(); + using var cache = new CultCache(registry, Codec(registry)); + var docB = new CultStoredDocument(new CultRecordKey("b"), DateTimeOffset.UtcNow.ToString("O"), descriptor, new IdDeck { Name = "b" }); + var journalRuns = 0; + Exception? journalError = null; + using var journal = cache.AddJournal(_ => + { + if (journalRuns++ > 0) + return; + try + { + switch (op) + { + case "pullall": store.PullAll(); break; + case "push": store.Push(docB); break; + case "delete": store.Delete(cache.GetStored(new CultRecordKey("a"))!); break; + case "pushall": store.PushAll(); break; + case "push+pushall": store.Push(docB); store.PushAll(); break; + case "delete+pushall": store.Delete(cache.GetStored(new CultRecordKey("a"))!); store.PushAll(); break; + case "commit": + store.CommitBatch(new CultCommitRequest(new[] { docB }, Array.Empty(), Array.Empty(), + Array.Empty<(CultRecordKey, string?)>(), false), wait: true); + break; + case "judge": store.JudgeChecks(new[] { "a" }, wait: true); break; + case "stage": store.RefuseRemovalOfUndeclared(new[] { "zz" }); break; + case "snapshot": ((SingleFileBackingStore)store).ReadStoreSnapshot(); break; + case "writedoc": + ((SingleFileBackingStore)store).WriteDocument(new CultRecordKey("b"), new IdDeck { Name = "b" }, registry: registry); + break; + case "writerecord": ((SingleFileBackingStore)store).WriteRecord(recordA!, entryA!, registry: registry); break; + default: throw new ArgumentException(op); + } + } + catch (Exception error) { journalError = error; } + }); + + cache.AddBackingStore(store); + + Assert.That(journalRuns, Is.GreaterThan(0), "the pull's admission ran the journal"); + Assert.That(journalError, Is.InstanceOf()); + Assert.That(journalError!.Message, Does.StartWith(op is "writedoc" or "writerecord" ? AttachedRefusal : JournalRefusal)); + Assert.That(store.IsDirty, Is.False, "nothing the cache never admitted is staged"); + + await cache.UpsertAsync(typeof(IdDeck), new IdDeck { Name = "later" }, new CultRecordKey("later")); + Assert.DoesNotThrow(() => cache.FlushAllBackingStores()); + Assert.DoesNotThrow(() => store.PullAll(), "the refused call did not take back the attach's registry"); + using var fresh = new CultCache(registry, Codec(registry)); + fresh.AddBackingStore(Open()); + string[] Keys(CultCache from) => from.AllStoredDocuments.Select(document => document.Key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray(); + Assert.That(Keys(cache), Does.Contain("a").And.Contain("later").And.Not.Contain("b")); + Assert.That(Keys(cache), Is.EqualTo(Keys(fresh)), "the attaching cache and a fresh cache over the same file agree"); + } + + // A cacheless write made inside another store's hold is refused as entering a second store, not as re-entering its own: the + // second-store rule is checked before the same-store one, so the message sends the caller to the right place. + [Test] + public void ACachelessWriteInsideAnotherStoresHoldGetsTheSecondStoreRefusal() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) }); + var pathB = Path.Combine(_directory, "second.cc"); + var storeA = new RoleStore(_path); + var storeB = new RoleStore(pathB); + TapDoc.Hook = () => storeB.WriteDocument(new CultRecordKey("inner"), new TapDoc { Name = "inner" }, registry: registry); + + var outer = Assert.Catch(() => storeA.WriteDocument(new CultRecordKey("outer"), new TapDoc { Name = "outer" }, registry: registry)); + + var refusal = outer!; + while (refusal is not InvalidOperationException && refusal.InnerException != null) + refusal = refusal.InnerException; + while (refusal.InnerException is InvalidOperationException deeper) + refusal = deeper; + Assert.That(refusal, Is.InstanceOf()); + Assert.That(refusal.Message, Does.StartWith("A store's gate was entered inside another store's hold")); + Assert.That(File.Exists(_path), Is.False); + Assert.That(File.Exists(pathB), Is.False); + } + + // A cacheless write owns its store outright: user code another cacheless write of the same store runs may not make a second. + [Test] + public void ACachelessWriteInsideACachelessWriteOfTheSameStoreIsRefused() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) }); + var store = new RoleStore(_path); + TapDoc.Hook = () => store.WriteDocument(new CultRecordKey("inner"), new TapDoc { Name = "inner" }, registry: registry); + + var outer = Assert.Catch(() => store.WriteDocument(new CultRecordKey("outer"), new TapDoc { Name = "outer" }, registry: registry)); + + Assert.That(Refused(outer), Is.True, "the outer write failed with the inner call's refusal"); + var refusal = outer!; + while (refusal is not InvalidOperationException && refusal.InnerException != null) + refusal = refusal.InnerException; + Assert.That(refusal.Message, Does.StartWith(ReentryRefusal)); + Assert.That(File.Exists(_path), Is.False); + Assert.That(store.Attached, Is.Null); + } + + // The bound on a call's gates is structural: an attach publishes Cache and takes it back inside one hold of the store's gate, so a + // cache read under that gate is settled. This test publishes and retracts in two holds, the only way to miss; the reader, which + // settled on the cache and then finds it gone under that cache's gate, throws. + [Test] + public void ACallWhoseSettledCacheChangesThrows() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry); + var store = new RoleStore(_path); + using var cache = new CultCache(registry); + var holding = new ManualResetEventSlim(); + var publish = new ManualResetEventSlim(); + var retract = new ManualResetEventSlim(); + var tamper = OnThread("tamper", () => cache.Held(() => + { + store.UnderStoreGate(() => + { + holding.Set(); + publish.Wait(TimeSpan.FromSeconds(30)); + store.Cache = cache; + return true; + }); + retract.Wait(TimeSpan.FromSeconds(30)); + store.UnderStoreGate(() => { store.Cache = null; return true; }); + return true; + })); + Assert.That(holding.Wait(TimeSpan.FromSeconds(10)), Is.True); + Exception? readerError = null; + var reader = OnThread("reader", () => + { + try { store.ReadStoreSnapshot(); } + catch (Exception e) { readerError = e; throw; } + }); + WaitUntilBlocked(reader.Thread); + publish.Set(); + Thread.Sleep(500); + retract.Set(); + Finishes(tamper.Thread, "the tampering thread"); + Finishes(reader.Thread, "the reader"); + + Assert.That(tamper.Error(), Is.Null); + Assert.That(readerError, Is.InstanceOf().With.Message.StartWith("A store's settled cache changed")); + } + + // Failing attaches over and over, from one, two and four caches, against readers: a reader's gate changes under it at most once + // (the bound above throws on a second), so no reader fails and every thread finishes. + [TestCase(1)] + [TestCase(2)] + [TestCase(4)] + public void ReadersAcrossFailingAttachChurnNeverExceedTheBound(int attachers) + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry); + var store = new RoleStore(_path) { FailAttachPull = true }; + store.ReleaseAttach.Set(); + var caches = Enumerable.Range(0, attachers).Select(_ => new CultCache(registry)).ToArray(); + var stop = 0; + var readerErrors = new System.Collections.Concurrent.ConcurrentBag(); + var attachErrors = new System.Collections.Concurrent.ConcurrentBag(); + var threads = caches.Select(c => OnThread("attach", () => + { + while (Volatile.Read(ref stop) == 0) + { + try { c.AddBackingStore(store); } + catch (InvalidOperationException e) when (e.Message.Contains("pull fails")) { } + catch (Exception e) { attachErrors.Add(e.Message); } + } + })).Concat(Enumerable.Range(0, 4).Select(_ => OnThread("churn-reader", () => + { + while (Volatile.Read(ref stop) == 0) + { + try { store.ReadStoreSnapshot(); } + catch (Exception e) { readerErrors.Add(e.Message); } + } + }))).ToArray(); + Thread.Sleep(TimeSpan.FromSeconds(2)); + Volatile.Write(ref stop, 1); + foreach (var t in threads) + Finishes(t.Thread, "a churn thread"); + + Assert.That(readerErrors, Is.Empty); + Assert.That(attachErrors, Is.Empty); + foreach (var c in caches) + c.Dispose(); + } + + // A call on a detached store takes exactly one gate (the store's), and one on a store whose attach has settled takes exactly + // one (the settled cache's, reached by the guess): the counter counts each take. + [Test] + public void OneStoreCallTakesExactlyOneGateOnADetachedStoreAndOnASettledOne() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry); + var store = new RoleStore(_path); + store.ReleaseAttach.Set(); + + CacheBackingStore.GateTakes = 0; + store.ReadStoreSnapshot(); + Assert.That(CacheBackingStore.GateTakes, Is.EqualTo(1), "a detached store's call takes the store's gate"); + + using var cache = new CultCache(registry); + cache.AddBackingStore(store); + CacheBackingStore.GateTakes = 0; + store.ReadStoreSnapshot(); + Assert.That(CacheBackingStore.GateTakes, Is.EqualTo(1), "a settled store's call takes only its cache's gate"); + } + + // Three gates are reached only by a stale guess that a later attach outdates: the call read the cache a failing attach had + // published (its first gate, taken only to find Cache moved), then the store's gate (its second) found another cache settled + // by an attach that won the store in between (its third). One round is ordered with the store's hooks, and no call exceeds three. + [Test] + public void OneStoreCallTakesThreeGatesWhenAStaleGuessIsFollowedBySuccessAndNeverMore() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry); + var mostTakes = 0; + for (var round = 0; round < 20 && mostTakes < 3; round++) + { + var store = new RoleStore(_path) { FailAttachPull = true }; + using var failing = new CultCache(registry); + using var settling = new CultCache(registry); + var failed = OnThread("attach", () => failing.AddBackingStore(store)); + Assert.That(store.AttachInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the failing attach is inside its pull, Cache published"); + var settled = OnThread("settling", () => settling.AddBackingStore(store)); + WaitUntilBlocked(settled.Thread); + var takes = 0; + var call = OnThread("call", () => + { + CacheBackingStore.GateTakes = 0; + store.ReadStoreSnapshot(); + takes = CacheBackingStore.GateTakes; + }); + WaitUntilBlocked(call.Thread); + Thread.Sleep(100); + store.ReleaseAttach.Set(); + Finishes(failed.Thread, "the failing attach"); + Finishes(settled.Thread, "the settling attach"); + Finishes(call.Thread, "the call"); + + Assert.That(Refused(failed.Error()), Is.True, "the first attach failed"); + Assert.That(settled.Error(), Is.Null, "the second attach settled"); + Assert.That(takes, Is.InRange(1, 3), "no call took more than three gates"); + mostTakes = Math.Max(mostTakes, takes); + } + + Assert.That(mostTakes, Is.EqualTo(3), "a call that read a stale guess and then met a settled cache took three gates"); + } + + // Under attaches that fail, Cache read under the store's gate is always null (an attach publishes and retracts it inside one + // hold of that gate), so one call takes at most two gates: the guessed cache's, then the store's. A retry around the unlocked + // guess would take one per stale guess and exceed this. + [Test] + public void OneStoreCallTakesAtMostTwoGatesUnderFailingAttachChurn() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry); + var store = new RoleStore(_path) { FailAttachPull = true }; + store.ReleaseAttach.Set(); + var caches = Enumerable.Range(0, 4).Select(_ => new CultCache(registry)).ToArray(); + var stop = 0; + var mostTakes = 0; + var calls = 0; + var threads = caches.Select(c => OnThread("attach", () => + { + while (Volatile.Read(ref stop) == 0) + { + try { c.AddBackingStore(store); } + catch (InvalidOperationException e) when (e.Message.Contains("pull fails")) { } + } + })).Concat(Enumerable.Range(0, 4).Select(_ => OnThread("counting-reader", () => + { + while (Volatile.Read(ref stop) == 0) + { + CacheBackingStore.GateTakes = 0; + try { store.ReadStoreSnapshot(); } + catch (InvalidOperationException) { } + var takes = CacheBackingStore.GateTakes; + Interlocked.Increment(ref calls); + int seen; + while (takes > (seen = Volatile.Read(ref mostTakes)) && Interlocked.CompareExchange(ref mostTakes, takes, seen) != seen) { } + } + }))).ToArray(); + Thread.Sleep(TimeSpan.FromSeconds(3)); + Volatile.Write(ref stop, 1); + foreach (var t in threads) + Finishes(t.Thread, "a churn thread"); + + Assert.That(calls, Is.GreaterThan(100), "the readers made calls under churn"); + Assert.That(mostTakes, Is.LessThanOrEqualTo(2), "no call took more than two gates"); + foreach (var c in caches) + c.Dispose(); + } + } +} diff --git a/tests/GameCult.Caching.Tests/CompatibleSchemaIdTests.cs b/tests/GameCult.Caching.Tests/CompatibleSchemaIdTests.cs new file mode 100644 index 00000000..3ef2816d --- /dev/null +++ b/tests/GameCult.Caching.Tests/CompatibleSchemaIdTests.cs @@ -0,0 +1,579 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; +using static GameCult.Caching.Tests.EmittedDocumentTypes; + +namespace GameCult.Caching.Tests +{ + // A document type declares the older schema ids its records may sit under (CultDocumentAttribute.CompatibleSchemaIds). The + // registered catalog entry lists them, a cache resolves a record under one to that type and stamps it with the type's own id, + // and a store writer never changes the identity of a record it did not receive. + public class CompatibleSchemaIdTests + { + private const string OldId = "old.id"; + private const string OwnerId = "sha256:86186abf7d63d8a326c725d39a10f3a4db11b1e8698e8c0fcf6d9720031bc480"; + + [CultDocument("tests.compat_deck", "tests.compat_deck.v2", CompatibleSchemaIds = new[] { OldId, OldId })] + [MessagePackObject] + public sealed class DeclaringDeck + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + } + + // The same schema as DeclaringDeck (the class name is not part of a schema id), declaring nothing. Emitted, because a + // declared type that differs from DeclaringDeck only in its declaration is refused beside it by the shared registry. + private static readonly Type BareDeck = Emit("BareDeck", "tests.compat_deck", "tests.compat_deck.v2", new[] { new Field("Name", typeof(string), 0, IsName: true) }); + + [CultDocument("tests.compat_owner", "tests.compat_owner.v1", CompatibleSchemaIds = new[] { OwnerId, " " })] + [MessagePackObject] + public sealed class Owner + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + } + + // One schema before and after a member was added, with nothing declared: the later type reads the earlier records by name. + [CultDocument("tests.compat_evolving", "tests.compat_evolving.v1")] + [MessagePackObject] + public sealed class EvolvingV1 + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + } + + [CultDocument("tests.compat_evolving", "tests.compat_evolving.v2")] + [MessagePackObject] + public sealed class EvolvingV2 + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + [Key(1)] public string Extra { get; set; } = ""; + } + + private static readonly CultDocumentRegistry Declaring = CultDocumentRegistry.ForTypes(new[] { typeof(DeclaringDeck) }); + private static readonly CultDocumentRegistry Bare = CultDocumentRegistry.ForTypes(new[] { BareDeck }); + private static readonly CultRecordKey D = new("d"); + private string _directory = ""; + + [SetUp] + public void SetUp() + { + _directory = Path.Combine(Path.GetTempPath(), "cultlib-compat-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, true); + } + + private static CultCache Open(string path, CultDocumentRegistry registry) => + CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry }); + + private static CultPersistedStoreSnapshot Read(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + + private static object Deck(string name) => New(BareDeck, ("Name", name)); + + private static void Rewrite(string path, Action edit) + { + var snapshot = Read(path); + edit(snapshot); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + } + + // A store written by a runtime that knew the schema under an older id and another name: one entry that owns OldId, one + // record under it. Nothing but the declaration lets this runtime's type claim it. + private string OldStore(string name) + { + var path = Path.Combine(_directory, name); + using (var cache = Open(path, Bare)) + cache.Commit(batch => batch.Upsert(BareDeck, Deck("d"), D)); + Rewrite(path, snapshot => + { + var entry = snapshot.SchemaCatalog.Single(); + entry.SchemaId = OldId; + entry.SchemaName = "tests.legacy_deck"; + entry.ContentHash = "stale"; + entry.CompatibleSchemaIds = new[] { OldId }; + snapshot.Records.Single().SchemaId = OldId; + }); + return path; + } + + // The same store with the entry keeping this schema's name and only listing the older id: what a store carries after a + // writer that did not declare it, so a runtime without the declaration can still read it. + private string OldStoreUnderTheSameName(string name) + { + var path = Path.Combine(_directory, name); + using (var cache = Open(path, Bare)) + cache.Commit(batch => batch.Upsert(BareDeck, Deck("d"), D)); + Rewrite(path, snapshot => + { + var entry = snapshot.SchemaCatalog.Single(); + entry.ContentHash = "stale"; + entry.CompatibleSchemaIds = new[] { entry.SchemaId, OldId }; + snapshot.Records.Single().SchemaId = OldId; + }); + return path; + } + + [Test] + public void TheEmittedBareDeckIsTheDeclaringDecksSchema() + { + Assert.That(Bare.GetRequired(BareDeck).SchemaId, Is.EqualTo(Declaring.GetRequired().SchemaId)); + } + + [Test] + public void ARecordUnderADeclaredOldIdOpensAndACommitRewritesItUnderTheRegisteredId() + { + var path = OldStore("old.cc"); + var descriptor = Declaring.GetRequired(); + + using (var cache = Open(path, Declaring)) + { + Assert.That(cache.Get(D)!.Name, Is.EqualTo("d"), "the declaration resolves the record to the type"); + Assert.That(cache.Commit(batch => batch.Upsert(typeof(DeclaringDeck), new DeclaringDeck { Name = "d2" }, D)), Is.True); + } + + var written = Read(path); + Assert.That(written.Records.Single().SchemaId, Is.EqualTo(descriptor.SchemaId)); + var entry = written.SchemaCatalog.Single(); + Assert.That(entry.SchemaId, Is.EqualTo(descriptor.SchemaId)); + Assert.That(entry.SchemaName, Is.EqualTo("tests.compat_deck")); + Assert.That(entry.CompatibleSchemaIds, Is.EqualTo(new[] { descriptor.SchemaId, OldId })); + foreach (var registry in new[] { Declaring, Bare }) + { + using var reopened = Open(path, registry); + Assert.That(reopened.AllEntries.Count(), Is.EqualTo(1)); + } + } + + // The name a catalog entry carries is metadata: a record resolves by its schema id, so a schema renamed under the id the + // registered type owns opens, with the entry's content hash no longer matching the type's. + [Test] + public void ASchemaRenamedUnderTheIdARegisteredTypeOwnsOpens() + { + var path = Path.Combine(_directory, "renamed.cc"); + using (var cache = Open(path, Bare)) + cache.Commit(batch => batch.Upsert(BareDeck, Deck("d"), D)); + Rewrite(path, snapshot => + { + snapshot.SchemaCatalog.Single().SchemaName = "tests.renamed_deck"; + snapshot.SchemaCatalog.Single().ContentHash = "stale"; + }); + + using var reopened = Open(path, Bare); + Assert.That(EmittedDocumentTypes.Read(reopened.Get(D)!, "Name"), Is.EqualTo("d")); + } + + [Test] + public void ARecordUnderAnOldIdNoTypeDeclaresIsCarriedAsForeignNotOpenedByThatSchemaName() + { + var path = OldStore("undeclared.cc"); + + using var cache = Open(path, Bare); + Assert.That(cache.Get(D), Is.Null, "the entry's name is metadata; only a declared id resolves it"); + Assert.That(cache.BackingStores.Single().ForeignRecords.Select(record => (record.Key, record.SchemaId, record.SchemaName)), + Is.EqualTo(new[] { (D.Value, OldId, "tests.legacy_deck") })); + } + + // An unconditional commit writes what it staged: the record the batch does not name is copied as the file holds it, under + // the old id and with the entry that publishes that id, though the cache resolved it to the registered type. + [Test] + public void AnUnconditionalCommitCopiesALoadedRecordUnderItsOldIdAsTheFileHoldsIt() + { + var path = OldStore("copied.cc"); + var descriptor = Declaring.GetRequired(); + var before = Read(path); + var entryBefore = StoreSlices.CatalogEntry(path, OldId); + + using (var cache = Open(path, Declaring)) + cache.Commit(batch => batch.Upsert(typeof(DeclaringDeck), new DeclaringDeck { Name = "e" }, new CultRecordKey("e"))); + + var written = Read(path); + Assert.That(written.Records.Single(record => record.Key == "d").SchemaId, Is.EqualTo(OldId)); + Assert.That(written.Records.Single(record => record.Key == "e").SchemaId, Is.EqualTo(descriptor.SchemaId)); + var copied = written.Records.Single(record => record.Key == "d"); + var original = before.Records.Single(); + Assert.That((copied.StoredAt, copied.Payload), Is.EqualTo((original.StoredAt, original.Payload))); + Assert.That(StoreSlices.CatalogEntry(path, OldId), Is.EqualTo(entryBefore), + "the entry that published the old id is carried as its bytes"); + } + + // A commit onto the file writes its batch and leaves every other record as the file holds it: the store writer never + // changes identity. The record stays under the older id, which the registered entry publishes as compatible. + [Test] + public void ACommitOntoTheFileLeavesARecordUnderADeclaredOldIdAsItIsAndPublishesTheId() + { + var path = OldStoreUnderTheSameName("onto.cc"); + var descriptor = Declaring.GetRequired(); + + using (var cache = Open(path, Declaring)) + { + Assert.That(cache.Get(D), Is.Not.Null); + Assert.That(cache.Commit(batch => + { + batch.Expect(new CultRecordKey("e"), null); + batch.Upsert(typeof(DeclaringDeck), new DeclaringDeck { Name = "e" }, new CultRecordKey("e")); + }), Is.True); + } + + var written = Read(path); + Assert.That(written.Records.Single(record => record.Key == "d").SchemaId, Is.EqualTo(OldId)); + Assert.That(written.Records.Single(record => record.Key == "e").SchemaId, Is.EqualTo(descriptor.SchemaId)); + Assert.That(written.SchemaCatalog.Single().CompatibleSchemaIds, Does.Contain(OldId)); + using var reopened = Open(path, Declaring); + Assert.That(reopened.Get(D), Is.Not.Null); + } + + [Test] + public void WithoutTheDeclarationACommitOntoTheFileIsRefusedTypedAndTheFileLeftAsItWas() + { + var path = OldStoreUnderTheSameName("undeclared-onto.cc"); + var bytes = File.ReadAllBytes(path); + + using var cache = Open(path, Bare); + var refusal = Assert.Throws(() => cache.Commit(batch => + { + batch.Expect(new CultRecordKey("e"), null); + batch.Upsert(BareDeck, Deck("e"), new CultRecordKey("e")); + }))!; + Assert.That(refusal.SchemaId, Is.EqualTo(OldId)); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes)); + } + + // A record under a declared old id is the same record the cache holds, loaded under that id: a condition naming it holds. + [Test] + public void AConditionOnARecordUnderADeclaredOldIdHoldsAndTheWholeStoreCountsAsUnchanged() + { + var path = OldStoreUnderTheSameName("conditions.cc"); + + using var cache = Open(path, Declaring); + var current = cache.Get(D)!; + Assert.That(cache.TryCommit(batch => + { + batch.Expect(D, current); + batch.ExpectUnchanged(); + batch.Upsert(typeof(DeclaringDeck), new DeclaringDeck { Name = "e" }, new CultRecordKey("e")); + }), Is.EqualTo(CultCommitOutcome.Committed)); + } + + // The directory store judges conditions against its manifest, whose record still sits under the older id. + [Test] + public void AConditionOnARecordUnderADeclaredOldIdHoldsInTheDirectoryStore() + { + var path = Path.Combine(_directory, "dir.cc"); + var options = new CultCacheOpenOptions { Registry = Bare, UseDirectoryStore = true }; + using (var cache = CultCacheMessagePack.Create(path, options)) + cache.Commit(batch => batch.Upsert(BareDeck, Deck("d"), D)); + Rewrite(path, manifest => + { + var entry = manifest.SchemaCatalog.Single(); + entry.ContentHash = "stale"; + entry.CompatibleSchemaIds = new[] { entry.SchemaId, OldId }; + manifest.Records.Single().SchemaId = OldId; + }); + + using var declaring = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Declaring, UseDirectoryStore = true }); + var current = declaring.Get(D)!; + Assert.That(declaring.TryCommit(batch => + { + batch.Expect(D, current); + batch.ExpectUnchanged(); + batch.Upsert(typeof(DeclaringDeck), new DeclaringDeck { Name = "e" }, new CultRecordKey("e")); + }), Is.EqualTo(CultCommitOutcome.Committed)); + } + + // Conditions compare the id a record was loaded under with the file's, exactly. A record resolved by some other path than + // its type's own id is still unchanged when nothing on disk moved: by its schema name under a foreign id, by name across a + // schema change that declared nothing, or by a file entry that lists this runtime's id. + private static void AssertUnchangedConditionsHold(CultCache cache, CultRecordKey key, object current, Type type, object added) + { + Assert.That(cache.TryCommit(batch => + { + batch.Expect(key, current); + batch.Upsert(type, added, new CultRecordKey("added-" + Guid.NewGuid().ToString("N"))); + }), Is.EqualTo(CultCommitOutcome.Committed), "Expect"); + Assert.That(cache.TryCommit(batch => + { + batch.ExpectUnchanged(); + batch.Upsert(type, added, new CultRecordKey("added-" + Guid.NewGuid().ToString("N"))); + }), Is.EqualTo(CultCommitOutcome.Committed), "ExpectUnchanged"); + } + + [Test] + public void AConditionOnARecordResolvedByNameUnderAForeignIdHolds() + { + var path = Path.Combine(_directory, "foreign.cc"); + using (var cache = Open(path, Bare)) + cache.Commit(batch => batch.Upsert(BareDeck, Deck("d"), D)); + Rewrite(path, snapshot => + { + var entry = snapshot.SchemaCatalog.Single(); + entry.SchemaId = "foreign.id"; + entry.CompatibleSchemaIds = new[] { "foreign.id" }; + snapshot.Records.Single().SchemaId = "foreign.id"; + }); + + using var reader = Open(path, Declaring); + AssertUnchangedConditionsHold(reader, D, reader.Get(D)!, typeof(DeclaringDeck), new DeclaringDeck { Name = "e" }); + } + + [Test] + public void AConditionOnARecordAnEvolvedTypeResolvedByNameHolds() + { + var path = Path.Combine(_directory, "evolved.cc"); + using (var cache = Open(path, CultDocumentRegistry.ForTypes(new[] { typeof(EvolvingV1) }))) + cache.Commit(batch => batch.Upsert(typeof(EvolvingV1), new EvolvingV1 { Name = "d" }, D)); + + using var reader = Open(path, CultDocumentRegistry.ForTypes(new[] { typeof(EvolvingV2) })); + AssertUnchangedConditionsHold(reader, D, reader.Get(D)!, typeof(EvolvingV2), new EvolvingV2 { Name = "e" }); + } + + [Test] + public void AConditionOnARecordAFileEntryListingTheLocalIdResolvedHolds() + { + var path = Path.Combine(_directory, "listed.cc"); + var localId = Bare.GetRequired(BareDeck).SchemaId; + using (var cache = Open(path, Bare)) + cache.Commit(batch => batch.Upsert(BareDeck, Deck("d"), D)); + Rewrite(path, snapshot => + { + var entry = snapshot.SchemaCatalog.Single(); + entry.SchemaId = OldId; + entry.CompatibleSchemaIds = new[] { OldId, localId }; + snapshot.Records.Single().SchemaId = OldId; + }); + + using var reader = Open(path, Bare); + AssertUnchangedConditionsHold(reader, D, reader.Get(D)!, BareDeck, Deck("e")); + } + + // A plain record and a variant loaded under a declared old id carry that id, so a condition on either holds while the file + // is unchanged. An unconditional commit then copies both as they are, still under the old id, and the cache still holds + // each under it: the same conditions hold against the rewritten file. + [Test] + public void ConditionsOnRecordsLoadedUnderAnOldIdHoldBeforeAndAfterAnUnconditionalCommit() + { + var path = Path.Combine(_directory, "restamped.cc"); + var b = new CultRecordKey("b"); + var v = new CultRecordKey("v"); + using (var cache = Open(path, Declaring)) + { + cache.Commit(batch => batch.Upsert(typeof(DeclaringDeck), new DeclaringDeck { Name = "b" }, b)); + cache.Commit(batch => batch.UpsertVariant(v, b, new[] { cache.Override(typeof(DeclaringDeck), nameof(DeclaringDeck.Name), "v") })); + } + + Rewrite(path, snapshot => + { + foreach (var record in snapshot.Records) + record.SchemaId = OldId; + }); + + using var reader = Open(path, Declaring); + void ConditionsHold(string phase) + { + foreach (var key in new[] { b, v }) + { + Assert.That(reader.TryCommit(batch => + { + batch.Expect(key, reader.Get(key)!); + batch.Upsert(typeof(DeclaringDeck), new DeclaringDeck { Name = "f" }, new CultRecordKey($"f-{phase}-{key.Value}")); + }), Is.EqualTo(CultCommitOutcome.Committed), $"{phase}: Expect {key.Value}"); + } + + Assert.That(reader.TryCommit(batch => + { + batch.ExpectUnchanged(); + batch.Upsert(typeof(DeclaringDeck), new DeclaringDeck { Name = "g" }, new CultRecordKey($"g-{phase}")); + }), Is.EqualTo(CultCommitOutcome.Committed), $"{phase}: ExpectUnchanged"); + } + + ConditionsHold("loaded"); + Assert.That(Read(path).Records.Where(record => record.Key is "b" or "v").Select(record => record.SchemaId), Is.All.EqualTo(OldId), + "commits onto the file left the loaded records under the old id"); + + reader.Commit(batch => batch.Upsert(typeof(DeclaringDeck), new DeclaringDeck { Name = "e" }, new CultRecordKey("e"))); + Assert.That(Read(path).Records.Where(record => record.Key is "b" or "v").Select(record => record.SchemaId), Is.All.EqualTo(OldId), + "the unconditional commit copied the loaded records under the old id"); + ConditionsHold("rewritten"); + } + + // The directory store rereads a record whose manifest entry moved to another id with the same storedAt, as a rewrite that + // mints no storedAt leaves it: after a pull the cache holds the record under that id, and a condition on it holds. + [Test] + public void ADirectoryStorePullRereadsARecordWhoseIdAloneChanged() + { + var path = Path.Combine(_directory, "dir-moved.cc"); + var options = new CultCacheOpenOptions { Registry = Declaring, UseDirectoryStore = true }; + using (var writer = CultCacheMessagePack.Create(path, options)) + writer.Commit(batch => batch.Upsert(typeof(DeclaringDeck), new DeclaringDeck { Name = "d" }, D)); + + using var cache = CultCacheMessagePack.Create(path, options); + Rewrite(path, manifest => manifest.Records.Single().SchemaId = OldId); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + + Assert.That(cache.TryCommit(batch => + { + batch.Expect(D, cache.Get(D)!); + batch.Upsert(typeof(DeclaringDeck), new DeclaringDeck { Name = "e" }, new CultRecordKey("e")); + }), Is.EqualTo(CultCommitOutcome.Committed)); + } + + // A rolling deployment shares one single-file store between v1 (one member) and v2 (a second member, declaring v1's id). + private static readonly Field RollName = new("Name", typeof(string), 0, IsName: true); + private static readonly Type RollV1 = Emit("RollV1", "tests.compat_roll", "tests.compat_roll.v1", new[] { RollName }); + private static readonly string RollV1Id = CultDocumentRegistry.ForTypes(new[] { RollV1 }).GetRequired(RollV1).SchemaId; + private static readonly Type RollV2 = Emit("RollV2", "tests.compat_roll", "tests.compat_roll.v2", new[] { RollName, new Field("Extra", typeof(string), 1) }, new[] { RollV1Id }); + private static readonly CultRecordKey RollK = new("k"); + private static readonly CultRecordKey RollY = new("y"); + + private CultCache OpenRoll(Type type) => Open(Path.Combine(_directory, "roll.cc"), CultDocumentRegistry.ForTypes(new[] { type })); + private string RollPath => Path.Combine(_directory, "roll.cc"); + private static string Extra(object? document) => (string?)EmittedDocumentTypes.Read(document!, "Extra") ?? ""; + + // Writes k with Extra "important" as v2. + private void WriteImportant() + { + using var writer = OpenRoll(RollV2); + writer.Commit(batch => batch.Upsert(RollV2, New(RollV2, ("Name", "k"), ("Extra", "important")), RollK)); + } + + // A write by a cache of the given version, through a commit or a plain flush, that leaves the key set as it was. + private void UnrelatedWrite(Type version, bool viaFlush) + { + using var cache = OpenRoll(version); + Assert.That(EmittedDocumentTypes.Read(cache.Get(RollK)!, "Name"), Is.EqualTo("k"), $"{version.Name} reads k"); + if (viaFlush) + { + cache.BackingStores.Single().PushAll(); + return; + } + + cache.Commit(batch => batch.Upsert(version, New(version, ("Name", "z")), new CultRecordKey("z"))); + cache.Commit(batch => batch.Remove(new CultRecordKey("z"))); + } + + // The derivation a v2 cache commits from the k it holds, under a condition on that k. + private static CultCommitOutcome Derive(CultCache cache, object held, bool unchangedOnly) => cache.TryCommit(batch => + { + if (unchangedOnly) + batch.ExpectUnchanged(); + else + batch.Expect(RollK, held); + batch.Upsert(RollV2, New(RollV2, ("Name", "y"), ("Extra", "derived-from-" + Extra(held))), RollY); + }); + + // v1 reads v2's record through the entry that lists v1's id: an id v1 does not own. A write of another record by v1 copies k + // exactly as v2 stored it, the member v1 lacks included, under v2's id and storedAt, so a v2 cache's condition on k still holds + // and what it derives from that member lands. + [Test] + public void AnUnrelatedWriteByAnOlderVersionLeavesANewerRecordAsStored([Values] bool unchangedOnly, [Values] bool viaFlush) + { + WriteImportant(); + var written = Read(RollPath); + + using var newer = OpenRoll(RollV2); + var current = newer.Get(RollK)!; + UnrelatedWrite(RollV1, viaFlush); + + var rewritten = Read(RollPath); + var record = rewritten.Records.Single(); + Assert.That((record.SchemaId, record.StoredAt, record.Payload), Is.EqualTo((written.Records.Single().SchemaId, written.Records.Single().StoredAt, written.Records.Single().Payload))); + Assert.That(CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot { SchemaCatalog = rewritten.SchemaCatalog }), + Is.EqualTo(CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot { SchemaCatalog = written.SchemaCatalog })), + "v2's catalog entry is written as v2 wrote it"); + + Assert.That(Derive(newer, current, unchangedOnly), Is.EqualTo(CultCommitOutcome.Committed)); + using var reopened = OpenRoll(RollV2); + Assert.That(Extra(reopened.Get(RollK)), Is.EqualTo("important")); + Assert.That(Extra(reopened.Get(RollY)), Is.EqualTo("derived-from-important")); + } + + // v1 reads v2's k through v2's entry, which lists v1's id: the list is the writer's statement, not v1's declaration, so to v1 + // k is read-only. v1's write of k is refused typed before anything is staged, the file is left byte for byte, and a v2 + // cache's condition on k still holds. + [Test] + public void AnOlderVersionCannotWriteANewerRecordItReadsThroughTheNewerEntry([Values] bool commit) + { + WriteImportant(); + var before = File.ReadAllBytes(RollPath); + using var newer = OpenRoll(RollV2); + var current = newer.Get(RollK)!; + + using (var older = OpenRoll(RollV1)) + { + var k = older.Get(RollK)!; + Assert.That(EmittedDocumentTypes.Read(k, "Name"), Is.EqualTo("k"), "v1 reads k"); + var refusal = Assert.Throws(() => + { + if (commit) + older.Commit(batch => batch.Upsert(RollV1, k, RollK)); + else + older.UpsertAsync(RollV1, k, RollK).GetAwaiter().GetResult(); + })!; + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((RollK.Value, Read(RollPath).Records.Single().SchemaId))); + Assert.That(older.BackingStores.Single().IsDirty, Is.False, "nothing was staged"); + older.FlushAsync().GetAwaiter().GetResult(); + } + + Assert.That(File.ReadAllBytes(RollPath), Is.EqualTo(before)); + Assert.That(Derive(newer, current, unchangedOnly: false), Is.EqualTo(CultCommitOutcome.Committed)); + using var reopened = OpenRoll(RollV2); + Assert.That(Extra(reopened.Get(RollK)), Is.EqualTo("important")); + Assert.That(Extra(reopened.Get(RollY)), Is.EqualTo("derived-from-important")); + } + + // A record a write copies keeps its storedAt, so a condition another cache holds on it survives an unrelated write. + [TestCase(false)] + [TestCase(true)] + public void AnUnrelatedWriteKeepsTheStoredAtOfARecordItCopies(bool viaFlush) + { + WriteImportant(); + var written = Read(RollPath).Records.Single(); + using var held = OpenRoll(RollV2); + var current = held.Get(RollK)!; + + UnrelatedWrite(RollV2, viaFlush); + + Assert.That(Read(RollPath).Records.Single().StoredAt, Is.EqualTo(written.StoredAt)); + Assert.That(Derive(held, current, unchangedOnly: false), Is.EqualTo(CultCommitOutcome.Committed)); + } + + // The migration CompatibleSchemaIds exists for: v2 declares v1's id and v1's class stays registered beside it, in either + // order. A record under v1's id is v1's, its owner; one under v2's id is v2's. + [TestCase(false)] + [TestCase(true)] + public void AVersionKeptBesideTheVersionDeclaringItsIdReadsItsOwnRecords(bool reversed) + { + using (var older = OpenRoll(RollV1)) + older.Commit(batch => batch.Upsert(RollV1, New(RollV1, ("Name", "k")), RollK)); + using (var newer = OpenRoll(RollV2)) + newer.Commit(batch => + { + batch.Expect(RollY, null); + batch.Upsert(RollV2, New(RollV2, ("Name", "y"), ("Extra", "e")), RollY); + }); + Assert.That(Read(RollPath).Records.Select(record => record.SchemaId), Is.EquivalentTo(new[] { RollV1Id, CultDocumentRegistry.ForTypes(new[] { RollV2 }).GetRequired(RollV2).SchemaId })); + + using var both = Open(RollPath, CultDocumentRegistry.ForTypes(reversed ? new[] { RollV2, RollV1 } : new[] { RollV1, RollV2 })); + Assert.That(both.Get(RollK)!.GetType(), Is.EqualTo(RollV1)); + Assert.That(both.Get(RollY)!.GetType(), Is.EqualTo(RollV2)); + } + + [Test] + public void ADeclarationIsRegisteredWithoutItsOwnIdAndItsEntryListsTheOwnIdFirst() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(Owner) }); + var descriptor = registry.GetRequired(); + + Assert.That(descriptor.SchemaId, Is.EqualTo(OwnerId), "the pinned id is this schema's id"); + Assert.That(descriptor.CompatibleSchemaIds, Is.Empty, "a type's own id and a blank id are not declared compatible ids"); + Assert.That(descriptor.ToCatalogEntry().CompatibleSchemaIds, Is.EqualTo(new[] { OwnerId })); + } + } +} diff --git a/tests/GameCult.Caching.Tests/ConditionalCommitTests.cs b/tests/GameCult.Caching.Tests/ConditionalCommitTests.cs index 5807b68e..137bebb5 100644 --- a/tests/GameCult.Caching.Tests/ConditionalCommitTests.cs +++ b/tests/GameCult.Caching.Tests/ConditionalCommitTests.cs @@ -83,18 +83,18 @@ namespace GameCult.Caching.Tests } [Test] - public void RecordRewrittenUnderAnotherSchemaWithItsStoredAtFailsExpect() + public void RecordRewrittenUnderAnotherSchemaFailsExpect() { SeedDrift(directory: false); using var cache = Drift(false, NarrowDrift).Open(); var observed = cache.Get(DriftKey)!; - var storedAt = cache.GetStored(DriftKey)!.StoredAt; using (var other = Drift(false, ReusedDrift).Open()) + { + other.BackingStores[0].Push(other.GetStored(DriftKey)!); other.BackingStores[0].PushAll(); - using (var reader = Drift(false, ReusedDrift).Open()) - Assert.That(reader.GetStored(DriftKey)!.StoredAt, Is.EqualTo(storedAt), "the rewrite kept storedAt"); + } - Assert.That(DriftCommit(cache, observed, 2), Is.False, "a record rewritten under another schema passed Expect on storedAt alone"); + Assert.That(DriftCommit(cache, observed, 2), Is.False, "a record rewritten under another schema passed Expect"); } // The directory store rewrites only what is pushed to it, so the other writer pushes the record it read unchanged. @@ -137,51 +137,10 @@ namespace GameCult.Caching.Tests cache.BackingStores[0].PushAll(); var stored = cache.GetStored(DriftKey)!; - Assert.That(stored.PersistedSchemaId, Is.EqualTo(stored.Descriptor.SchemaId), "the rewrite was not recorded"); + Assert.That(stored.StoredSchemaId, Is.EqualTo(stored.Descriptor.SchemaId), "the rewrite was not recorded"); Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 2), Is.True, "Expect failed after this cache's own push rewrote the record"); } - // A store written outside the library marks its own rewrite through the base class. - [Test] - public void ACustomStoreMarksItsOwnWholeFileRewrite() - { - SeedDrift(directory: false); - using var cache = new CultCache(NarrowDrift); - var store = new WholeFileStore(PathOf("drift.cc")); - cache.AddBackingStore(store); - store.PullAll(); - var loaded = cache.GetStored(DriftKey)!; - Assert.That(loaded.PersistedSchemaId, Is.Not.EqualTo(loaded.Descriptor.SchemaId), "the record was not loaded under drift"); - - store.Push(loaded); - store.PushAll(); - - Assert.That(cache.GetStored(DriftKey)!.PersistedSchemaId, Is.EqualTo(loaded.Descriptor.SchemaId), "the custom store's rewrite was not recorded"); - Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 2), Is.True, "Expect failed after the custom store rewrote the record"); - using var reader = Drift(false, NarrowDrift).Open(); - Assert.That(reader.Get(DriftKey)!.Value, Is.EqualTo(2)); - } - - // A store marks only what it holds: another cache's copy of the same record is refused and left as read. - [Test] - public void ACustomStoreCannotMarkAnEntryItDoesNotHold() - { - SeedDrift(directory: false); - using var owner = new CultCache(NarrowDrift); - var ownerStore = new WholeFileStore(PathOf("drift.cc")); - owner.AddBackingStore(ownerStore); - ownerStore.PullAll(); - using var other = new CultCache(NarrowDrift); - var otherStore = new WholeFileStore(PathOf("drift.cc")); - other.AddBackingStore(otherStore); - otherStore.PullAll(); - var foreign = owner.GetStored(DriftKey)!; - var persisted = foreign.PersistedSchemaId; - - Assert.Throws(() => otherStore.Mark(foreign), "a store marked another cache's entry"); - Assert.That(foreign.PersistedSchemaId, Is.EqualTo(persisted), "a refused mark changed the entry"); - } - [Test] public void OwnUnconditionalCommitKeepsDriftedExpectHolding() { @@ -215,12 +174,12 @@ namespace GameCult.Caching.Tests wide = writer.GetStored(DriftKey)!.Descriptor.SchemaId; using var cache = Drift(false, NarrowDrift).Open(); var loaded = cache.GetStored(DriftKey)!; - Assert.That(loaded.PersistedSchemaId, Is.EqualTo(wide), "the entry carries the schema id its record is stored under"); + Assert.That(loaded.StoredSchemaId, Is.EqualTo(wide), "the entry carries the schema id its record is stored under"); Assert.That(loaded.Descriptor.SchemaId, Is.Not.EqualTo(wide)); Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 2), Is.True); var written = cache.GetStored(DriftKey)!; - Assert.That(written.PersistedSchemaId, Is.EqualTo(written.Descriptor.SchemaId)); + Assert.That(written.StoredSchemaId, Is.EqualTo(written.Descriptor.SchemaId)); Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 3), Is.True, "Expect failed on the record this cache just wrote"); } @@ -429,6 +388,24 @@ namespace GameCult.Caching.Tests Assert.That(reads, Is.GreaterThan(0)); } + // With no backing store a record's version is minted with it: an Expect on the held record commits, and once the record is + // replaced after it was observed (here by the same document again) the Expect no longer holds. + [Test] + public void InMemoryExpectHoldsUntilTheRecordIsReplaced() + { + using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(Counter) })); + cache.Commit(batch => batch.Upsert(new Counter { Name = "counter", Value = 1 }, new CultRecordHandle(Key))); + var first = cache.Get(Key)!; + + Assert.That(cache.Commit(batch => batch.Expect(Key, first)), Is.True); + + Assert.That(cache.Commit(batch => + { + batch.Expect(Key, first); + cache.UpsertAsync(first, new CultRecordHandle(Key)).GetAwaiter().GetResult(); + }), Is.False); + } + [Test] public void StoredAtIsStrictlyIncreasingPerKey() { @@ -775,7 +752,7 @@ namespace GameCult.Caching.Tests } [Test] - public void UnconditionalCommitIsLastWriterWinsLikeFlush() + public void UnconditionalCommitIsLastWriterWinsPerKeyLikeFlush() { var committed = LastWriter(SingleFile(PathOf("commit.cc")), (cache, key) => Assert.That(cache.Commit(batch => batch.Upsert(new Counter { Name = key.Value }, new CultRecordHandle(key))), Is.True)); @@ -785,10 +762,10 @@ namespace GameCult.Caching.Tests cache.FlushAllBackingStores(); }); - Assert.That(committed, Is.EqualTo(new[] { "a", "x" }), "an unconditional commit kept a record its cache never saw"); + Assert.That(committed, Is.EqualTo(new[] { "a", "c", "x" }), "an unconditional commit dropped a record another writer added"); Assert.That(committed, Is.EqualTo(flushed)); - // A directory store's flush lands staged keys onto the current manifest, and its commit does exactly the same. + // A directory store's flush lands staged keys onto the current manifest, and its commit does exactly the same: so does a single file's. var directoryCommitted = LastWriter(DirectoryStore(PathOf("commit-dir.cc")), (cache, key) => Assert.That(cache.Commit(batch => batch.Upsert(new Counter { Name = key.Value }, new CultRecordHandle(key))), Is.True)); var directoryFlushed = LastWriter(DirectoryStore(PathOf("flush-dir.cc")), (cache, key) => @@ -830,9 +807,11 @@ namespace GameCult.Caching.Tests writer.FlushAllBackingStores(); } + // A record of a schema this cache does not register is not corruption: the store opens and carries it (ForeignRecordTests). var bytes = File.ReadAllBytes(unknown); using var narrow = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(Counter) })); - Assert.That(() => narrow.AddBackingStore(new SingleFileMessagePackBackingStore(unknown)), Throws.Exception, "a store with an unknown schema opened"); + narrow.AddBackingStore(new SingleFileMessagePackBackingStore(unknown)); + Assert.That(narrow.BackingStores.Single().ForeignRecords, Has.Count.EqualTo(1)); Assert.That(File.ReadAllBytes(unknown), Is.EqualTo(bytes), "opening rewrote a store with an unknown schema"); } @@ -1029,6 +1008,10 @@ namespace GameCult.Caching.Tests private static StoreFactory DirectoryStore(string path, CultDocumentRegistry? registry = null) => new(path, () => new DirectoryMessagePackBackingStore(path), registry); + // The id Wide is stored under, which the later versions declare compatible: a cache reads and writes a record under another + // id only when its type declares it. SeedDrift checks that the constant is Wide's id. + private const string WideDriftSchemaId = "sha256:6033aeca8a784899aff1f8c43eb9df6bf3364e4fb6014e423c65afd51a9b8f18"; + private static readonly CultRecordKey DriftKey = new("drift"); private static readonly CultDocumentRegistry WideDrift = CultDocumentRegistry.ForTypes(new[] { typeof(ConditionalDriftWide) }); private static readonly CultDocumentRegistry NarrowDrift = CultDocumentRegistry.ForTypes(new[] { typeof(ConditionalDriftNarrow) }); @@ -1040,6 +1023,7 @@ namespace GameCult.Caching.Tests // The record at DriftKey, written under v1. private void SeedDrift(bool directory) { + Assert.That(WideDrift.GetRequired(typeof(ConditionalDriftWide)).SchemaId, Is.EqualTo(WideDriftSchemaId)); using var cache = Drift(directory, WideDrift).Open(); Assert.That(cache.Commit(batch => batch.Upsert( new ConditionalDriftWide { Name = "drift", Value = 1, Extra = "extra" }, @@ -1052,65 +1036,6 @@ namespace GameCult.Caching.Tests batch.Upsert(new ConditionalDriftNarrow { Name = "drift", Value = value }, new CultRecordHandle(DriftKey)); }); - // A store derived from CacheBackingStore alone, as a consumer would write one: it rewrites the whole file on every - // write, in the single-file MessagePack format, and marks each record it rewrote. - private sealed class WholeFileStore : CacheBackingStore - { - private readonly string _path; - - public WholeFileStore(string path) => _path = path; - - private CultPersistedStoreSnapshot Read() => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path)); - - public override void PullAll() => Held(() => - { - var snapshot = Read(); - var loaded = snapshot.Records - .Select(record => ToStoredDocument(record, snapshot.SchemaCatalog, - (type, payload) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, Registry), out _)) - .ToArray(); - Loaded?.Invoke(loaded, Array.Empty()); - foreach (var stored in loaded) - Entries[stored.Key.Value] = stored; - }); - - public override void Push(CultStoredDocument entry) => Held(() => - { - Entries[entry.Key.Value] = entry; - IsDirty = true; - }); - - public override void Delete(CultStoredDocument entry) => throw new NotSupportedException(); - - public void Mark(CultStoredDocument entry) => MarkWritten(entry); - - public override void PushAll() => Held(Write); - - public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => Held(() => - { - if (!request.ConditionsHold(Read().Records, Entries.Values)) - return CultCommitOutcome.Mismatch; - foreach (var entry in request.Upserts) - Entries[entry.Key.Value] = entry; - Write(); - return CultCommitOutcome.Committed; - }); - - private void Write() - { - var snapshot = new CultPersistedStoreSnapshot - { - Records = Entries.Values.Select(entry => ToPersistedRecord(entry, - document => CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType(), Registry))).ToArray(), - SchemaCatalog = Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry()).ToArray() - }; - File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); - foreach (var entry in Entries.Values) - MarkWritten(entry); - MarkFlushSucceeded(); - } - } - // Pauses a pull between reading the file and handing the records to the cache. private sealed class PausingStore : SingleFileMessagePackBackingStore { @@ -1130,9 +1055,9 @@ namespace GameCult.Caching.Tests base.Push(entry); } - protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data) + protected override (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) ReadStore(byte[] data) { - var snapshot = base.DeserializeSnapshot(data); + var snapshot = base.ReadStore(data); if (Pause is { } pause) { Paused = true; @@ -1196,7 +1121,7 @@ namespace GameCult.Caching.Tests public string Extra = string.Empty; } - [CultDocument("tests.conditional_drift", "tests.conditional_drift.v2")] + [CultDocument("tests.conditional_drift", "tests.conditional_drift.v2", CompatibleSchemaIds = new[] { WideDriftSchemaId })] [MessagePackObject(AllowPrivate = true)] internal sealed class ConditionalDriftNarrow { @@ -1208,7 +1133,7 @@ namespace GameCult.Caching.Tests public int Value; } - [CultDocument("tests.conditional_drift", "tests.conditional_drift.v3")] + [CultDocument("tests.conditional_drift", "tests.conditional_drift.v3", CompatibleSchemaIds = new[] { WideDriftSchemaId })] [MessagePackObject(AllowPrivate = true)] internal sealed class ConditionalDriftReused { diff --git a/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs b/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs index 2e061f47..5f60544d 100644 --- a/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs +++ b/tests/GameCult.Caching.Tests/CultDocumentRegistryTests.cs @@ -1,9 +1,8 @@ using System; using System.Linq; -using System.Reflection; -using System.Reflection.Emit; using System.Threading.Tasks; using NUnit.Framework; +using static GameCult.Caching.Tests.EmittedDocumentTypes; namespace GameCult.Caching.Tests; @@ -13,8 +12,8 @@ public sealed class CultDocumentRegistryTests [Test] public void ForTypesContainsOnlyExplicitDocuments() { - var requested = CreateDocumentType("explicit", "tests.registry.explicit", "v1"); - var unrelated = CreateDocumentType("unrelated", "tests.registry.unrelated", "v1"); + var requested = Emit("explicit", "tests.registry.explicit", "v1"); + var unrelated = Emit("unrelated", "tests.registry.unrelated", "v1"); var registry = CultDocumentRegistry.ForTypes(new[] { requested, requested }); @@ -26,7 +25,7 @@ public sealed class CultDocumentRegistryTests public void IdenticalTypeRegistrationIsIdempotent() { var registry = new CultDocumentRegistry(); - var type = CreateDocumentType("idempotent", "tests.registry.idempotent", "v1"); + var type = Emit("idempotent", "tests.registry.idempotent", "v1"); var first = registry.GetRequired(type); var second = registry.GetRequired(type); @@ -34,34 +33,12 @@ public sealed class CultDocumentRegistryTests Assert.That(second, Is.SameAs(first)); } - [Test] - public void ExactWireCompatibleAliasRegistersByTypeWithOneCanonicalSchemaDescriptor() - { - var registry = new CultDocumentRegistry(); - var firstType = CreateDocumentType("alias_owner", "tests.registry.alias", "v1"); - var aliasType = CreateDocumentType("alias_claimant", "tests.registry.alias", "v1"); - - var canonical = registry.GetRequired(firstType); - var alias = registry.GetRequired(aliasType); - - Assert.Multiple(() => - { - Assert.That(alias.DocumentType, Is.EqualTo(aliasType)); - Assert.That(alias.SchemaId, Is.EqualTo(canonical.SchemaId)); - Assert.That(registry.GetRequired(aliasType), Is.SameAs(alias)); - Assert.That(registry.GetRequiredBySchemaId(canonical.SchemaId), Is.SameAs(canonical)); - Assert.That( - registry.AllDescriptors.Count(descriptor => descriptor.SchemaId == canonical.SchemaId), - Is.EqualTo(2)); - }); - } - [Test] public void DifferentVersionsSharingSchemaNameRegisterWithoutReflectionOrderSelection() { var registry = new CultDocumentRegistry(); - var firstType = CreateDocumentType("version_one", "tests.registry.versioned", "v1"); - var secondType = CreateDocumentType("version_two", "tests.registry.versioned", "v2"); + var firstType = Emit("version_one", "tests.registry.versioned", "v1"); + var secondType = Emit("version_two", "tests.registry.versioned", "v2"); var first = registry.GetRequired(firstType); var second = registry.GetRequired(secondType); @@ -86,33 +63,11 @@ public sealed class CultDocumentRegistryTests .And.Message.Contains("v2")); } - [Test] - public void IncompatibleTypesClaimingSameSchemaNameAndVersionFailBeforeMutation() - { - var registry = new CultDocumentRegistry(); - var firstType = CreateDocumentType("layout_owner", "tests.registry.layout_collision", "v1"); - var secondType = CreateDocumentType( - "layout_claimant", - "tests.registry.layout_collision", - "v1", - addStringField: true); - var first = registry.GetRequired(firstType); - - Assert.That( - () => registry.GetRequired(secondType), - Throws.TypeOf() - .With.Message.Contains("schema name 'tests.registry.layout_collision' version 'v1'") - .And.Message.Contains(firstType.FullName) - .And.Message.Contains(secondType.FullName)); - Assert.That(registry.GetRequiredBySchemaId(first.SchemaId), Is.SameAs(first)); - Assert.That(registry.AllDescriptors.Any(descriptor => descriptor.DocumentType == secondType), Is.False); - } - [Test] public void ConcurrentRefreshAndReadsObserveCompleteRegistrySnapshots() { var registry = new CultDocumentRegistry(); - var type = CreateDocumentType("refresh_visible", "tests.registry.refresh_visible", "v1"); + var type = Emit("refresh_visible", "tests.registry.refresh_visible", "v1"); var descriptor = registry.GetRequired(type); Parallel.For(0, 20, _ => @@ -123,33 +78,94 @@ public sealed class CultDocumentRegistryTests }); } - private static Type CreateDocumentType( - string typeName, - string schemaName, - string schemaVersion, - bool addStringField = false) + // A second type for one schema is refused, typed, naming both and the claimed id, and the registry keeps what it had. + private static void AssertSecondClaimRefused(Type first, Type second) { - var assembly = AssemblyBuilder.DefineDynamicAssembly( - new AssemblyName("GameCult.Caching.RegistryTests." + Guid.NewGuid().ToString("N")), - AssemblyBuilderAccess.Run); - var module = assembly.DefineDynamicModule("main"); - var type = module.DefineType( - typeName, - TypeAttributes.Class | TypeAttributes.Public | TypeAttributes.Sealed); - var attributeConstructor = typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!; - type.SetCustomAttribute(new CustomAttributeBuilder( - attributeConstructor, - new object[] { schemaName, schemaVersion })); - type.SetCustomAttribute(new CustomAttributeBuilder( - typeof(global::MessagePack.MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, - new object[] { false })); - if (addStringField) + var schemaId = CultDocumentRegistry.ForTypes(new[] { second }).GetRequired(second).SchemaId; + var refusal = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { first, second }))!; + Assert.Multiple(() => { - type.DefineField("Value", typeof(string), FieldAttributes.Public).SetCustomAttribute(new CustomAttributeBuilder( - typeof(global::MessagePack.KeyAttribute).GetConstructor(new[] { typeof(int) })!, - new object[] { 0 })); - } + Assert.That(refusal.SchemaId, Is.EqualTo(schemaId)); + Assert.That(refusal.Message, Does.Contain(first.FullName).And.Contain(second.FullName)); + }); - return type.CreateType()!; + var registry = CultDocumentRegistry.ForTypes(new[] { first }); + Assert.That(() => registry.GetRequired(second), Throws.TypeOf()); + Assert.That(registry.AllDescriptors.Select(descriptor => descriptor.DocumentType), Is.EqualTo(new[] { first })); + } + + private static CultSchemaCatalogEntry[] CatalogOf(params Type[] types) => + types.Select(type => CultDocumentRegistry.ForTypes(new[] { type }).GetRequired(type).ToCatalogEntry()).ToArray(); + + // Owner beats lister: a type that owns an id and one that lists it register together in either order (the v1 class kept + // beside a v2 class declaring v1's id). A record under the id resolves to its owner; one under the lister's own id to the + // lister; without the owner registered, the id resolves to the lister. + [TestCase(false)] + [TestCase(true)] + public void AnOwnerAndATypeListingItsIdRegisterTogetherAndTheOwnerWins(bool reversed) + { + var owner = Emit("owns", "tests.registry.owns", "v1"); + var ownerId = CultDocumentRegistry.ForTypes(new[] { owner }).GetRequired(owner).SchemaId; + var lister = Emit("lists", "tests.registry.owns", "v2", new[] { new Field("Extra", typeof(string), 0) }, new[] { ownerId }); + var listerId = CultDocumentRegistry.ForTypes(new[] { lister }).GetRequired(lister).SchemaId; + var catalog = CatalogOf(owner, lister); + + var registry = CultDocumentRegistry.ForTypes(reversed ? new[] { lister, owner } : new[] { owner, lister }); + + Assert.Multiple(() => + { + Assert.That(registry.AllDescriptors.Select(descriptor => descriptor.DocumentType), Is.EquivalentTo(new[] { owner, lister })); + Assert.That(registry.ResolvePersistedSchema(ownerId, catalog).DocumentType, Is.EqualTo(owner)); + Assert.That(registry.ResolvePersistedSchema(listerId, catalog).DocumentType, Is.EqualTo(lister)); + Assert.That(CultDocumentRegistry.ForTypes(new[] { lister }).ResolvePersistedSchema(ownerId, catalog).DocumentType, Is.EqualTo(lister)); + }); + } + + // Two types listing one id register together in either order. With an owner registered, a record under the id is the + // owner's; with none, the id names no single type, and a record under it is refused, typed, naming the listers. + [TestCase(false)] + [TestCase(true)] + public void TwoTypesListingOneIdRegisterAndARecordUnderItNeedsAnOwner(bool reversed) + { + var owner = Emit("shared_owner", "tests.registry.shared", "v1"); + var sharedId = CultDocumentRegistry.ForTypes(new[] { owner }).GetRequired(owner).SchemaId; + var a = Emit("declares_a", "tests.registry.declares_a", "v1", compatibleSchemaIds: new[] { sharedId }); + var b = Emit("declares_b", "tests.registry.declares_b", "v1", compatibleSchemaIds: new[] { sharedId }); + var listers = reversed ? new[] { b, a } : new[] { a, b }; + var catalog = CatalogOf(owner); + + var ambiguous = CultDocumentRegistry.ForTypes(listers); + var refusal = Assert.Throws(() => ambiguous.ResolvePersistedSchema(sharedId, catalog))!; + Assert.Multiple(() => + { + Assert.That(refusal.SchemaId, Is.EqualTo(sharedId)); + Assert.That(refusal.SchemaNames, Is.EquivalentTo(new[] { "tests.registry.declares_a", "tests.registry.declares_b" })); + Assert.That(refusal.Message, Does.Contain(a.FullName).And.Contain(b.FullName)); + }); + + Assert.That(CultDocumentRegistry.ForTypes(listers.Append(owner)).ResolvePersistedSchema(sharedId, catalog).DocumentType, Is.EqualTo(owner)); + Assert.That(CultDocumentRegistry.ForTypes(listers.Prepend(owner)).ResolvePersistedSchema(sharedId, catalog).DocumentType, Is.EqualTo(owner)); + } + + // One type per schema, in every runtime: a second type of the same schema name and version is refused whichever registers + // first, whether it is identical to the first, declares different compatible ids, or has different members. + [TestCase("identical", false)] + [TestCase("identical", true)] + [TestCase("declaration", false)] + [TestCase("declaration", true)] + [TestCase("members", false)] + [TestCase("members", true)] + public void ASecondTypeForOneSchemaIsRefused(string difference, bool reversed) + { + var schemaName = $"tests.registry.claimed_twice_{difference}"; + var first = Emit($"first_{difference}", schemaName, "v1"); + var second = difference switch + { + "identical" => Emit($"second_{difference}", schemaName, "v1"), + "declaration" => Emit($"second_{difference}", schemaName, "v1", compatibleSchemaIds: new[] { "tests.registry.older" }), + _ => Emit($"second_{difference}", schemaName, "v1", new[] { new Field("Value", typeof(string), 0) }), + }; + + AssertSecondClaimRefused(reversed ? second : first, reversed ? first : second); } } diff --git a/tests/GameCult.Caching.Tests/CultMathExprVariantTests.cs b/tests/GameCult.Caching.Tests/CultMathExprVariantTests.cs new file mode 100644 index 00000000..181a7ff0 --- /dev/null +++ b/tests/GameCult.Caching.Tests/CultMathExprVariantTests.cs @@ -0,0 +1,79 @@ +#nullable enable +using System; +using System.IO; +using CultMath.Expr; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // An ExprGraph is a plain value with no element-id lists. It survives a single-file cache, a variant inherits it, + // a variant may override it whole, and an edit to the base reaches the inheriting variant. + public class CultMathExprVariantTests + { + [CultDocument("tests.expr_variant", "tests.expr_variant.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class ExprDoc + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public ExprGraph Graph = default!; + [Key(2)] public float Scale; + } + + private static ExprGraph Graph(float k) + { + var b = new ExprBuilder(); + var x = b.Input("x"); + var p = b.Param("k", k); + b.Output("o", b.Call("mul", x, p)); + return b.Build(); + } + + private static byte[] Enc(ExprGraph g) => MessagePackSerializer.Serialize(g, CultDocumentMessagePackSerialization.Options); + + [Test] + public void ExprGraphRoundTripsThroughACacheAndIsInheritedOrOverriddenByVariants() + { + var dir = Path.Combine(Path.GetTempPath(), "cultlib-exprvariant-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var path = Path.Combine(dir, "expr.cc"); + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(ExprDoc) }); + var baseKey = new CultRecordKey("base"); + var scaledKey = new CultRecordKey("scaled"); + var regraphedKey = new CultRecordKey("regraphed"); + + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry })) + { + cache.Commit(b => b.Upsert(typeof(ExprDoc), new ExprDoc { Name = "base", Graph = Graph(1.5f), Scale = 2f }, baseKey)); + cache.Commit(b => b.UpsertVariant(scaledKey, baseKey, new[] { cache.Override(nameof(ExprDoc.Name), "scaled"), cache.Override(nameof(ExprDoc.Scale), 3f) })); + cache.Commit(b => b.UpsertVariant(regraphedKey, baseKey, new[] { cache.Override(nameof(ExprDoc.Name), "regraphed"), cache.Override(nameof(ExprDoc.Graph), Graph(4f)) })); + } + + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry })) + { + var baseDoc = cache.Get(baseKey)!; + var scaled = cache.Get(scaledKey)!; + var regraphed = cache.Get(regraphedKey)!; + + Assert.That(Enc(baseDoc.Graph), Is.EqualTo(Enc(Graph(1.5f))), "the base graph survives the file"); + Assert.That(scaled.Scale, Is.EqualTo(3f)); + Assert.That(Enc(scaled.Graph), Is.EqualTo(Enc(Graph(1.5f))), "a variant that does not override the graph inherits it"); + Assert.That(Enc(regraphed.Graph), Is.EqualTo(Enc(Graph(4f))), "a variant that overrides the graph replaces it whole"); + Assert.That(regraphed.Scale, Is.EqualTo(2f), "and keeps the rest of the base"); + + cache.Commit(b => b.Upsert(typeof(ExprDoc), new ExprDoc { Name = "base", Graph = Graph(9f), Scale = 2f }, baseKey)); + Assert.That(Enc(cache.Get(scaledKey)!.Graph), Is.EqualTo(Enc(Graph(9f))), "an edit to the base reaches the inheriting variant"); + Assert.That(Enc(cache.Get(regraphedKey)!.Graph), Is.EqualTo(Enc(Graph(4f))), "and not the overriding one"); + } + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + } +} diff --git a/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs b/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs new file mode 100644 index 00000000..2bc1d9df --- /dev/null +++ b/tests/GameCult.Caching.Tests/ElementIdEngineTests.cs @@ -0,0 +1,1087 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // The id engine's two halves, driven directly: which element types a document type reaches, and how unset ids are filled. + public class ElementIdEngineTests + { + private static List Problems(Type type) => CultElementIds.Inspect(type).Problems; + + private static int Assign(object root, string key, bool deterministic) + { + var plan = CultElementIds.Plan(root.GetType(), root, key, deterministic); + plan.Apply(); + return plan.Count; + } + + private static void RefusesBare(Type holder) => + Assert.That(Problems(holder), Has.Count.EqualTo(1).And.All.Contain("EBare").And.All.Contain("[CultElementId]"), holder.Name); + + [Test] + public void EveryShapeThatCanHoldAListReachesItsElements() + { + foreach (var holder in new[] + { + typeof(HList), typeof(HArray), typeof(HDictionary), typeof(HNested), typeof(HListOfLists), + typeof(HElementNest), typeof(HUnion), typeof(HNullableInner), typeof(HStruct), typeof(HStructList), typeof(HNullableStruct), + typeof(HReadOnlyMap) + }) + RefusesBare(holder); + Assert.That(Problems(typeof(HFine)), Is.Empty); + } + + [Test] + public void EachWayAnIdCanBeWrongIsNamed() + { + Assert.That(Problems(typeof(HTwo)).Single(), Does.Contain("two [CultElementId] members").And.Contain("A").And.Contain("B")); + Assert.That(Problems(typeof(HInt)).Single(), Does.Contain("not a string").And.Contain("Id")); + Assert.That(Problems(typeof(HNoKey)).Single(), Does.Contain("no integer [Key]").And.Contain("Id")); + Assert.That(Problems(typeof(HGetOnly)).Single(), Does.Contain("cannot be assigned").And.Contain("Id")); + Assert.That(Problems(typeof(HMissingSource)).Single(), Does.Contain("Nope").And.Contain("not a keyed member")); + Assert.That(Problems(typeof(HBadSourceKind)).Single(), Does.Contain("Tags").And.Contain("not a string or a number")); + Assert.That(Problems(typeof(HUnkeyedSource)).Single(), Does.Contain("Loose").And.Contain("not a keyed member")); + Assert.That(Problems(typeof(HNullableSource)), Is.Empty, "a nullable number is a number"); + } + + [Test] + public void AssignFillsEveryListShapeAndOnlyTheUnsetOnes() + { + var root = new HAll + { + Dict = { ["k"] = new HHasIds { Items = { new EIded(), new EIded { Id = "0123456789ab" } } } }, + Grid = { new List { new EIded(), new EIded() } }, + Nest = { new ENest { Sub = { new EIded() } } }, + }; + var count = Assign(root, "r", deterministic: false); + Assert.That(count, Is.EqualTo(5)); + Assert.That(root.Dict["k"].Items[1].Id, Is.EqualTo("0123456789ab")); + var ids = new[] { root.Dict["k"].Items[0].Id }.Concat(root.Grid[0].Select(x => x.Id)).Concat(new[] { root.Nest[0].Id, root.Nest[0].Sub[0].Id }).ToArray(); + Assert.That(ids, Has.All.Matches(id => id.Length == 12)); + Assert.That(ids.Distinct().Count(), Is.EqualTo(ids.Length)); + Assert.That(Assign(root, "r", deterministic: false), Is.EqualTo(0), "a second pass finds nothing unset"); + } + + // Holds is what a store marks its header by: an id anywhere a list can sit counts, and an unset one does not. + [Test] + public void HoldsFindsASetIdWhereverAListCanSitAndNothingElse() + { + const string id = "0123456789ab"; + Assert.That(CultElementIds.Holds(typeof(HAll), null), Is.False); + Assert.That(CultElementIds.Holds(typeof(HAll), new HAll + { + Dict = { ["k"] = new HHasIds { Items = { new EIded() } } }, + Grid = { new List { new EIded() } }, + Nest = { new ENest { Sub = { new EIded() } } }, + }), Is.False, "unset ids hold nothing"); + Assert.That(CultElementIds.Holds(typeof(HAll), new HAll { Dict = { ["k"] = new HHasIds { Items = { new EIded { Id = id } } } } }), Is.True, "a dictionary value"); + Assert.That(CultElementIds.Holds(typeof(HAll), new HAll { Grid = { new List { new EIded { Id = id } } } }), Is.True, "a list of lists"); + Assert.That(CultElementIds.Holds(typeof(HAll), new HAll { Nest = { new ENest { Sub = { new EIded { Id = id } } } } }), Is.True, "a list under an unset element"); + Assert.That(CultElementIds.Holds(typeof(HAll), new HAll { Nest = { new ENest { Id = id } } }), Is.True, "an element"); + Assert.That(CultElementIds.Holds(typeof(HAll), new HAll { Dict = { ["a"] = new HHasIds { Items = { new EIded() } }, ["b"] = new HHasIds { Items = { new EIded { Id = id } } } } }), Is.True, "the second dictionary value"); + Assert.That(CultElementIds.Holds(typeof(HAll), new HAll { Grid = { new List { new EIded() }, new List { new EIded { Id = id } } } }), Is.True, "the second list"); + Assert.That(CultElementIds.Holds(typeof(HAll), new HAll { Nest = { new ENest(), new ENest { Sub = { new EIded { Id = id } } } } }), Is.True, "the second item of a list of elements"); + Assert.That(CultElementIds.Holds(typeof(HAll), new HAll { Grid = { new List { new EIded(), new EIded { Id = id } } } }), Is.True, "the second element of a list"); + } + + [Test] + public void APlanCountsAndChangesNothingUntilApplied() + { + var root = new HHasIds { Items = { new EIded(), new EIded() } }; + var plan = CultElementIds.Plan(typeof(HHasIds), root, "r", deterministic: true); + Assert.That(plan.Count, Is.EqualTo(2)); + Assert.That(root.Items.Select(x => x.Id), Has.All.Empty); + plan.Apply(); + Assert.That(root.Items.Select(x => x.Id), Has.All.Not.Empty); + plan.Undo(); + Assert.That(root.Items.Select(x => x.Id), Has.All.Empty); + } + + [Test] + public void ADeterministicIdDependsOnTheRecordAndThePositionAndNothingElse() + { + HHasIds Make() => new() { Items = { new EIded(), new EIded() } }; + string[] Ids(string key) + { + var root = Make(); + Assign(root, key, deterministic: true); + return root.Items.Select(x => x.Id).ToArray(); + } + + Assert.That(Ids("a"), Is.EqualTo(Ids("a"))); + Assert.That(Ids("a"), Is.Not.EqualTo(Ids("b"))); + Assert.That(Ids("a")[0], Is.Not.EqualTo(Ids("a")[1])); + Assert.That(Ids("a"), Has.All.Matches(id => Regex.IsMatch(id, "^[0-9a-f]{12}$"))); + var random = Make(); + Assign(random, "a", deterministic: false); + Assert.That(random.Items.Select(x => x.Id), Is.Not.EqualTo(Ids("a")), "a write mints random ids, a load mints from the path"); + } + + [Test] + public void ADuplicateIsRefusedInAnyListShapeNamingTheRecordTheListAndTheId() + { + var dict = new HAll { Dict = { ["k"] = new HHasIds { Items = { new EIded { Id = "aaaaaaaaaaaa" }, new EIded { Id = "aaaaaaaaaaaa" } } } } }; + var refusal = Assert.Throws(() => Assign(dict, "rec", deterministic: true))!; + Assert.That(refusal.RecordKey, Is.EqualTo("rec")); + Assert.That(refusal.ElementId, Is.EqualTo("aaaaaaaaaaaa")); + Assert.That(refusal.ListPath, Is.EqualTo("rec.0{k}.0")); + Assert.That(refusal.Message, Does.Contain("rec").And.Contain("aaaaaaaaaaaa").And.Contain("rec.0{k}.0")); + var grid = new HAll { Grid = { new List { new EIded { Id = "bbbbbbbbbbbb" }, new EIded { Id = "bbbbbbbbbbbb" } } } }; + Assert.Throws(() => Assign(grid, "r", deterministic: false)); + } + + [Test] + public void TheHexRefusalNamesTheDerivedFormForReadableIds() + { + var refusal = Assert.Throws(() => Assign(new HHasIds { Items = { new EIded { Id = "root" } } }, "r", deterministic: false))!; + Assert.That(refusal.Message, Does.Contain("12 lowercase hex").And.Contain("[CultElementId(nameof(Member))]")); + } + + [Test] + public void ARandomIdMustBeTwelveLowercaseHexCharactersAndADerivedIdNeedOnlyBeText() + { + foreach (var bad in new[] { "x", "ABCDEF012345", "0123456789a", "0123456789abc", "0123456789ag", "0123456789 a" }) + { + var refusal = Assert.Throws(() => Assign(new HHasIds { Items = { new EIded { Id = bad } } }, "r", deterministic: false), bad)!; + Assert.That(refusal.Message, Does.Contain("12 lowercase hex").And.Contain(bad), bad); + Assert.That(refusal.ElementId, Is.EqualTo(bad)); + } + + Assert.That(Assign(new HHasIds { Items = { new EIded { Id = "0123456789ab" }, new EIded { Id = "abcdef012345" } } }, "r", deterministic: false), Is.EqualTo(0)); + Assert.That(Assign(new HDerived { Items = { new EDerived { Number = 5, Id = "not hex, not the source" } } }, "r", deterministic: false), Is.EqualTo(0)); + } + + [Test] + public void ADerivedIdWhoseSourceIsNullOrEmptyIsRefusedNamingTheSourceMember() + { + foreach (var name in new string?[] { null, "" }) + { + var refusal = Assert.Throws( + () => Assign(new HDerivedText { Items = { new EDerivedText { Name = "fine" }, new EDerivedText { Name = name } } }, "rec", deterministic: false), + name ?? "null")!; + Assert.That(refusal.Member, Is.EqualTo("Name")); + Assert.That(refusal.RecordKey, Is.EqualTo("rec")); + Assert.That(refusal.Message, Does.Contain("Name").And.Contain("null or empty")); + } + + Assert.That(Assign(new HDerivedText { Items = { new EDerivedText { Name = "a" }, new EDerivedText { Name = "b" } } }, "r", deterministic: false), Is.EqualTo(2)); + } + + [Test] + public void APlanThatRefusesHasChangedNothingAndAnUndoRestoresWhatWasThere() + { + var root = new HAll + { + Dict = { ["k"] = new HHasIds { Items = { new EIded(), new EIded() } } }, + Grid = { new List { new EIded { Id = "cccccccccccc" }, new EIded { Id = "cccccccccccc" } } }, + }; + Assert.Throws(() => Assign(root, "r", deterministic: false)); + Assert.That(root.Dict["k"].Items.Select(x => x.Id), Has.All.Empty, "the list before the refused one was not minted into"); + + var ok = new HHasIds { Items = { new EIded(), new EIded { Id = "dddddddddddd" } } }; + var plan = CultElementIds.Plan(typeof(HHasIds), ok, "r", deterministic: false); + plan.Apply(); + plan.Undo(); + Assert.That(ok.Items.Select(x => x.Id), Is.EqualTo(new[] { "", "dddddddddddd" })); + } + + [Test] + public void ADeterministicIdIsTheSameUnderEveryCultureIncludingDictionaryKeys() + { + var expected = new[] + { + Sha6("r|r.0{1.5}.0/0|0"), Sha6("r|r.1{-3}.0/0|0"), Sha6("r|r.2{09/29/2026 12:00:00}.0/0|0") + }; + string[] Mint() + { + var root = new HCulture + { + ByDouble = { [1.5] = new HHasIds { Items = { new EIded() } } }, + ByInt = { [-3] = new HHasIds { Items = { new EIded() } } }, + ByDate = { [new DateTime(2026, 9, 29, 12, 0, 0, DateTimeKind.Unspecified)] = new HHasIds { Items = { new EIded() } } }, + }; + Assign(root, "r", deterministic: true); + return new[] { root.ByDouble[1.5].Items[0].Id, root.ByInt[-3].Items[0].Id, root.ByDate.Values.Single().Items[0].Id }; + } + + var saved = System.Globalization.CultureInfo.CurrentCulture; + try + { + foreach (var name in new[] { "en-US", "de-DE", "sv-SE", "ar-SA" }) + { + System.Globalization.CultureInfo.CurrentCulture = System.Globalization.CultureInfo.GetCultureInfo(name); + if (name == "de-DE") + Assert.That(1.5.ToString(), Is.EqualTo("1,5"), "the runtime has no culture data, so this test would prove nothing"); + Assert.That(Mint(), Is.EqualTo(expected), name); + } + } + finally + { + System.Globalization.CultureInfo.CurrentCulture = saved; + } + } + + [Test] + public void AnInterfaceUnionIsJudgedOnItsConcreteTypesAndMintedIntoThem() + { + Assert.That(Problems(typeof(HIfaceBad)).Single(), Does.Contain("IfaceBad").And.Contain("no [CultElementId] member")); + Assert.That(Problems(typeof(HIfaceGood)), Is.Empty); + var root = new HIfaceGood { Items = { new IfaceGood(), new IfaceGood() } }; + Assert.That(Assign(root, "r", deterministic: false), Is.EqualTo(2)); + Assert.That(root.Items.Select(x => ((IfaceGood)x).Id), Has.All.Not.Empty); + } + + [Test] + public void AnAbstractUnionBaseIsNotJudgedAndASubtypeListedTwiceIsJudgedOnce() + { + Assert.That(Problems(typeof(HDup)).Single(), Does.Contain("DupSub").And.Contain("no [CultElementId] member")); + Assert.That(Problems(typeof(HAbstractNoId)), Is.Empty, "the abstract base has no id, and no element can be one"); + } + + [Test] + public void ASubtypeThatReusesTheBaseIdsKeyIsRefusedAtRegistration() + { + var problem = Problems(typeof(HKeyClash)).Single(); + Assert.That(problem, Does.Contain("KeyClash").And.Contain("Id").And.Contain("Clash").And.Contain("Key(0)")); + } + + private static string Sha6(string text) + { + using var sha = System.Security.Cryptography.SHA256.Create(); + return string.Concat(sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(text)).Take(6).Select(b => b.ToString("x2"))); + } + + [Test] + public void TheDeterministicIdIsThePinnedDerivationAndARetryFollowsACollision() + { + var plain = new HHasIds { Items = { new EIded(), new EIded() } }; + Assign(plain, "r", deterministic: true); + Assert.That(plain.Items.Select(x => x.Id), Is.EqualTo(new[] { Sha6("r|r.0/0|0"), Sha6("r|r.0/1|0") })); + + var collide = new HHasIds { Items = { new EIded(), new EIded { Id = Sha6("r|r.0/0|0") } } }; + Assign(collide, "r", deterministic: true); + Assert.That(collide.Items[0].Id, Is.EqualTo(Sha6("r|r.0/0|1"))); + + var dict = new HAll { Dict = { ["k"] = new HHasIds { Items = { new EIded() } } } }; + Assign(dict, "r", deterministic: true); + Assert.That(dict.Dict["k"].Items[0].Id, Is.EqualTo(Sha6("r|r.0{k}.0/0|0"))); + } + + [Test] + public void ANullElementIsSkippedAndAMintedIdNeverEqualsAnExplicitOne() + { + var root = new HHasIds { Items = { null!, new EIded(), new EIded { Id = "abcdef012345" } } }; + Assert.That(Assign(root, "r", deterministic: false), Is.EqualTo(1)); + Assert.That(root.Items[1].Id, Is.Not.Empty.And.Not.EqualTo("explicit")); + } + + [Test] + public void ADerivedIdFollowsItsSourceIncludingANullableNumberAndDuplicatesAreRefused() + { + var root = new HDerived { Items = { new EDerived { Number = 5 }, new EDerived { Number = 12 }, new EDerived { Number = 5, Id = "own" } } }; + Assign(root, "r", deterministic: false); + Assert.That(root.Items.Select(x => x.Id), Is.EqualTo(new[] { "5", "12", "own" })); + var twice = new HDerived { Items = { new EDerived { Number = 5 }, new EDerived { Number = 5 } } }; + Assert.That(Assert.Throws(() => Assign(twice, "r", deterministic: false))!.Message, Does.Contain("'5'")); + } + + // A list element is written by its [Key] layout or its id never reaches disk. A union arm without [MessagePackObject] + // is written by some other formatter; a list of a type that is not keyed at all is a value list, replaced whole. + [Test] + public void ANonKeyedArmOfAListUnionIsRefusedAtRegistration() + { + var problem = Problems(typeof(HUnkeyedArmList)).Single(); + Assert.That(problem, Does.Contain("UnkeyedArm").And.Contain("formatter the cache does not own").And.Contain("HUnkeyedArmList")); + Assert.That(Problems(typeof(HUnkeyedArmField)), Is.Empty, "the same union as a field has no element id to lose"); + Assert.That(Problems(typeof(HValueList)), Is.Empty, "a list of a non-keyed type is a value list"); + } + + // [MessagePackFormatter] on an element type, an owner, a collection class or a member makes that value one whole value: it + // has no element ids and is off the id path. The attribute on the id member itself is the one refusal that stays. + [Test] + public void AFormatterAttributeMakesAWholeValueExceptOnTheId() + { + Assert.That(Problems(typeof(HFormattedId)).Single(), Does.Contain("Id").And.Contain("[MessagePackFormatter]")); + foreach (var (type, holder) in new (Type, object)[] + { + (typeof(HFormattedElement), new HFormattedElement { Items = { new FormattedElement() } }), + (typeof(HFormattedList), new HFormattedList { Items = { new EIded() } }), + (typeof(HFormattedOwner), new HFormattedOwner { Owner = { Items = { new EIded() } } }), + (typeof(HFormattedMember), new HFormattedMember { Inner = { Items = { new EIded() } } }), + (typeof(HFormattedDerived), new HFormattedDerived { Items = { new FormattedDerived() } }), + }) + { + Assert.That(Problems(type), Is.Empty, type.Name); + Assert.That(CultElementIds.Inspect(type).IdPath, Is.Empty, type.Name); + Assert.That(Assign(holder, "r", deterministic: false), Is.EqualTo(0), type.Name); + } + + Assert.That(Problems(typeof(HFormattedField)), Is.Empty, "a formatted type with no list below it is a field"); + } + + // The collection types between a member and its object list are written by some formatter too, so they sit on the id path. + // A value list (of a struct, a primitive or a non-keyed class) never reaches an object, so it does not. + [Test] + public void ACollectionTypeOnTheWayToAnObjectListIsOnTheIdPath() + { + var path = CultElementIds.Inspect(typeof(HCollections)).IdPath; + Assert.That(path, Is.EquivalentTo(new[] + { + typeof(HCollections), typeof(EIded), typeof(HHasIds), + typeof(List), typeof(EIded[]), typeof(Dictionary), typeof(List>) + })); + + var valueLists = CultElementIds.Inspect(typeof(HValueListsBesideObjects)).IdPath; + Assert.That(valueLists, Is.EquivalentTo(new[] { typeof(HValueListsBesideObjects), typeof(List), typeof(EIded) })); + } + + [Test] + public void AFormatterOnACollectionClassOfAnObjectListIsAWholeValue() + { + Assert.That(Problems(typeof(HRoles)), Is.Empty); + Assert.That(Assign(new HRoles { Items = { new EIded() } }, "r", deterministic: false), Is.EqualTo(0), "its elements carry no ids and are never minted"); + Assert.That(Problems(typeof(HFormattedValueCollection)), Is.Empty, "a collection class of a non-keyed element is a value list"); + } + + // MemberInfo.IsDefined ignores inherit for properties: an override sees its base's attribute only through Declares. + [Test] + public void AnInheritedFormatterOnAnOverriddenMemberIsHonoured() + { + Assert.That(Problems(typeof(HOverFormattedId)).Single(), Does.Contain("Id").And.Contain("[MessagePackFormatter]")); + Assert.That(Problems(typeof(OwnerOverFormatted)), Is.Empty, "the override inherits its base's formatter, so the member is a whole value"); + Assert.That(CultElementIds.Inspect(typeof(OwnerOverFormatted)).IdPath, Is.Empty, "a walked override would put its list on the id path"); + Assert.That(Assign(new OwnerOverFormatted { Items = { new EIded() } }, "r", deterministic: false), Is.EqualTo(0), "and mint into a list its formatter may drop ids from"); + + Assert.That(Problems(typeof(HIdOverride)), Is.Empty, "a base [CultElementId] is the override's id"); + var root = new HIdOverride { Items = { new IdOverride() } }; + Assert.That(Assign(root, "r", deterministic: false), Is.EqualTo(1)); + Assert.That(root.Items[0].Id, Has.Length.EqualTo(12)); + } + + // The union arm has a valid id, so IdProblem passes it and only the rule that an element is written by its [Key] layout + // can refuse it; it is refused once per document however many lists hold it. + [Test] + public void ANonKeyedArmWithAValidIdIsRefusedOncePerType() + { + Assert.That(Problems(typeof(HUnkeyedArmList)).Single(), Does.Contain("UnkeyedArm").And.Contain("formatter the cache does not own")); + Assert.That(Problems(typeof(HUnkeyedTwoLists)).Single(), Does.Contain("UnkeyedArm")); + Assert.That(Problems(typeof(HUnkeyedArmField)), Is.Empty); + } + + // The id path is a fixed point, not the stack at the moment a list is found: a type in a cycle with the list's owner, and + // a type reached twice, are both on the way to it. + [Test] + public void TheIdPathHoldsEveryTypeThatReachesAnObjectList() + { + var path = CultElementIds.Inspect(typeof(HDiamond)).IdPath; + Assert.That(path, Is.EquivalentTo(new[] { typeof(HDiamond), typeof(HLeft), typeof(HRight), typeof(HCycle), typeof(HCycleBack), typeof(EIded), typeof(List) })); + Assert.That(CultElementIds.Inspect(typeof(HFine)).IdPath, Is.EquivalentTo(new[] { typeof(HFine), typeof(ENest), typeof(EIded), typeof(List), typeof(List) })); + Assert.That(CultElementIds.Inspect(typeof(HFormattedField)).IdPath, Is.Empty); + + // A union base is on the path both as the type a list holds (its formatter writes the arm) and as a field type whose + // arm holds the list. + Assert.That(CultElementIds.Inspect(typeof(HPlainBaseList)).IdPath, Is.EquivalentTo(new[] { typeof(HPlainBaseList), typeof(PlainBase), typeof(PlainArm), typeof(List) })); + Assert.That(CultElementIds.Inspect(typeof(HIdBaseOwner)).IdPath, Is.EquivalentTo(new[] { typeof(HIdBaseOwner), typeof(IdBase), typeof(IdArm), typeof(EIded), typeof(List) })); + } + + // A keyed struct is a value MessagePack writes by key, and an object list inside one carries element ids like any other. + // It is walked as an owner and is never an element: a boxed copy cannot carry a minted id. + [Test] + public void AKeyedStructIsWalkedAsAnOwnerAndNeverAnElement() + { + var root = new HStructIded + { + Hold = new SHoldIded { Items = new List { new EIded() } }, + Many = { new SHoldIded { Items = new List { new EIded() } } }, + }; + Assert.That(Problems(typeof(HStructIded)), Is.Empty); + Assert.That(CultElementIds.Inspect(typeof(HStructIded)).IdPath, + Is.EquivalentTo(new[] { typeof(HStructIded), typeof(SHoldIded), typeof(List), typeof(List), typeof(EIded) })); + Assert.That(CultElementIds.Holds(typeof(HStructIded), root), Is.False); + Assert.That(Assign(root, "r", deterministic: false), Is.EqualTo(2)); + Assert.That(root.Hold.Items[0].Id, Is.Not.Empty, "visible through the struct copy"); + Assert.That(root.Many[0].Items[0].Id, Is.Not.Empty, "visible through the struct in a list"); + Assert.That(CultElementIds.Holds(typeof(HStructIded), root), Is.True); + var one = new HStructIded { Hold = new SHoldIded { Items = new List { new EIded { Id = "0123456789ab" } } } }; + Assert.That(CultElementIds.Holds(typeof(HStructIded), one), Is.True); + + Assert.That(Problems(typeof(HStructValues)), Is.Empty); + Assert.That(CultElementIds.Inspect(typeof(HStructValues)).IdPath, + Is.EquivalentTo(new[] { typeof(HStructValues), typeof(List), typeof(EIded) }), "a list of keyed structs is a value list"); + var values = new HStructValues { Structs = { new SValue { N = 1 } }, Objects = { new EIded() } }; + Assert.That(Assign(values, "r", deterministic: false), Is.EqualTo(1)); + + var nullable = new HNullableStructIded { Hold = new SHoldIded { Items = new List { new EIded() } } }; + Assert.That(Assign(nullable, "r", deterministic: false), Is.EqualTo(1), "a nullable struct is walked as its struct"); + Assert.That(CultElementIds.Holds(typeof(HNullableStructIded), nullable), Is.True); + } + + // A type is an owner or an element only when MessagePack serializes it by key ([MessagePackObject], inherited, or + // [Union]). A struct without either is a value however many [Key] members it carries. + [Test] + public void OnlyAKeyedTypeIsWalked() + { + Assert.That(Problems(typeof(HUnkeyedStruct)), Is.Empty); + Assert.That(CultElementIds.Inspect(typeof(HUnkeyedStruct)).IdPath, Is.Empty); + Assert.That(Problems(typeof(HInheritedBare)).Single(), Does.Contain("InheritedBare").And.Contain("[CultElementId]"), + "[MessagePackObject] reaches a subclass through inheritance"); + } + + // Every dictionary interface MessagePack writes as a map is walked by one path, and the minted path text is the one a + // store loaded before ids already holds, so its deterministic ids do not move. + [Test] + public void EveryMapInterfaceIsWalkedByTheSamePath() + { + var element = new Func(() => new HHasIds { Items = { new EIded() } }); + var viaInterface = new HReadOnlyIded { Map = new Dictionary { ["k"] = element() } }; + var viaClass = new HReadOnlyIded { Map = new RoMap(new Dictionary { ["k"] = element() }) }; + var viaDictionary = new HMapIded { Map = { ["k"] = element() } }; + Assert.That(Assign(viaInterface, "r", deterministic: true), Is.EqualTo(1)); + Assert.That(Assign(viaClass, "r", deterministic: true), Is.EqualTo(1), "a class implementing only IReadOnlyDictionary<,>"); + Assert.That(Assign(viaDictionary, "r", deterministic: true), Is.EqualTo(1)); + var id = viaDictionary.Map["k"].Items[0].Id; + Assert.That(viaInterface.Map["k"].Items[0].Id, Is.EqualTo(id)); + Assert.That(viaClass.Map["k"].Items[0].Id, Is.EqualTo(id)); + + // SHA-256 of "||0": slot 0, the key in braces, slot 0, the index. + var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes("r|r.0{k}.0/0|0")); + Assert.That(id, Is.EqualTo(Convert.ToHexString(hash, 0, 6).ToLowerInvariant()), "the path text is unchanged"); + + var held = new HReadOnlyIded { Map = new RoMap(new Dictionary { ["k"] = new HHasIds { Items = { new EIded { Id = "0123456789ab" } } } }) }; + Assert.That(CultElementIds.Holds(typeof(HReadOnlyIded), held), Is.True); + Assert.That(CultElementIds.Holds(typeof(HReadOnlyIded), viaInterface), Is.True); + Assert.That(CultElementIds.Holds(typeof(HMapIded), new HMapIded { Map = { ["k"] = new HHasIds { Items = { new EIded() } } } }), Is.False); + } + + // Registration judges the declared shape, so minting and reading follow it: a member declared object is untyped on disk, + // and what a subclass adds under a base-typed member is not written, so neither is walked. + [Test] + public void TheWalkFollowsTheDeclaredShape() + { + var derived = new HBase { B = new DerC { L = { new EIded(), new EIded { Id = "0123456789ab" } } } }; + Assert.That(CultElementIds.Plan(typeof(HBase), derived, "r", deterministic: false).Count, Is.Zero); + Assert.That(CultElementIds.Holds(typeof(HBase), derived), Is.False); + var opaque = new HObject { O = new HHasIds { Items = { new EIded(), new EIded { Id = "0123456789ab" } } } }; + Assert.That(CultElementIds.Plan(typeof(HObject), opaque, "r", deterministic: false).Count, Is.Zero); + Assert.That(CultElementIds.Holds(typeof(HObject), opaque), Is.False); + foreach (var type in new[] { typeof(HBase), typeof(HObject) }) + { + Assert.That(Problems(type), Is.Empty, type.Name); + Assert.That(CultElementIds.Inspect(type).IdPath, Is.Empty, type.Name); + } + + // A union base still writes its arm, so the arm is walked. + var arm = new HIdBaseOwner { Root = new IdArm { Items = { new EIded() } } }; + Assert.That(Assign(arm, "r", deterministic: false), Is.EqualTo(1)); + } + + // MessagePack writes a non-public [Key] member only under AllowPrivate, so a non-public member of a type without it is nobody's + // id business; under AllowPrivate it is written, read and minted like any other member. + [Test] + public void ANonPublicMemberIsWalkedOnlyUnderAllowPrivate() + { + foreach (var type in new[] { typeof(HHiddenList), typeof(HHiddenOwner), typeof(HHiddenFromBase) }) + { + Assert.That(Problems(type), Is.Empty, type.Name); + Assert.That(CultElementIds.Inspect(type).IdPath, Is.Empty, type.Name); + } + + Assert.That(Problems(typeof(HOptHidden)), Is.Empty); + Assert.That(CultElementIds.Inspect(typeof(HOptHidden)).IdPath, Is.EquivalentTo(new[] { typeof(HOptHidden), typeof(List), typeof(EIded) })); + var opt = new HOptHidden(); + opt.Peek.Add(new EIded()); + opt.Peek.Add(new EIded()); + Assert.That(Assign(opt, "r", deterministic: false), Is.EqualTo(2)); + Assert.That(opt.Peek.Select(item => item.Id), Has.All.Match("^[0-9a-f]{12}$")); + + Assert.That(Problems(typeof(HHiddenLeaf)), Is.Empty); + Assert.That(CultElementIds.Inspect(typeof(HHiddenLeaf)).IdPath, Is.EquivalentTo(new[] { typeof(HHiddenLeaf), typeof(List), typeof(EIded) }), + "a non-public member adds nothing to the id path"); + } + + // MessagePack writes a [MessagePackObject] class that is only IEnumerable by its keys, so the walk reads it by key. A + // keyed tree node that enumerates its own children is the shape that overflowed the stack when enumeration came first. + [Test] + public void ATypeThatEnumeratesItselfIsWalkedByKeyAndTerminates() + { + var survey = CultElementIds.Inspect(typeof(HKNode)); + Assert.That(survey.Problems, Is.Empty); + Assert.That(survey.IdPath, Is.EquivalentTo(new[] { typeof(HKNode), typeof(List), typeof(KNode) })); + + var root = new HKNode { Nodes = { new KNode { Children = { new KNode { Children = { new KNode() } } } } } }; + Assert.That(Assign(root, "r", deterministic: true), Is.EqualTo(3)); + Assert.That(root.Nodes[0].Id, Has.Length.EqualTo(12)); + Assert.That(root.Nodes[0].Children[0].Children[0].Id, Has.Length.EqualTo(12)); + var options = GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.Options; + var back = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(root, options), options); + Assert.That(back.Nodes[0].Id, Is.EqualTo(root.Nodes[0].Id), "MessagePack writes the node by key, ids included"); + Assert.That(back.Nodes[0].Children[0].Children[0].Id, Is.EqualTo(root.Nodes[0].Children[0].Children[0].Id)); + + // An unkeyed type that enumerates itself holds no element list and ends the type walk. + var unkeyed = CultElementIds.Inspect(typeof(HUNode)); + Assert.That(unkeyed.Problems, Is.Empty); + Assert.That(unkeyed.IdPath, Is.Empty); + + // A keyed class that only enumerates is read by its keyed lists, so both are judged and minted. + Assert.That(Problems(typeof(KBag)).Single(), Does.Contain("EBare")); + var bag = new KBagIded { Items = { new EIded(), new EIded() } }; + Assert.That(Assign(bag, "r", deterministic: false), Is.EqualTo(2)); + Assert.That(CultElementIds.Holds(typeof(KBagIded), bag), Is.True); + } + + // The walk reads the members MessagePack writes: without AllowPrivate a public field or a property with a public getter, with + // it every member up the base chain, the most derived [MessagePackObject] declaration deciding; no [IgnoreMember]. + [Test] + public void TheWalkReadsTheMembersMessagePackWrites() + { + Assert.That(Problems(typeof(PubIgn)), Is.Empty, "an ignored member is never written, so its list is nobody's business"); + Assert.That(Problems(typeof(ApIgn)), Is.Empty, "an ignored private member is not written"); + Assert.That(Problems(typeof(PgHold)), Is.Empty, "a list behind a private getter is not written without AllowPrivate"); + + foreach (var type in new[] { typeof(EIded), typeof(HHiddenLeaf), typeof(PubIgn), typeof(ApIgn), typeof(PgHold), typeof(MemberMix), typeof(HOptHidden), typeof(ApDerived), typeof(NoApDerived), typeof(ApInheritor), typeof(ApIgnAp), typeof(PSkH), typeof(ISkH) }) + { + var allowPrivate = type.GetCustomAttribute(true)!.AllowPrivate; + Assert.That(CultElementIds.WrittenKeyedMembers(type).Select(member => member.Name), + Is.EquivalentTo(MessagePackMembers(type, allowPrivate)), type.Name); + } + + Assert.That(CultElementIds.WrittenKeyedMembers(typeof(NoApDerived)).Select(member => member.Name), Is.EquivalentTo(new[] { "N" }), + "the derived declaration decides, so an AllowPrivate base adds nothing"); + } + + // The names of the members MessagePack's own discovery reads for a type, by reflection into its internal type: the + // oracle the walk's member set is pinned to. + private static List MessagePackMembers(Type type, bool allowPrivate) + { + const string moved = "MessagePack moved its member discovery; re-derive MembersOf"; + const System.Reflection.BindingFlags all = System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic; + var info = typeof(MessagePackSerializer).Assembly.GetType("MessagePack.Internal.ObjectSerializationInfo"); + var create = info?.GetMethod("CreateOrNull", all); + if (create == null || create.GetParameters().Length != 4) + { + Assert.Fail(moved); + return new List(); + } + + var result = create.Invoke(null, new object[] { type, false, false, allowPrivate }); + var members = result?.GetType().GetProperty("Members", all)?.GetValue(result) as System.Collections.IEnumerable; + if (members == null) + { + Assert.Fail(moved); + return new List(); + } + + var names = new List(); + foreach (var member in members) + { + var readable = member.GetType().GetProperty("IsReadable", all)?.GetValue(member); + var name = member.GetType().GetProperty("Name", all)?.GetValue(member) as string; + if (readable is not bool || name == null) + Assert.Fail(moved); + if ((bool)readable!) + names.Add(name!); + } + + return names; + } + + // An id MessagePack writes but cannot read back would be re-minted differently on every load; one it does not write + // never reaches disk. + [Test] + public void AnIdMessagePackCannotReadBackIsRefused() + { + Assert.That(Problems(typeof(HPrivSet)).Single(), Does.Contain("setter is not public").And.Contain("Id")); + Assert.That(Problems(typeof(HIgnId)).Single(), Does.Contain("does not write").And.Contain("Id")); + Assert.That(Problems(typeof(HPrivateId)).Single(), Does.Contain("does not write").And.Contain("id")); + Assert.That(Problems(typeof(HInit)), Is.Empty); + Assert.That(Problems(typeof(HCtorId)), Is.Empty); + + foreach (var holder in new object[] + { + new HInit { Items = { new EInit(), new EInit() } }, + new HCtorId { Items = { new ECtorId(null), new ECtorId(null) } }, + }) + { + Assert.That(Assign(holder, "r", deterministic: false), Is.EqualTo(2), holder.GetType().Name); + var bytes = MessagePackSerializer.Serialize(holder.GetType(), holder, GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.Options); + var back = MessagePackSerializer.Deserialize(holder.GetType(), bytes, GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.Options); + Assert.That(Ids(back), Is.EqualTo(Ids(holder)), holder.GetType().Name); + Assert.That(Ids(back), Has.All.Match("^[0-9a-f]{12}$"), holder.GetType().Name); + } + } + + // Every shape the walk does not own is one whole value: no problem, off the id path, never minted into and never counted. + [Test] + public void AShapeTheWalkDoesNotOwnIsOneWholeValue() + { + const string Set = "aaaaaaaaaaaa"; + EIded E(string id) => new() { Id = id }; + var holders = new (Type Type, Func Make)[] + { + (typeof(Whole>), id => new Whole> { Value = ImmutableArray.Create(E(id)) }), + (typeof(Whole?>), id => new Whole?> { Value = ImmutableArray.Create(E(id)) }), + (typeof(Whole>), id => new Whole> { Value = new ArraySegment(new[] { E(id) }) }), + (typeof(Whole>), id => new Whole> { Value = new[] { E(id) } }), + (typeof(Whole>), id => new Whole> { Value = new[] { E(id) } }), + (typeof(Whole>>), id => new Whole>> { Value = new("k", new List { E(id) }) }), + (typeof(Whole<(List, int)>), id => new Whole<(List, int)> { Value = (new List { E(id) }, 1) }), + (typeof(Whole, int>>), id => new Whole, int>> { Value = Tuple.Create(new List { E(id) }, 1) }), + (typeof(Whole>>), id => new Whole>> { Value = new Lazy>(() => new List { E(id) }) }), + (typeof(Whole), id => new Whole { Value = new NkHold { Items = { E(id) } } }), + (typeof(Whole), id => new Whole { Value = new SkHold { Items = { E(id) } } }), + (typeof(Whole), id => new Whole { Value = new DcHold { Items = { E(id) } } }), + (typeof(Whole), id => new Whole { Value = new KpBase { Items = { E(id) } } }), + (typeof(Whole), id => new Whole { Value = new KpDerived { Items = { E(id) } } }), + (typeof(HRoles), id => new HRoles { Items = { E(id) } }), + (typeof(HFormattedList), id => new HFormattedList { Items = { E(id) } }), + (typeof(HHiddenList), id => new HHiddenList()), + }; + foreach (var (type, make) in holders) + { + Assert.That(Problems(type), Is.Empty, type.ToString()); + Assert.That(CultElementIds.Inspect(type).IdPath, Is.Empty, type.ToString()); + Assert.That(CultElementIds.CanHoldIds(type), Is.False, type.ToString()); + Assert.That(Assign(make(string.Empty), "r", deterministic: false), Is.EqualTo(0), type.ToString()); + Assert.That(Assign(make(string.Empty), "r", deterministic: true), Is.EqualTo(0), type.ToString()); + Assert.That(CultElementIds.Holds(type, make(Set)), Is.False, type.ToString()); + } + + // A [DataContract] subclass of a [MessagePackObject] type is still read by key: the attribute it inherits keeps it an owner. + Assert.That(CultElementIds.Inspect(typeof(Whole)).IdPath, Does.Contain(typeof(List))); + + // Elements of a string-keyed type carry no id either, and an owned list of a bare element is still refused. + Assert.That(Problems(typeof(Whole>)), Is.Empty); + RefusesBare(typeof(Whole>)); + } + + // A string key counts only on a member MessagePack writes: a private or [IgnoreMember] one leaves the type integer-keyed and owned. + [Test] + public void AStringKeyOnAMemberMessagePackDoesNotWriteLeavesTheTypeOwned() + { + foreach (var holder in new object[] { new PSkH { Items = { new EIded(), new EIded() } }, new ISkH { Items = { new EIded(), new EIded() } } }) + { + Assert.That(Problems(holder.GetType()), Is.Empty, holder.GetType().Name); + Assert.That(CultElementIds.Inspect(holder.GetType()).IdPath, Does.Contain(holder.GetType()), holder.GetType().Name); + Assert.That(Assign(holder, "r", deterministic: false), Is.EqualTo(2), holder.GetType().Name); + Assert.That(CultElementIds.Holds(holder.GetType(), holder), Is.True, holder.GetType().Name); + } + + Assert.That(CultElementIds.WalksAsOwner(typeof(SkOver)), Is.False, "a string key inherited by an override still makes the type one whole value"); + Assert.That(CultElementIds.WalksAsOwner(typeof(PSkH)), Is.True); + Assert.That(Problems(typeof(Whole>)).Single(), Does.Contain("[CultElementId]")); + Assert.That(Problems(typeof(Whole>)).Single(), Does.Contain("[CultElementId]"), "an element whose only string key is ignored is as bare as its twin"); + } + + // The AllowPrivate edges: an [IgnoreMember] member is not written even under it, and only the element's own most derived + // declaration lets a private setter be read back. + [Test] + public void TheAllowPrivateEdgesFollowWhatMessagePackWrites() + { + Assert.That(CultElementIds.WrittenKeyedMembers(typeof(ApIgnAp)), Is.Empty, "an ignored member is not written under AllowPrivate"); + Assert.That(Problems(typeof(HApIgnId)).Single(), Does.Contain("does not write").And.Contain("Id")); + Assert.That(Problems(typeof(HNoApPrivSet)).Single(), Does.Contain("setter is not public").And.Contain("Id"), + "the derived plain [MessagePackObject] decides: its private setter is not read back, whatever its base declares"); + } + + // MessagePack writes an AllowPrivate type with every member up its base chain, and the most derived [MessagePackObject] + // declaration decides whether that is so. + [Test] + public void AnAllowPrivateTypeIsWalkedByTheMembersMessagePackWrites() + { + Assert.That(CultElementIds.WrittenKeyedMembers(typeof(ApDerived)).Select(member => member.Name), Is.EquivalentTo(new[] { "baseStash", "own", "N", "Pg" })); + Assert.That(CultElementIds.WrittenKeyedMembers(typeof(NoApDerived)).Select(member => member.Name), Is.EquivalentTo(new[] { "N" })); + Assert.That(CultElementIds.WrittenKeyedMembers(typeof(ApInheritor)).Select(member => member.Name), Is.EquivalentTo(new[] { "baseStash", "own", "N", "Pg" }), + "AllowPrivate is inherited by a subclass that declares no attribute of its own"); + + var options = GameCult.Caching.MessagePack.CultDocumentMessagePackSerialization.Options; + var held = new HOptHidden(); + held.Peek.Add(new EIded()); + Assert.That(Assign(held, "r", deterministic: false), Is.EqualTo(1)); + var back = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(held, options), options); + Assert.That(back.Peek.Single().Id, Is.EqualTo(held.Peek.Single().Id), "the minted id of a private list survives a round trip"); + + Assert.That(Problems(typeof(HApPrivSet)), Is.Empty, "MessagePack reads an AllowPrivate type's private setter back"); + var privSet = new HApPrivSet { Items = { new EApPrivSet(), new EApPrivSet() } }; + Assert.That(Assign(privSet, "r", deterministic: false), Is.EqualTo(2)); + var round = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(privSet, options), options); + Assert.That(round.Items.Select(item => item.Id), Is.EqualTo(privSet.Items.Select(item => item.Id))); + Assert.That(round.Items.Select(item => item.Id), Has.All.Match("^[0-9a-f]{12}$")); + Assert.That(Problems(typeof(HPrivSet)).Single(), Does.Contain("setter is not public"), "without AllowPrivate it is still refused"); + } + + private static string?[] Ids(object holder) => holder switch + { + HInit init => init.Items.Select(item => item.Id).ToArray(), + HCtorId ctor => ctor.Items.Select(item => item.Id).ToArray(), + _ => throw new ArgumentException(holder.GetType().Name), + }; + + // A subclass that redeclares the id member does not change which member the declared element writes. + [Test] + public void AnElementIsReadByItsDeclaredTypeNotItsRuntimeType() + { + Assert.That(Problems(typeof(HDoc2)), Is.Empty); + static HE Hd(string? declared = null, string? redeclared = null) + { + var item = new HD { Id = redeclared }; + ((HE)item).Id = declared; + return item; + } + + var minted = new HDoc2 { Items = { Hd() } }; + Assert.That(Assign(minted, "r", deterministic: false), Is.EqualTo(1)); + Assert.That(minted.Items[0].Id, Does.Match("^[0-9a-f]{12}$")); + Assert.That(((HD)minted.Items[0]).Id, Is.Null, "the redeclared member is never written, so it is not minted"); + + Assert.That(() => CultElementIds.Plan(typeof(HDoc2), new HDoc2 { Items = { Hd(declared: "not-hex") } }, "r", false), + Throws.TypeOf(), "the declared member is checked"); + Assert.That(() => CultElementIds.Plan(typeof(HDoc2), new HDoc2 { Items = { Hd(redeclared: "not-hex") } }, "r", false), + Throws.Nothing, "the redeclared member is never read"); + + Assert.That(CultElementIds.Holds(typeof(HDoc2), new HDoc2 { Items = { Hd(declared: "0123456789ab") } }), Is.True); + Assert.That(CultElementIds.Holds(typeof(HDoc2), new HDoc2 { Items = { Hd(redeclared: "0123456789ab") } }), Is.False); + } + + // ---- shapes ---- + [MessagePackObject] public class PubIgn { [Key(0)] [IgnoreMember] public List L { get; set; } = new(); } +#pragma warning disable CS0169, CS0414, CS0649 + [MessagePackObject] public class ApIgn { [Key(0)] [IgnoreMember] private List l = new(); } +#pragma warning restore CS0169, CS0414, CS0649 + [MessagePackObject] public class PgHold { [Key(0)] public List Items { private get; set; } = new(); } + [MessagePackObject] + public class MemberMix + { + [Key(0)] public int A; + [Key(1)] [IgnoreMember] public List? Ign; + [Key(2)] public string? PS { get; private set; } + [Key(3)] public string? GetOnly { get; } +#pragma warning disable CS0169, CS0414, CS0649 + [Key(4)] private int priv; +#pragma warning restore CS0169, CS0414, CS0649 + [Key(5)] public string? InitOnly { get; init; } + [IgnoreMember] public int Priv => priv; + } + + [MessagePackObject] public class EPrivSet { [Key(0)] [CultElementId] public string? Id { get; private set; } } + [MessagePackObject] public class HPrivSet { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class EIgnId { [Key(0)] [IgnoreMember] [CultElementId] public string? Id { get; set; } } + [MessagePackObject] public class HIgnId { [Key(0)] public List Items { get; set; } = new(); } + #pragma warning disable CS0169, CS0649 + [MessagePackObject] public class EPrivateId { [Key(0)] [CultElementId] private string? id; } +#pragma warning restore CS0169, CS0649 + [MessagePackObject] public class HPrivateId { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class EInit { [Key(0)] [CultElementId] public string? Id { get; init; } } + [MessagePackObject] public class HInit { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] + public class ECtorId + { + [SerializationConstructor] public ECtorId(string? id) { Id = id; } + [Key(0)] [CultElementId] public string? Id { get; set; } + } + [MessagePackObject] public class HCtorId { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] public class HE { [Key(0)] [CultElementId] public string? Id { get; set; } } + [MessagePackObject] public class HD : HE { [Key(2)] [CultElementId] public new string? Id { get; set; } } + [MessagePackObject] public class HDoc2 { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] + public class KNode : IEnumerable + { + [Key(0)] [CultElementId] public string? Id { get; set; } + [Key(1)] public List Children { get; set; } = new(); + public IEnumerator GetEnumerator() => Children.GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + [MessagePackObject] public class HKNode { [Key(0)] public List Nodes { get; set; } = new(); } + public class UNode : IEnumerable + { + public IEnumerator GetEnumerator() { yield break; } + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + [MessagePackObject] public class HUNode { [Key(0)] public UNode? N { get; set; } } + [MessagePackObject] + public class KBag : IEnumerable + { + [Key(0)] public List Items { get; set; } = new(); + [Key(1)] public List Other { get; set; } = new(); + public IEnumerator GetEnumerator() { yield break; } + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + [MessagePackObject] + public class KBagIded : IEnumerable + { + [Key(0)] public List Items { get; set; } = new(); + public IEnumerator GetEnumerator() { yield break; } + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [MessagePackObject] public class EBare { [Key(0)] public string X { get; set; } = ""; } + [MessagePackObject] public class EIded { [Key(0)] public string X { get; set; } = ""; [Key(1)] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class ENest { [Key(0)] [CultElementId] public string Id { get; set; } = ""; [Key(1)] public List Sub { get; set; } = new(); } + [MessagePackObject] public class EBareNest { [Key(0)] [CultElementId] public string Id { get; set; } = ""; [Key(1)] public List Sub { get; set; } = new(); } + + public sealed class NoFormatter { } + public class UnkeyedArm : ArmBase { [Key(0)] [CultElementId] public string Id { get; set; } = ""; } + [Union(0, typeof(KeyedArm)), Union(1, typeof(UnkeyedArm))] public abstract class ArmBase { } + [MessagePackObject] public class KeyedArm : ArmBase { [Key(0)] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class HUnkeyedArmList { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class HUnkeyedArmField { [Key(0)] public ArmBase? Root { get; set; } } + [MessagePackObject] + public class HCollections + { + [Key(0)] public List A { get; set; } = new(); + [Key(1)] public EIded[] B { get; set; } = Array.Empty(); + [Key(2)] public Dictionary C { get; set; } = new(); + [Key(3)] public List> D { get; set; } = new(); + } + + [MessagePackObject] + public class HValueListsBesideObjects + { + [Key(0)] public List Objects { get; set; } = new(); + [Key(1)] public List Numbers { get; set; } = new(); + [Key(2)] public List Plain { get; set; } = new(); + [Key(3)] public List Points { get; set; } = new(); + } + + [MessagePackFormatter(typeof(NoFormatter))] public class Roles : List { } + [MessagePackObject] public class HRoles { [Key(0)] public Roles Items { get; set; } = new(); } + [MessagePackFormatter(typeof(NoFormatter))] public class PlainValues : List { } + [MessagePackObject] public class HFormattedValueCollection { [Key(0)] public List Objects { get; set; } = new(); [Key(1)] public PlainValues Plain { get; set; } = new(); } + + [MessagePackObject] + public abstract class FormattedIdBase + { + [Key(0)] [MessagePackFormatter(typeof(NoFormatter))] public virtual string Id { get; set; } = ""; + } + + [MessagePackObject] + public class FormattedIdOverride : FormattedIdBase + { + [Key(0)] [CultElementId] public override string Id { get; set; } = ""; + } + + [MessagePackObject] public class HOverFormattedId { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] + public class OwnerBaseFormatted + { + [Key(0)] [MessagePackFormatter(typeof(NoFormatter))] public virtual List Items { get; set; } = new(); + } + + [MessagePackObject] + public class OwnerOverFormatted : OwnerBaseFormatted + { + [Key(0)] public override List Items { get; set; } = new(); + } + + [MessagePackObject] + public class IdVirtual + { + [Key(0)] public string X { get; set; } = ""; + [Key(1)] [CultElementId] public virtual string Id { get; set; } = ""; + } + + [MessagePackObject] public class IdOverride : IdVirtual { [Key(1)] public override string Id { get; set; } = ""; } + [MessagePackObject] public class HIdOverride { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] public class HUnkeyedTwoLists { [Key(0)] public List A { get; set; } = new(); [Key(1)] public List B { get; set; } = new(); } + public class PlainValue { public string X { get; set; } = ""; } + [MessagePackObject] public class HValueList { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] [MessagePackFormatter(typeof(NoFormatter))] public class FormattedElement { [Key(0)] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class HFormattedElement { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class HFormattedList { [Key(0)] [MessagePackFormatter(typeof(NoFormatter))] public List Items { get; set; } = new(); } + [MessagePackObject] public class FormattedIdElement { [Key(0)] [CultElementId] [MessagePackFormatter(typeof(NoFormatter))] public string Id { get; set; } = ""; } + [MessagePackObject] public class HFormattedId { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] [MessagePackFormatter(typeof(NoFormatter))] public class FormattedOwner { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class HFormattedOwner { [Key(0)] public FormattedOwner Owner { get; set; } = new(); } + [MessagePackObject] public class HFormattedMember { [Key(0)] [MessagePackFormatter(typeof(NoFormatter))] public HHasIds Inner { get; set; } = new(); } + [MessagePackObject] [MessagePackFormatter(typeof(NoFormatter))] public class FormattedLeaf { [Key(0)] public int N { get; set; } } + [MessagePackObject] public class HFormattedField { [Key(0)] public FormattedLeaf Leaf { get; set; } = new(); [Key(1)] public List Tags { get; set; } = new(); } + + [MessagePackObject] public class FormattedDerived : FormattedElement { } + [MessagePackObject] public class HFormattedDerived { [Key(0)] public List Items { get; set; } = new(); } + [Union(0, typeof(IdArm))] public abstract class IdBase { } + [MessagePackObject] public class IdArm : IdBase { [Key(0)] [CultElementId] public string Id { get; set; } = ""; [Key(1)] public List Items { get; set; } = new(); } + [Union(0, typeof(PlainArm))] public abstract class PlainBase { } + [MessagePackObject] public class PlainArm : PlainBase { [Key(0)] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class HPlainBaseList { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class HIdBaseOwner { [Key(0)] public IdBase? Root { get; set; } } + + [MessagePackObject] public class HLeft { [Key(0)] public HCycle Cycle { get; set; } = new(); } + [MessagePackObject] public class HRight { [Key(0)] public HCycle Cycle { get; set; } = new(); } + [MessagePackObject] public class HCycle { [Key(0)] public HCycleBack Back { get; set; } = new(); } + [MessagePackObject] public class HCycleBack { [Key(0)] public HCycle? Up { get; set; } [Key(1)] public List Items { get; set; } = new(); } + [MessagePackObject] public class HDiamond { [Key(0)] public HLeft Left { get; set; } = new(); [Key(1)] public HRight Right { get; set; } = new(); } + + [MessagePackObject] public class HList { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class HArray { [Key(0)] public EBare[] Items { get; set; } = Array.Empty(); } + [MessagePackObject] public class HDictionary { [Key(0)] public Dictionary Map { get; set; } = new(); } + [MessagePackObject] public class HNested { [Key(0)] public HList Inner { get; set; } = new(); } + [MessagePackObject] public class HNullableInner { [Key(0)] public HList? Inner { get; set; } } + [MessagePackObject] public class HListOfLists { [Key(0)] public List> Grid { get; set; } = new(); } + [MessagePackObject] public class HElementNest { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class HFine { [Key(0)] public List Items { get; set; } = new(); [Key(1)] public List Tags { get; set; } = new(); [Key(2)] public int[] Numbers { get; set; } = Array.Empty(); } + + [MessagePackObject] + [Union(0, typeof(UnionSub))] + public abstract class UnionBase { [Key(0)] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class UnionSub : UnionBase { [Key(1)] public List Hidden { get; set; } = new(); } + [MessagePackObject] public class HUnion { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] public class ETwo { [Key(0)] [CultElementId] public string A { get; set; } = ""; [Key(1)] [CultElementId] public string B { get; set; } = ""; } + [MessagePackObject] public class HTwo { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class EInt { [Key(0)] [CultElementId] public int Id { get; set; } } + [MessagePackObject] public class HInt { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class ENoKey { [IgnoreMember] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class HNoKey { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class EGetOnly { [Key(0)] [CultElementId] public string Id { get; } = ""; } + [MessagePackObject] public class HGetOnly { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class EMissingSource { [Key(0)] [CultElementId("Nope")] public string Id { get; set; } = ""; } + [MessagePackObject] public class HMissingSource { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class EBadSourceKind { [Key(0)] public List Tags { get; set; } = new(); [Key(1)] [CultElementId(nameof(Tags))] public string Id { get; set; } = ""; } + [MessagePackObject] public class HBadSourceKind { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class EUnkeyedSource { [IgnoreMember] public string Loose { get; set; } = ""; [Key(0)] [CultElementId(nameof(Loose))] public string Id { get; set; } = ""; } + [MessagePackObject] public class HUnkeyedSource { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class ENullableSource { [Key(0)] public int? Number { get; set; } [Key(1)] [CultElementId(nameof(Number))] public string Id { get; set; } = ""; } + [MessagePackObject] public class HNullableSource { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] public class HHasIds { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] + public class HAll + { + [Key(0)] public Dictionary Dict { get; set; } = new(); + [Key(1)] public List> Grid { get; set; } = new(); + [Key(2)] public List Nest { get; set; } = new(); + } + + [MessagePackObject] public struct SHold { [Key(0)] public List Items; } + [MessagePackObject] public struct SHoldIded { [Key(0)] public List Items; } + [MessagePackObject] public struct SValue { [Key(0)] public int N; } + public struct UnkeyedS { [Key(0)] public List Items; } + [MessagePackObject] public class HUnkeyedStruct { [Key(0)] public UnkeyedS Value; } + public class InheritedBare : EBare { } + [MessagePackObject] public class HInheritedBare { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject] public class HNullableStructIded { [Key(0)] public SHoldIded? Hold; } + [MessagePackObject] public class HStruct { [Key(0)] public SHold Hold; } + [MessagePackObject] public class HStructList { [Key(0)] public List Holds { get; set; } = new(); } + [MessagePackObject] public class HNullableStruct { [Key(0)] public SHold? Hold; } + [MessagePackObject] public class HStructIded { [Key(0)] public SHoldIded Hold; [Key(1)] public List Many = new(); } + [MessagePackObject] public class HStructValues { [Key(0)] public List Structs = new(); [Key(1)] public List Objects = new(); } + + [MessagePackObject] public class HReadOnlyMap { [Key(0)] public IReadOnlyDictionary Map { get; set; } = new Dictionary(); } + [MessagePackObject] public class HReadOnlyIded { [Key(0)] public IReadOnlyDictionary Map { get; set; } = new Dictionary(); } + [MessagePackObject] public class HMapIded { [Key(0)] public Dictionary Map { get; set; } = new(); } + public sealed class RoMap : IReadOnlyDictionary + { + private readonly Dictionary _inner; + public RoMap(Dictionary inner) => _inner = inner; + public HHasIds this[string key] => _inner[key]; + public IEnumerable Keys => _inner.Keys; + public IEnumerable Values => _inner.Values; + public int Count => _inner.Count; + public bool ContainsKey(string key) => _inner.ContainsKey(key); + public bool TryGetValue(string key, out HHasIds value) => _inner.TryGetValue(key, out value!); + public IEnumerator> GetEnumerator() => _inner.GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [MessagePackObject] public class BaseC { [Key(0)] public int X { get; set; } } + [MessagePackObject] public class DerC : BaseC { [Key(1)] public List L { get; set; } = new(); } + [MessagePackObject] public class HBase { [Key(0)] public BaseC B { get; set; } = new(); } + [MessagePackObject] public class HObject { [Key(0)] public object? O { get; set; } } + +#pragma warning disable CS0169, CS0414, CS0649 + [MessagePackObject] public class HHiddenList { [Key(0)] private List Stash { get; set; } = new(); } + [MessagePackObject] public class HHiddenOwner { [Key(0)] private HHasIds stash = new(); } + [MessagePackObject(AllowPrivate = true)] public class HOptHidden { [Key(0)] private List stash = new(); [IgnoreMember] public List Peek => stash; } + [MessagePackObject] public class HHiddenBase { [Key(0)] private List stash = new(); } + [MessagePackObject] public class HHiddenFromBase : HHiddenBase { [Key(1)] public int N { get; set; } } + [MessagePackObject] public class HHiddenLeaf { [Key(0)] public List Items { get; set; } = new(); [Key(1)] private int secret = 7; } + [MessagePackObject] public class ApBase { [Key(0)] private List baseStash = new(); } + [MessagePackObject(AllowPrivate = true)] public class ApDerived : ApBase { [Key(1)] private List own = new(); [Key(2)] public int N { get; set; } [Key(3)] public List Pg { private get; set; } = new(); } + [MessagePackObject(AllowPrivate = true)] public class ApBase2 { [Key(0)] private List baseStash = new(); } + [MessagePackObject] public class NoApDerived : ApBase2 { [Key(1)] public int N { get; set; } } +#pragma warning restore CS0169, CS0414, CS0649 + + [MessagePackObject] public class Whole { [Key(0)] public T? Value { get; set; } } + public class NkHold { public List Items = new(); } + [MessagePackObject] public class SkHold { [Key("items")] public List Items = new(); } + [MessagePackObject(keyAsPropertyName: true)] public class KpBase { [Key(0)] public List Items = new(); } + public class KpDerived : KpBase { } + [MessagePackObject] public class MpBase { [Key(0)] public List Items = new(); } + [System.Runtime.Serialization.DataContract] public class DcOverMp : MpBase { } + public class ApInheritor : ApDerived { } + [MessagePackObject] public class SkElem { [Key("x")] public string X { get; set; } = ""; } + [System.Runtime.Serialization.DataContract] public class DcHold { [System.Runtime.Serialization.DataMember(Order = 0)] public List Items = new(); } + [MessagePackObject(AllowPrivate = true)] public class ApIgnAp { [Key(0)] [IgnoreMember] private List l = new(); } + [MessagePackObject] public class PSkH { [Key(0)] public List Items = new(); [Key("x")] private int x = 1; } + [MessagePackObject] public class ISkH { [Key(0)] public List Items = new(); [IgnoreMember] [Key("x")] public int X { get; set; } } + [MessagePackObject] public class SkOverBase { [Key("items")] public virtual List Items { get; set; } = new(); } + [MessagePackObject] public class SkOver : SkOverBase { public override List Items { get; set; } = new(); } + [MessagePackObject] public class NoIdEl { [Key(0)] public int N { get; set; } [IgnoreMember] [Key("x")] public int X { get; set; } } + [MessagePackObject] public class NoIdPlain { [Key(0)] public int N { get; set; } } + [MessagePackObject(AllowPrivate = true)] public class EApIgnId { [Key(0)] public int N { get; set; } [Key(1)] [IgnoreMember] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class HApIgnId { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject(AllowPrivate = true)] public class ApIdBase { [Key(0)] public int N { get; set; } } + [MessagePackObject] public class ENoApPrivSet : ApIdBase { [Key(1)] [CultElementId] public string? Id { get; private set; } } + [MessagePackObject] public class HNoApPrivSet { [Key(0)] public List Items { get; set; } = new(); } + [MessagePackObject(AllowPrivate = true)] public class EApPrivSet { [Key(0)] [CultElementId] public string? Id { get; private set; } } + [MessagePackObject] public class HApPrivSet { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] public class EDerivedText { [Key(0)] public string? Name { get; set; } [Key(1)] [CultElementId(nameof(Name))] public string Id { get; set; } = ""; } + [MessagePackObject] public class HDerivedText { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] + public class HCulture + { + [Key(0)] public Dictionary ByDouble { get; set; } = new(); + [Key(1)] public Dictionary ByInt { get; set; } = new(); + [Key(2)] public Dictionary ByDate { get; set; } = new(); + } + + // An interface union: the interface declares the id, the concrete type does not, so nothing would ever mint into it. + [Union(0, typeof(IfaceBad))] + public interface IUnionIface { [Key(0)] [CultElementId] string Id { get; set; } } + [MessagePackObject] public class IfaceBad : IUnionIface { [Key(0)] public string Id { get; set; } = ""; } + [MessagePackObject] public class HIfaceBad { [Key(0)] public List Items { get; set; } = new(); } + + [Union(0, typeof(IfaceGood))] + public interface IUnionIfaceGood { } + [MessagePackObject] public class IfaceGood : IUnionIfaceGood { [Key(0)] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class HIfaceGood { [Key(0)] public List Items { get; set; } = new(); } + + // A subtype that reuses the base id's key. + [MessagePackObject] + [Union(0, typeof(KeyClash))] + public abstract class KeyClashBase { [Key(0)] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class KeyClash : KeyClashBase { [Key(0)] public string Clash { get; set; } = ""; } + [MessagePackObject] public class HKeyClash { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] + [Union(0, typeof(DupSub))] + [Union(1, typeof(DupSub))] + public abstract class DupBase { } + [MessagePackObject] public class DupSub : DupBase { [Key(0)] public string X { get; set; } = ""; } + [MessagePackObject] public class HDup { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] + [Union(0, typeof(NoIdSub))] + public abstract class NoIdBase { } + [MessagePackObject] public class NoIdSub : NoIdBase { [Key(0)] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class HAbstractNoId { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] public class EDerived { [Key(0)] public int? Number { get; set; } [Key(1)] [CultElementId(nameof(Number))] public string Id { get; set; } = ""; } + [MessagePackObject] public class HDerived { [Key(0)] public List Items { get; set; } = new(); } + } +} diff --git a/tests/GameCult.Caching.Tests/ElementIdTests.cs b/tests/GameCult.Caching.Tests/ElementIdTests.cs new file mode 100644 index 00000000..1b6b3fe2 --- /dev/null +++ b/tests/GameCult.Caching.Tests/ElementIdTests.cs @@ -0,0 +1,1468 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using System.Text.RegularExpressions; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // C2a of docs/document-variants-cut.md: every object element of a list has an id, minted by the cache. + public class ElementIdTests + { + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + + private static readonly ModuleBuilder Rejected = AssemblyBuilder + .DefineDynamicAssembly(new AssemblyName("RejectedElementShapes"), AssemblyBuilderAccess.Run) + .DefineDynamicModule("RejectedElementShapes"); + + private string _directory = string.Empty; + + [SetUp] + public void CreateDirectory() + { + _directory = Path.Combine(Path.GetTempPath(), $"cultlib-element-ids-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + + private string PathOf(string name) => Path.Combine(_directory, name); + + private static CultCache Open(string path) => + CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + + private static IdDeck Deck(string name, int reels = 2) => new() + { + Name = name, + Reels = Enumerable.Range(0, reels).Select(_ => new IdReel { Label = "reel", Marks = { new IdMark { Text = "m" }, new IdMark { Text = "m" } } }).ToList() + }; + + private static string[] AllIds(IdDeck deck) => + deck.Reels.Select(reel => reel.Id) + .Concat(deck.Reels.SelectMany(reel => reel.Marks.Select(mark => mark.Id))) + .Concat(deck.Moves.Select(move => move.Id)) + .ToArray(); + + // A store written before ids existed: the current catalog and schema id, payloads without the id slots. + private static void WritePreIdStore(string path, string? keepKey, params (string Key, string Name, int Reels)[] records) + { + using (var seed = Open(path)) + { + seed.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("seed"), new CultRecordKey("seed"))); + if (keepKey != null) + seed.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("kept"), new CultRecordKey(keepKey))); + } + + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var schemaId = snapshot.Records.First().SchemaId; + var kept = snapshot.Records.Where(record => record.Key == keepKey); + snapshot.Records = kept.Concat(records.Select(record => new CultPersistedRecord + { + Key = record.Key, + SchemaId = schemaId, + StoredAt = DateTimeOffset.UtcNow.ToString("O"), + Payload = MessagePackSerializer.Serialize(new OldDeck + { + Name = record.Name, + Reels = Enumerable.Range(0, record.Reels).Select(_ => new OldReel { Label = "reel", Marks = { new OldMark { Text = "m" }, new OldMark { Text = "m" } } }).ToList() + }) + })).ToArray(); + // What a store from before ids declared. + snapshot.FormatVersion = CultPersistedStoreSnapshot.FormatV1; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + } + + // An element id is an ordinary keyed string, so a record's bytes do not say whether it holds one: only the store header does. + // A cache whose type cannot see the ids (a later shape with no reels, declared compatible or resolved by name) that copies the + // record forward leaves the store marked, the record untouched, and its ids readable by the type that wrote them. + [Test] + public void ANarrowerTypeFlushingAnotherRecordKeepsAMarkedStoreMarked([Values] bool declared, [Values] bool directory) + { + var path = PathOf("narrow.cc"); + var deckSchemaId = Registry.GetRequired(typeof(IdDeck)).SchemaId; + var deck = Deck("r"); + using (var owner = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory })) + owner.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("r"))); + var marked = directory ? "cultcache.store.v5.directory-content-addressed-pages" : CultPersistedStoreSnapshot.FormatV3; + var before = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + Assert.That(before.FormatVersion, Is.EqualTo(marked)); + + var narrow = EmittedDocumentTypes.Emit( + "NarrowDeck" + (declared ? "Declared" : "ByName"), + "tests.element_id_deck", + declared ? "tests.element_id_deck.v9" : "tests.element_id_deck.v8", + new[] { new EmittedDocumentTypes.Field("Name", typeof(string), 0, IsName: true) }, + declared ? new[] { deckSchemaId } : null); + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = CultDocumentRegistry.ForTypes(new[] { narrow }), UseDirectoryStore = directory })) + { + Assert.That(cache.Get(new CultRecordKey("r")), Is.Not.Null, "the narrower type reads the record it will copy"); + cache.UpsertAsync(narrow, EmittedDocumentTypes.New(narrow, ("Name", "y")), new CultRecordKey("y")).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + } + + var after = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + Assert.That(after.FormatVersion, Is.EqualTo(marked)); + var kept = before.Records.Single(record => record.Key == "r"); + var copied = after.Records.Single(record => record.Key == "r"); + Assert.That($"{copied.SchemaId}|{copied.StoredAt}", Is.EqualTo($"{kept.SchemaId}|{kept.StoredAt}")); + using var reader = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory }); + Assert.That(reader.Get(new CultRecordKey("r"))!.Reels.Select(reel => reel.Id), Is.EqualTo(deck.Reels.Select(reel => reel.Id))); + } + + private static CultPersistedRecord DiskRecord(string path, string key) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Single(record => record.Key == key); + + [Test] + public void ATypeWhoseListElementLacksAnIdIsRefusedByName() + { + var element = Rejected.DefineType("BareElement", TypeAttributes.Public); + element.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false })); + element.DefineField("Label", typeof(string), FieldAttributes.Public) + .SetCustomAttribute(new CustomAttributeBuilder(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { 0 })); + var elementType = element.CreateType()!; + + var document = Rejected.DefineType("BareDocument", TypeAttributes.Public); + document.SetCustomAttribute(new CustomAttributeBuilder(typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!, new object[] { "shape.bare", "shape.bare.v1" })); + document.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false })); + document.DefineField("Items", typeof(List<>).MakeGenericType(elementType), FieldAttributes.Public) + .SetCustomAttribute(new CustomAttributeBuilder(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { 0 })); + + var refusal = Assert.Throws(() => CultDocumentRegistry.ForTypes(new[] { document.CreateType()! }))!; + Assert.That(refusal.Message, Does.Contain("BareDocument").And.Contain("BareElement").And.Contain("Items").And.Contain("[CultElementId]")); + } + + [Test] + public void AWriteMintsAnIdForEveryElementAndTheyRoundTrip() + { + var path = PathOf("deck.cc"); + var deck = Deck("d"); + deck.Moves.Add(new IdSlash { Reach = 1 }); + using (var writer = Open(path)) + writer.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("d"))); + var written = AllIds(deck); + Assert.That(written, Has.Length.EqualTo(7)); + Assert.That(written, Has.All.Matches(id => Regex.IsMatch(id, "^[0-9a-f]{12}$"))); + + using var reader = Open(path); + Assert.That(AllIds(reader.Get(new CultRecordKey("d"))!), Is.EqualTo(written)); + } + + [Test] + public void TwoLookAlikeUnionElementsGetDistinctIds() + { + var deck = new IdDeck { Name = "u", Moves = { new IdSlash { Reach = 3 }, new IdSlash { Reach = 3 }, new IdStab { Depth = 3 } } }; + using var cache = Open(PathOf("u.cc")); + cache.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("u"))); + Assert.That(deck.Moves.Select(move => move.Id).Distinct().Count(), Is.EqualTo(3)); + } + + [Test] + public void ADuplicateIdInOneListIsRefusedAndTheSameIdInTwoListsIsNot() + { + using var cache = Open(PathOf("dup.cc")); + var dup = Deck("dup"); + dup.Reels[0].Id = "aaaaaaaaaaaa"; + dup.Reels[1].Id = "aaaaaaaaaaaa"; + var refusal = Assert.Throws(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), dup, new CultRecordKey("dup"))))!; + Assert.That(refusal.Message, Does.Contain("aaaaaaaaaaaa")); + Assert.That(cache.Get(new CultRecordKey("dup")), Is.Null); + + var fine = Deck("fine"); + fine.Reels[0].Id = "aaaaaaaaaaaa"; + fine.Reels[0].Marks[0].Id = "aaaaaaaaaaaa"; + Assert.That(cache.Commit(batch => batch.Upsert(typeof(IdDeck), fine, new CultRecordKey("fine"))), Is.True); + } + + [Test] + public void APreIdStoreLoadsAndItsIdsAreMintedExactlyOnce() + { + var path = PathOf("old.cc"); + WritePreIdStore(path, null, ("a", "a", 2), ("b", "b", 1)); + var before = DiskRecord(path, "a").Payload; + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "the fixture is a store from before ids"); + + string[] first; + using (var cache = Open(path)) + { + first = AllIds(cache.Get(new CultRecordKey("a"))!); + Assert.That(first, Has.Length.EqualTo(6)); + Assert.That(first, Has.All.Not.Empty); + Assert.That(first.Distinct().Count(), Is.EqualTo(first.Length)); + Assert.That(DiskRecord(path, "a").Payload, Is.EqualTo(before), "loading mints in memory and writes nothing"); + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("other"), new CultRecordKey("other"))); + Assert.That(DiskRecord(path, "a").Payload, Is.EqualTo(before), "a write of another record leaves a's bytes as they are: its minted ids stay in memory"); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "and the write declares that the store holds ids: the record it wrote holds some"); + } + + using (var again = Open(path)) + Assert.That(AllIds(again.Get(new CultRecordKey("a"))!), Is.EqualTo(first), "a reload is identical to what was read"); + + using (var writer = Open(path)) + { + var deck = writer.Get(new CultRecordKey("a"))!; + deck.Name = "a2"; + writer.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("a"))); + writer.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("a"))); + } + + using var last = Open(path); + var reread = last.Get(new CultRecordKey("a"))!; + Assert.That(reread.Name, Is.EqualTo("a2")); + Assert.That(AllIds(reread), Is.EqualTo(first), "the ids minted at load are the ids written, and stay through repeated writes"); + } + + [Test] + public void MintElementIdsRewritesEveryPreIdRecordAndNothingElse() + { + var path = PathOf("mint.cc"); + WritePreIdStore(path, "new", ("old1", "o1", 1), ("old2", "o2", 3)); + string[] ids1, ids2; + var newRecord = DiskRecord(path, "new"); + + using (var cache = Open(path)) + { + ids1 = AllIds(cache.Get(new CultRecordKey("old1"))!); + ids2 = AllIds(cache.Get(new CultRecordKey("old2"))!); + Assert.That(cache.MintElementIds(), Is.EqualTo(2)); + Assert.That(cache.MintElementIds(), Is.EqualTo(0), "the second run finds nothing to do"); + } + + Assert.That(DiskRecord(path, "new").Payload, Is.EqualTo(newRecord.Payload)); + Assert.That(DiskRecord(path, "new").StoredAt, Is.EqualTo(newRecord.StoredAt), "a record that already had ids is not touched"); + Assert.That(AllIds(MessagePackSerializer.Deserialize(DiskRecord(path, "old1").Payload)), Is.EqualTo(ids1), "the ids are on disk now"); + using var reader = Open(path); + Assert.That(AllIds(reader.Get(new CultRecordKey("old1"))!), Is.EqualTo(ids1)); + Assert.That(AllIds(reader.Get(new CultRecordKey("old2"))!), Is.EqualTo(ids2)); + Assert.That(reader.MintElementIds(), Is.EqualTo(0)); + } + + // A pre-id record this cache reads whole through the catalog's schema name is owned: the rewrite that persists its minted + // ids is accepted and stores it under this type's id. + [Test] + public void MintElementIdsRewritesAPreIdRecordUnderAnotherRuntimesIdThatItReadsWhole() + { + const string otherRuntimeId = "other.runtime.id_deck"; + var path = PathOf("mint-drift.cc"); + WritePreIdStore(path, null, ("a", "a", 1)); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var entry = snapshot.SchemaCatalog.Single(); + entry.SchemaId = otherRuntimeId; + entry.ContentHash = otherRuntimeId; + entry.CompatibleSchemaIds = new[] { otherRuntimeId }; + snapshot.Records.Single().SchemaId = otherRuntimeId; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + + using (var cache = Open(path)) + { + Assert.That(cache.MintElementIds(), Is.EqualTo(1)); + Assert.That(cache.GetStored(new CultRecordKey("a"))!.StoredSchemaId, Is.Not.EqualTo(otherRuntimeId), "the rewrite restamped it"); + } + + Assert.That(DiskRecord(path, "a").SchemaId, Is.Not.EqualTo(otherRuntimeId)); + } + + // Pushing a loaded record rewrites it in place under this type's id, and the cache keeps that entry. A newer version of the type, + // registered afterwards, reads the entry the record is now published by whole, so it owns the record: staging asks the entry + // that publishes the id the record carries now, not the one that published the id it was loaded under. + [Test] + public void ANewerVersionReadsWholeTheRecordAPushRelabelledInPlace() + { + const string otherRuntimeId = "other.runtime.id_deck"; + var path = PathOf("mint-then-newer.cc"); + WritePreIdStore(path, null, ("a", "a", 1)); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var entry = snapshot.SchemaCatalog.Single(); + entry.SchemaId = otherRuntimeId; + entry.ContentHash = otherRuntimeId; + entry.CompatibleSchemaIds = new[] { otherRuntimeId }; + snapshot.Records.Single().SchemaId = otherRuntimeId; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }); + var key = new CultRecordKey("a"); + + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry })) + { + var loaded = cache.GetStored(key)!; + cache.BackingStores[0].Push(loaded); + cache.BackingStores[0].PushAll(); + Assert.That(ReferenceEquals(cache.GetStored(key), loaded), Is.True, "the cache keeps the entry the store relabelled"); + Assert.That(loaded.StoredSchemaId, Is.Not.EqualTo(otherRuntimeId), "the push relabelled the loaded entry in place"); + registry.GetRequired(typeof(IdDeckNext)); + Assert.That(cache.Commit(batch => batch.Upsert(typeof(IdDeckNext), new IdDeckNext { Name = "newer" }, key)), Is.True); + } + + Assert.That(DiskRecord(path, "a").SchemaId, Is.EqualTo(registry.GetRequired(typeof(IdDeckNext)).SchemaId)); + } + + // A pre-id record this cache reads only by dropping a persisted slot is read-only, so the rewrite that would persist its + // minted ids is refused typed, naming it, and the store is left byte for byte. + [Test] + public void MintElementIdsIsRefusedByAReadOnlyRecordWithMintedIds() + { + const string otherRuntimeId = "other.runtime.id_deck"; + var path = PathOf("mint-read-only.cc"); + WritePreIdStore(path, null, ("a", "a", 1)); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var entry = snapshot.SchemaCatalog.Single(); + entry.SchemaId = otherRuntimeId; + entry.ContentHash = otherRuntimeId; + entry.CompatibleSchemaIds = new[] { otherRuntimeId }; + entry.Members = entry.Members.Append(new CultSchemaMemberCatalogEntry { Slot = 99, MemberName = "Dropped", TypeName = "System.String" }).ToArray(); + snapshot.Records.Single().SchemaId = otherRuntimeId; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var before = File.ReadAllBytes(path); + + using (var cache = Open(path)) + { + Assert.That(AllIds(cache.Get(new CultRecordKey("a"))!), Has.All.Not.Empty, "the load minted ids in memory"); + var refusal = Assert.Throws(() => cache.MintElementIds())!; + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo(("a", otherRuntimeId))); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + } + + [Test] + public void TheByteCostOfAnIdIsMeasured() + { + var ids = new IdDeck { Name = "m" }; + var old = new OldDeck { Name = "m" }; + for (var index = 0; index < 100; index++) + { + ids.Reels.Add(new IdReel { Label = "reel", Id = "0123456789ab" }); + old.Reels.Add(new OldReel { Label = "reel" }); + } + + // IdDeck also carries the empty Moves list at slot 2: one byte, once. + var perElement = (MessagePackSerializer.Serialize(ids).Length - MessagePackSerializer.Serialize(old).Length - 1) / 100.0; + TestContext.Out.WriteLine($"element id cost: {perElement} bytes per element (12-char id in the next free slot)"); + Assert.That(perElement, Is.EqualTo(13.0), "1 byte of string header and 12 of id; the element's array header does not grow"); + } + + [Test] + public void ARemovedPreIdRecordIsNotRewrittenByMintElementIds() + { + var path = PathOf("removed.cc"); + WritePreIdStore(path, null, ("a", "a", 1), ("b", "b", 1)); + using var cache = Open(path); + // Conditional, so the file is merged onto and b's ids stay in memory only. + cache.Commit(batch => + { + batch.Expect(new CultRecordKey("elsewhere"), null); + batch.Remove(new CultRecordKey("a")); + }); + Assert.That(cache.MintElementIds(), Is.EqualTo(1), "only the survivor is listed; the removed record is not resurrected"); + Assert.That(cache.Get(new CultRecordKey("a")), Is.Null); + } + + [Test] + public void ADerivedIdIsTheSourceTextNeverRandomAndItsDuplicatesAreRefused() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SpanDoc) }); + SpanDoc Make() => new() { Name = "s", Spans = { new IdSpan { Offset = 0, Size = 4 }, new IdSpan { Offset = 262144, Size = 4 } } }; + var first = Make(); + var second = Make(); + using (var cache = CultCacheMessagePack.Create(PathOf("span1.cc"), new CultCacheOpenOptions { Registry = registry })) + cache.Commit(batch => batch.Upsert(typeof(SpanDoc), first, new CultRecordKey("s"))); + using (var cache = CultCacheMessagePack.Create(PathOf("span2.cc"), new CultCacheOpenOptions { Registry = registry })) + cache.Commit(batch => batch.Upsert(typeof(SpanDoc), second, new CultRecordKey("s"))); + Assert.That(first.Spans.Select(span => span.Id), Is.EqualTo(new[] { "0", "262144" })); + Assert.That(MessagePackSerializer.Serialize(second), Is.EqualTo(MessagePackSerializer.Serialize(first)), "the same content encodes to the same bytes"); + + var twice = new SpanDoc { Name = "t", Spans = { new IdSpan { Offset = 7 }, new IdSpan { Offset = 7 } } }; + using var refusing = CultCacheMessagePack.Create(PathOf("span3.cc"), new CultCacheOpenOptions { Registry = registry }); + var refusal = Assert.Throws(() => refusing.Commit(batch => batch.Upsert(typeof(SpanDoc), twice, new CultRecordKey("t"))))!; + Assert.That(refusal.Message, Does.Contain("'7'")); + } + + // ---- C2a fix batch: the marker, refusals at load, override values, atomic minting ---- + + private const string HexA = "aaaaaaaaaaaa"; + + private static CultCache OpenWith(string path, bool directory, params Type[] types) => + CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = CultDocumentRegistry.ForTypes(types), UseDirectoryStore = directory }); + + private static string HeaderOf(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion; + + private static void UpsertPlain(CultCache cache, string key) => + cache.Commit(batch => batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = key }, new CultRecordKey(key))); + + private static CultElementIdException Refusal(Exception error) + { + for (var inner = error; inner != null; inner = inner.InnerException!) + { + if (inner is CultElementIdException typed) + return typed; + } + + throw new AssertionException("no CultElementIdException in the chain: " + error); + } + + private static byte[] OldShapedReels(string label) => + MessagePackSerializer.Serialize(new List { new() { Label = label, Marks = { new OldMark { Text = "x" }, new OldMark { Text = "y" } } } }); + + // A holder the walk does not own (ImmutableArray here) is one whole value: its elements carry no element ids, so it never + // marks the store, and a variant replaces it whole (q3 amended 2026-10-16). + [Test] + public void AWholeListHolderNeverMarksTheStoreAndAVariantReplacesItWhole() + { + Assert.That(CultElementIds.CanHoldIds(typeof(IdWholeHolder)), Is.False); + var path = PathOf("whole-holder.cc"); + using (var cache = OpenWith(path, false, typeof(IdWholeHolder))) + { + cache.Commit(batch => batch.Upsert(typeof(IdWholeHolder), + new IdWholeHolder { Name = "base", Reels = ImmutableArray.Create(new IdReel { Label = "set", Id = HexA }, new IdReel { Label = "unset" }) }, + new CultRecordKey("base"))); + } + + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "ids inside a whole value mark nothing"); + + using (var fresh = OpenWith(path, false, typeof(IdWholeHolder))) + { + fresh.Commit(batch => batch.Upsert(typeof(IdWholeHolder), new IdWholeHolder { Name = "other" }, new CultRecordKey("other"))); + var back = fresh.Get(new CultRecordKey("base"))!; + Assert.That(back.Reels.Select(reel => reel.Id), Is.EqualTo(new[] { HexA, "" }), "set ids are plain values, unset ids stay unset"); + } + + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1")); + using (var cache = OpenWith(path, false, typeof(IdWholeHolder))) + { + var replacement = ImmutableArray.Create(new IdReel { Label = "v", Id = "eeeeeeeeeeee" }); + cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), + new[] { cache.Override(nameof(IdWholeHolder.Name), "v"), cache.Override(nameof(IdWholeHolder.Reels), replacement) })); + Assert.That(cache.Get(new CultRecordKey("v"))!.Reels.Select(reel => reel.Id), Is.EqualTo(new[] { "eeeeeeeeeeee" }), "replaced whole"); + Assert.That(cache.Get(new CultRecordKey("base"))!.Reels, Has.Length.EqualTo(2), "the base is untouched"); + } + + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"), "a variant makes v2, and nothing in it holds element ids"); + using var again = OpenWith(path, false, typeof(IdWholeHolder)); + Assert.That(again.Get(new CultRecordKey("v"))!.Reels.Single().Id, Is.EqualTo("eeeeeeeeeeee")); + + var step = new CultVariantOverride(CultOverrideOp.Set, new[] { new CultPathStep(1, HexA) }, string.Empty, MessagePackSerializer.Serialize("x")); + var refused = Assert.Catch(() => again.Commit(batch => batch.UpsertVariant(new CultRecordKey("p"), new CultRecordKey("base"), new[] { again.Override(nameof(IdWholeHolder.Name), "p"), step })))!; + Assert.That(refused.ToString(), Does.Contain("top-level Set overrides only")); + } + + [Test] + public void AStoreThatCanHoldElementIdsCarriesTheV3MarkerAndOneThatCannotStaysV1() + { + var ids = PathOf("ids.cc"); + using (var cache = OpenWith(ids, false, typeof(IdDeck))) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d"))); + Assert.That(HeaderOf(ids), Is.EqualTo("cultcache.store.v3")); + + var plain = PathOf("plain.cc"); + using (var cache = OpenWith(plain, false, typeof(PreCut2FixtureItem))) + UpsertPlain(cache, "a"); + Assert.That(HeaderOf(plain), Is.EqualTo("cultcache.store.v1")); + + using var reread = OpenWith(ids, false, typeof(IdDeck)); + Assert.That(reread.Get(new CultRecordKey("d")), Is.Not.Null, "a v3 store reads"); + } + + [Test] + public void AStagedWriteThatIsFlushedCarriesTheMarkerAndLeavesLoadMintedIdsToMintElementIds() + { + var path = PathOf("flush.cc"); + WritePreIdStore(path, null, ("a", "a", 1)); + using (var cache = Open(path)) + { + cache.UpsertAsync(typeof(IdDeck), Deck("staged"), new CultRecordKey("staged")).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + Assert.That(cache.MintElementIds(), Is.EqualTo(1), "the flush wrote only the staged record: a's minted ids are still in memory"); + } + + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3")); + Assert.That(AllIds(MessagePackSerializer.Deserialize(DiskRecord(path, "a").Payload)), Has.All.Not.Empty); + } + + [Test] + public void AnUnconditionalWriteOfAPlainRecordKeepsTheMarkerWhileTheStoreHoldsIds() + { + var path = PathOf("wholeview.cc"); + using var cache = OpenWith(path, false, typeof(IdDeck), typeof(PreCut2FixtureItem)); + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d"))); + UpsertPlain(cache, "plain"); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "the store still holds a deck, whatever this commit wrote"); + } + + [Test] + public void AMergeOntoAFileAnotherWriterMarkedKeepsTheMarkerEvenWhenThisWriterHoldsNoIds() + { + var path = PathOf("merge.cc"); + Type[] types = { typeof(IdDeck), typeof(PreCut2FixtureItem) }; + using var first = OpenWith(path, false, types); + UpsertPlain(first, "first"); + using var second = OpenWith(path, false, types); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1")); + first.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d"))); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3")); + + Assert.That(second.Commit(batch => + { + batch.Expect(new CultRecordKey("second"), null); + batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "second" }, new CultRecordKey("second")); + }), Is.True); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "the file holds a deck another writer put there; second holds none"); + } + + [Test] + public void AnUnconditionalWriteOfAPlainRecordDoesNotPersistOrMarkTheIdsALoadedRecordMinted() + { + var path = PathOf("loaded-minted.cc"); + WritePreIdStore(path, null, ("a", "a", 1)); + var before = DiskRecord(path, "a"); + using (var cache = OpenWith(path, false, typeof(IdDeck), typeof(PreCut2FixtureItem))) + { + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1")); + UpsertPlain(cache, "p"); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "nothing the write stored holds an id"); + Assert.That(DiskRecord(path, "a").Payload, Is.EqualTo(before.Payload), "a is copied as it was stored"); + cache.MintElementIds(); + } + + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "MintElementIds persisted a's minted ids"); + Assert.That(AllIds(MessagePackSerializer.Deserialize(DiskRecord(path, "a").Payload)), Has.All.Not.Empty); + } + + [Test] + public void AMarkedFileStaysMarkedWhenACommitOnlyTouchesOtherRecords() + { + var path = PathOf("sticky.cc"); + using (var cache = OpenWith(path, false, typeof(IdDeck), typeof(PreCut2FixtureItem))) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d"))); + using (var cache = OpenWith(path, false, typeof(IdDeck), typeof(PreCut2FixtureItem))) + { + Assert.That(cache.Commit(batch => + { + batch.Expect(new CultRecordKey("plain"), null); + batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "plain" }, new CultRecordKey("plain")); + }), Is.True); + } + + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "a merge onto a marked file does not shed the marker"); + } + + [Test] + public void ADirectoryStoreThatCanHoldElementIdsCarriesAV5ManifestAndStaysMarked() + { + var ids = PathOf("ids-dir.cc"); + using (var cache = OpenWith(ids, true, typeof(IdDeck), typeof(PreCut2FixtureItem))) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d"))); + Assert.That(HeaderOf(ids), Is.EqualTo("cultcache.store.v5.directory-content-addressed-pages")); + using (var cache = OpenWith(ids, true, typeof(IdDeck), typeof(PreCut2FixtureItem))) + { + Assert.That(cache.Get(new CultRecordKey("d")), Is.Not.Null, "a v5 manifest reads"); + UpsertPlain(cache, "plain"); + } + + Assert.That(HeaderOf(ids), Is.EqualTo("cultcache.store.v5.directory-content-addressed-pages"), "a rewrite that touches a plain record keeps the marker"); + + var mixed = PathOf("mixed-dir.cc"); + using (var cache = OpenWith(mixed, true, typeof(IdDeck), typeof(PreCut2FixtureItem))) + { + cache.Commit(batch => + { + batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "p" }, new CultRecordKey("p")); + batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d")); + }); + } + + Assert.That(HeaderOf(mixed), Is.EqualTo("cultcache.store.v5.directory-content-addressed-pages"), "one written record that can hold ids is enough"); + + var plain = PathOf("plain-dir.cc"); + using (var cache = OpenWith(plain, true, typeof(PreCut2FixtureItem))) + UpsertPlain(cache, "a"); + Assert.That(HeaderOf(plain), Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages")); + } + + [Test] + public void AVariantStoreThatCanHoldElementIdsIsV3AndItsVariantsRead() + { + var path = PathOf("variant-v3.cc"); + using (var cache = OpenWith(path, false, typeof(IdDeck))) + { + cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), Deck("base"), new CultRecordKey("base")); + batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), new[] { cache.Override(nameof(IdDeck.Name), "v") }); + }); + } + + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3")); + using var again = OpenWith(path, false, typeof(IdDeck)); + Assert.That(again.Get(new CultRecordKey("v"))!.Name, Is.EqualTo("v")); + } + + [Test] + public void ADuplicateIdOnDiskRefusesTheLoadNamingTheRecordTheListAndTheId() + { + var path = PathOf("dup-disk.cc"); + WritePreIdStore(path, null, ("a", "a", 1)); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var deck = Deck("dup"); + deck.Reels[0].Id = HexA; + deck.Reels[1].Id = HexA; + deck.Reels[0].Marks[0].Id = HexA; + snapshot.Records[0].Payload = MessagePackSerializer.Serialize(deck); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + + var refusal = Refusal(Assert.Catch(() => Open(path).Dispose())!); + Assert.That(refusal.RecordKey, Is.EqualTo("a")); + Assert.That(refusal.ElementId, Is.EqualTo(HexA)); + Assert.That(refusal.ListPath, Is.EqualTo("a.1")); + } + + [Test] + public void AnIdOfTheWrongFormatOnDiskRefusesTheLoadAndAWriteOfItIsRefused() + { + var path = PathOf("bad-format.cc"); + WritePreIdStore(path, null, ("a", "a", 1)); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var deck = Deck("bad"); + deck.Reels[0].Id = "t00000000001"; + snapshot.Records[0].Payload = MessagePackSerializer.Serialize(deck); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var refusal = Refusal(Assert.Catch(() => Open(path).Dispose())!); + Assert.That(refusal.RecordKey, Is.EqualTo("a")); + Assert.That(refusal.ElementId, Is.EqualTo("t00000000001")); + + using var cache = Open(PathOf("bad-write.cc")); + var written = Deck("w"); + written.Reels[0].Marks[1].Id = "NOTHEX"; + Assert.That(Assert.Throws(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), written, new CultRecordKey("w"))))!.ElementId, Is.EqualTo("NOTHEX")); + } + + [Test] + public void ABatchRefusedForADuplicateLeavesEveryDocumentItHeldUnminted() + { + using var cache = Open(PathOf("batch-refused.cc")); + var fine = Deck("fine"); + var dup = Deck("dup"); + dup.Reels[0].Id = HexA; + dup.Reels[1].Id = HexA; + Assert.Throws(() => cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), fine, new CultRecordKey("fine")); + batch.Upsert(typeof(IdDeck), dup, new CultRecordKey("dup")); + })); + Assert.That(AllIds(fine), Has.All.Empty, "the refused batch minted nothing into the other document"); + Assert.That(dup.Reels[0].Marks.Select(mark => mark.Id), Has.All.Empty); + Assert.That(cache.Get(new CultRecordKey("fine")), Is.Null); + } + + [Test] + public void AWriteThatDoesNotCommitGivesItsIdsBack() + { + using var cache = Open(PathOf("mismatch.cc")); + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("existing"), new CultRecordKey("existing"))); + var fresh = Deck("fresh"); + var outcome = cache.TryCommit(batch => + { + batch.Expect(new CultRecordKey("existing"), null); + batch.Upsert(typeof(IdDeck), fresh, new CultRecordKey("fresh")); + }); + Assert.That(outcome, Is.EqualTo(CultCommitOutcome.Mismatch)); + Assert.That(AllIds(fresh), Has.All.Empty); + } + + [Test] + public void AnUnconditionalWriteLeavesTheLoadMintedIdsOfTheRecordsItDidNotStageOnTheList() + { + var path = PathOf("unconditional.cc"); + WritePreIdStore(path, null, ("a", "a", 1), ("b", "b", 1)); + using var cache = Open(path); + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("other"), new CultRecordKey("other"))); + Assert.That(AllIds(MessagePackSerializer.Deserialize(DiskRecord(path, "b").Payload)), Has.All.Empty, "the write did not touch b"); + Assert.That(cache.MintElementIds(), Is.EqualTo(2), "a and b exist with ids only in memory until MintElementIds writes them"); + Assert.That(AllIds(MessagePackSerializer.Deserialize(DiskRecord(path, "b").Payload)), Has.All.Not.Empty); + } + + [Test] + public void AConditionalWriteThatLeavesOtherRecordsAsTheyWereLeavesThemOnTheList() + { + var path = PathOf("conditional.cc"); + WritePreIdStore(path, null, ("a", "a", 1), ("b", "b", 1)); + using var cache = Open(path); + Assert.That(cache.Commit(batch => + { + batch.Expect(new CultRecordKey("fresh"), null); + batch.Upsert(typeof(IdDeck), Deck("fresh"), new CultRecordKey("fresh")); + }), Is.True); + Assert.That(cache.MintElementIds(), Is.EqualTo(2), "a and b were not written; their ids are still only in memory"); + } + + [Test] + public void AnOverrideValueGetsItsIdsMintedOnWriteWhoeverBuiltIt() + { + var path = PathOf("override-write.cc"); + using (var cache = Open(path)) + { + var reels = new List { new() { Label = "v", Marks = { new IdMark { Text = "x" } } }, new() { Label = "v" } }; + cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), Deck("base"), new CultRecordKey("base")); + // One built by the cache's own helper, one from bytes the id rule never saw. + batch.UpsertVariant(new CultRecordKey("helper"), new CultRecordKey("base"), + new[] { cache.Override(nameof(IdDeck.Name), "helper"), cache.Override(nameof(IdDeck.Reels), reels) }); + batch.UpsertVariant(new CultRecordKey("raw"), new CultRecordKey("base"), + new[] { cache.Override(nameof(IdDeck.Name), "raw"), CultVariantOverride.Set(1, OldShapedReels("r")) }); + }); + Assert.That(reels.Select(reel => reel.Id), Has.All.Empty, "the caller's list is not written to; ids are minted in the admitted copy"); + foreach (var key in new[] { "helper", "raw" }) + { + var resolved = cache.Get(new CultRecordKey(key))!; + var ids = resolved.Reels.Select(reel => reel.Id).Concat(resolved.Reels.SelectMany(reel => reel.Marks.Select(mark => mark.Id))).ToArray(); + Assert.That(ids, Has.All.Matches(id => Regex.IsMatch(id, "^[0-9a-f]{12}$")), key); + } + } + + foreach (var key in new[] { "helper", "raw" }) + { + var stored = MessagePackSerializer.Deserialize>(DiskRecord(path, key).Variant!.Overrides.Single(entry => entry.Path[0].Slot == 1).Value); + Assert.That(stored.Select(reel => reel.Id), Has.All.Matches(id => id.Length == 12), $"{key}: the persisted delta carries the ids"); + using var reader = Open(path); + Assert.That(reader.Get(new CultRecordKey(key))!.Reels.Select(reel => reel.Id), Is.EqualTo(stored.Select(reel => reel.Id))); + } + } + + [Test] + public void AnOverrideValueWithADuplicateIdIsRefusedNamingTheVariant() + { + using var cache = Open(PathOf("override-dup.cc")); + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("base"), new CultRecordKey("base"))); + var dup = new List { new() { Id = HexA }, new() { Id = HexA } }; + var refusal = Assert.Throws(() => cache.Commit(batch => + batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), + new[] { cache.Override(nameof(IdDeck.Name), "v"), cache.Override(nameof(IdDeck.Reels), dup) })))!; + Assert.That(refusal.RecordKey, Does.StartWith("v")); + Assert.That(refusal.ElementId, Is.EqualTo(HexA)); + Assert.That(cache.Get(new CultRecordKey("v")), Is.Null); + } + + [Test] + public void AVariantWrittenBeforeIdsMintsItsOverrideIdsOnLoadAndMintElementIdsRewritesIt() + { + var path = PathOf("override-load.cc"); + using (var seed = Open(path)) + { + seed.Commit(batch => + { + batch.Upsert(typeof(IdDeck), Deck("base"), new CultRecordKey("base")); + batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), + new[] { seed.Override(nameof(IdDeck.Name), "v"), seed.Override(nameof(IdDeck.Reels), new List { new() }) }); + }); + } + + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var record = snapshot.Records.Single(entry => entry.Key == "v"); + record.Variant = new CultVariantDelta(record.Variant!.BaseKey, record.Variant.Overrides + .Select(entry => entry.Path[0].Slot == 1 ? CultVariantOverride.Set(1, OldShapedReels("old")) : entry).ToArray()); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var untouched = File.ReadAllBytes(path); + + string[] first; + using (var peek = Open(path)) + first = AllIds(peek.Get(new CultRecordKey("v"))!); + using (var cache = Open(path)) + { + Assert.That(AllIds(cache.Get(new CultRecordKey("v"))!), Is.EqualTo(first), "the ids minted at load are a function of the record, so a second load mints the same"); + Assert.That(first, Has.Length.EqualTo(3)); + Assert.That(first, Has.All.Not.Empty); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(untouched), "loading mints in memory and writes nothing"); + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("other"), new CultRecordKey("other"))); + // A base edit re-resolves the variant as a dependent; it is still only in memory. + var baseDeck = cache.Get(new CultRecordKey("base"))!; + baseDeck.Reels[0].Label = "edited"; + cache.Commit(batch => batch.Upsert(typeof(IdDeck), baseDeck, new CultRecordKey("base"))); + Assert.That(AllIds(cache.Get(new CultRecordKey("v"))!), Is.EqualTo(first), "the dependent re-resolves to the same minted ids"); + Assert.That(cache.MintElementIds(), Is.EqualTo(1), "a flush of the store persisted the delta as it was handed over, so the variant is still in memory only"); + Assert.That(cache.MintElementIds(), Is.EqualTo(0)); + } + + var persisted = MessagePackSerializer.Deserialize>(DiskRecord(path, "v").Variant!.Overrides.Single(entry => entry.Path[0].Slot == 1).Value); + Assert.That(persisted.Select(reel => reel.Id).Concat(persisted.SelectMany(reel => reel.Marks.Select(mark => mark.Id))), Is.EqualTo(first)); + using var again = Open(path); + Assert.That(AllIds(again.Get(new CultRecordKey("v"))!), Is.EqualTo(first)); + } + + [Test] + public void ARefusalAfterMintingLeavesEveryPlainDocumentInTheBatchUnminted() + { + using var cache = Open(PathOf("late-refusal.cc")); + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("base"), new CultRecordKey("base"))); + var plain = Deck("plain"); + var dup = new List { new() { Id = HexA }, new() { Id = HexA } }; + Assert.Throws(() => cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), plain, new CultRecordKey("plain")); + batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), + new[] { cache.Override(nameof(IdDeck.Name), "v"), cache.Override(nameof(IdDeck.Reels), dup) }); + })); + Assert.That(AllIds(plain), Has.All.Empty, "the variant's refusal came after the plain record was minted; the mint was given back"); + Assert.That(cache.Get(new CultRecordKey("plain")), Is.Null); + } + + // ---- C2a fix batch 3: mark by content, one object twice ---- + + private static IdDeck EmptyDeck(string name) => new() { Name = name }; + + [Test] + public void ASingleFileStoreIsMarkedByTheIdsItHoldsNotByTheTypesItCouldHold() + { + var empty = PathOf("empty-deck.cc"); + using (var cache = Open(empty)) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e"))); + Assert.That(HeaderOf(empty), Is.EqualTo("cultcache.store.v1"), "a deck with no elements holds no id"); + + var flushed = PathOf("flushed.cc"); + using (var cache = Open(flushed)) + { + cache.UpsertAsync(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + } + + Assert.That(HeaderOf(flushed), Is.EqualTo("cultcache.store.v1"), "the whole-store flush decides by content too"); + + using (var cache = Open(flushed)) + { + cache.UpsertAsync(typeof(IdDeck), Deck("d"), new CultRecordKey("d")).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + } + + Assert.That(HeaderOf(flushed), Is.EqualTo("cultcache.store.v3"), "one element with an id marks the flush"); + + var marked = PathOf("marked.cc"); + using (var cache = Open(marked)) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d"))); + using (var cache = Open(marked)) + { + Assert.That(cache.Commit(batch => + { + batch.Expect(new CultRecordKey("e"), null); + batch.Upsert(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")); + }), Is.True); + } + + Assert.That(HeaderOf(marked), Is.EqualTo("cultcache.store.v3"), "a file already marked on disk stays marked"); + } + + [Test] + public void AnIdAlreadyThereMarksTheStoreAndSoDoesARecordLoadedHoldingOne() + { + var preset = PathOf("preset.cc"); + using (var cache = Open(preset)) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "p", Reels = { new IdReel { Id = HexA } } }, new CultRecordKey("p"))); + Assert.That(HeaderOf(preset), Is.EqualTo("cultcache.store.v3"), "the deck minted nothing, and still holds an id"); + + // The deck reloads holding ids it did not mint; a whole-store flush of a store now holding only that deck and an empty one. + using (var cache = Open(preset)) + { + cache.UpsertAsync(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + } + + Assert.That(HeaderOf(preset), Is.EqualTo("cultcache.store.v3"), "a loaded record's ids count"); + } + + // A record a caller builds and hands straight to a store never passes an admission; the flag still means what it persists. + [TestCase(false, "cultcache.store.v3")] + [TestCase(true, "cultcache.store.v5.directory-content-addressed-pages")] + public void ARecordPushedStraightToAStoreIsMarkedByTheIdsItHolds(bool directory, string marked) + { + var path = PathOf(directory ? "direct-dir.cc" : "direct.cc"); + using var cache = OpenWith(path, directory, typeof(IdDeck)); + var store = cache.BackingStores[0]; + var descriptor = Registry.GetRequired(); + var withId = new IdDeck { Name = "d", Reels = { new IdReel { Id = HexA } } }; + + store.Push(new CultStoredDocument(new CultRecordKey("d"), "2026-09-29T00:00:00.0000000+00:00", descriptor, withId)); + store.PushAll(); + Assert.That(HeaderOf(path), Is.EqualTo(marked)); + + // The directory store writes only its dirty pages and keeps a marked manifest; the single file is whole-store. + if (directory) return; + store.Push(new CultStoredDocument(new CultRecordKey("d"), "2026-09-29T00:00:00.0000000+00:00", descriptor, EmptyDeck("d"))); + store.PushAll(); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "a whole-store flush decides by what the store now holds"); + } + + // Soul's P7 and P10: a store reads the header off what it writes, when it writes it. A staged document changed after it was + // staged, loaded or not, is marked by what it holds at the flush, in both directions. A document changed in place and not + // staged is not written, so it marks nothing. + [TestCase(false)] + [TestCase(true)] + public void AFlushIsMarkedByWhatAStagedDocumentHoldsNowNotWhatItHeldWhenStaged(bool loaded) + { + var path = PathOf(loaded ? "mutated-loaded.cc" : "mutated-staged.cc"); + var key = new CultRecordKey("d"); + using (var seed = Open(path)) + seed.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("d"), key)); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1")); + var seeded = DiskRecord(path, "d"); + + using var cache = Open(path); + var deck = loaded ? cache.Get(key)! : EmptyDeck("d"); + void Stage() => cache.UpsertAsync(typeof(IdDeck), deck, key).GetAwaiter().GetResult(); + if (!loaded) + Stage(); + + deck.Reels.Add(new IdReel { Id = HexA }); + if (loaded) + { + cache.BackingStores[0].PushAll(); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "a change in place that was never staged is not written"); + Assert.That(DiskRecord(path, "d").Payload, Is.EqualTo(seeded.Payload)); + Stage(); + } + + cache.BackingStores[0].PushAll(); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "an id added after staging marks the flush"); + + deck.Reels.Clear(); + Stage(); + cache.BackingStores[0].PushAll(); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "the last id taken away after staging unmarks it"); + } + + // A store with no cache has no codec to read a variant's override values with, so it cannot see whether they hold an id: a + // variant it writes counts as holding one. The same record read through a cache is marked by what its values hold. A + // flush that stages nothing copies the variant as stored. + [Test] + public void AVariantAStoreCannotReadCountsAsHoldingIdsWhenItWritesIt() + { + var path = PathOf("detached-variant.cc"); + using (var cache = Open(path)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base")); + batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), new[] { cache.Override(nameof(IdDeck.Name), "v") }); + }); + } + + using (var cache = Open(path)) + cache.BackingStores[0].PushAll(); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"), "read with a codec, a name override holds no id"); + + var blind = new SingleFileMessagePackBackingStore(path); + blind.UseRegistry(Registry); + blind.PullAll(); + blind.PushAll(); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"), "a flush that staged nothing copies the variant and its header"); + + blind.Push(new CultStoredDocument( + new CultRecordKey("v"), + DateTimeOffset.UtcNow.ToString("O"), + Registry.GetRequired(typeof(IdDeck)), + new CultVariantDelta("base", Array.Empty()), + null, + null)); + blind.PushAll(); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "read without a codec, the variant it writes cannot be seen, so it is marked"); + } + + // Through a real attach: the second cache reads the variant from the file, and the flush it then makes writes the header its + // resolved values hold, never the one a codecless copy would claim (a codecless variant counts as holding ids). + [TestCase("name", "cultcache.store.v2")] + [TestCase("reels", "cultcache.store.v3")] + public void AnAttachedVariantStoreFlushesTheHeaderItsValuesHold(string overridden, string header) + { + var path = PathOf("attached-variant-" + overridden + ".cc"); + using (var cache = Open(path)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base")); + var name = cache.Override(nameof(IdDeck.Name), "v"); + batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), overridden == "name" + ? new[] { name } + : new[] { name, cache.Override(nameof(IdDeck.Reels), new List { new() { Label = "r", Id = HexA } }) }); + }); + } + + using (var second = Open(path)) + { + second.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("other"), new CultRecordKey("other"))); + second.FlushAllBackingStores(); + } + + Assert.That(HeaderOf(path), Is.EqualTo(header)); + } + + [Test] + public void ADirectoryStoreIsMarkedByTheIdsItWritesAndAnEmptyDeckDoesNotMarkIt() + { + var empty = PathOf("empty-dir.cc"); + using (var cache = OpenWith(empty, true, typeof(IdDeck))) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e"))); + Assert.That(HeaderOf(empty), Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages")); + + using (var cache = OpenWith(empty, true, typeof(IdDeck))) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d"))); + Assert.That(HeaderOf(empty), Is.EqualTo("cultcache.store.v5.directory-content-addressed-pages")); + } + + [Test] + public void AVariantIsMarkedByItsOverrideIdsAndOtherwiseIsV2() + { + var plain = PathOf("variant-plain.cc"); + using (var cache = Open(plain)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base")); + batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), new[] { cache.Override(nameof(IdDeck.Name), "v") }); + }); + } + + Assert.That(HeaderOf(plain), Is.EqualTo("cultcache.store.v2")); + + var withIds = PathOf("variant-ids.cc"); + using (var cache = Open(withIds)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base")); + batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), + new[] { cache.Override(nameof(IdDeck.Name), "v"), cache.Override(nameof(IdDeck.Reels), new List { new() { Label = "r" } }) }); + }); + } + + Assert.That(HeaderOf(withIds), Is.EqualTo("cultcache.store.v3")); + + // Reloaded, the variant is the record the store keeps: a whole-store flush beside an empty deck still marks the file. + using (var cache = Open(withIds)) + { + cache.UpsertAsync(typeof(IdDeck), EmptyDeck("e"), new CultRecordKey("e")).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + } + + Assert.That(HeaderOf(withIds), Is.EqualTo("cultcache.store.v3")); + + // Flattened, the variant is a plain record holding the ids its override brought; the base holds none. + using (var cache = Open(withIds)) + { + cache.FlattenAsync(new CultRecordKey("v")).GetAwaiter().GetResult(); + cache.Remove(new CultRecordKey("e")); + cache.FlushAllBackingStores(); + } + + Assert.That(HeaderOf(withIds), Is.EqualTo("cultcache.store.v3")); + } + + // Soul's probe: the base gains ids in the batch that flattens the variant, then leaves. The flattened record is the + // document it stores, so its ids mark the file whatever the base did before or after. + [TestCase(false)] + [TestCase(true)] + public void AFlattenedVariantIsMarkedByTheDocumentItStoresNotByTheOneItWasPlannedAgainst(bool viaFlush) + { + var path = PathOf(viaFlush ? "flatten-flush.cc" : "flatten-commit.cc"); + using (var cache = Open(path)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base")); + batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), new[] { cache.Override(nameof(IdDeck.Name), "v") }); + }); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2")); + + cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), Deck("base", reels: 1), new CultRecordKey("base")); + batch.Flatten(new CultRecordKey("v")); + }); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3")); + + if (viaFlush) + { + cache.Remove(new CultRecordKey("base")); + cache.FlushAllBackingStores(); + } + else + { + Assert.That(cache.Commit(batch => batch.Remove(new CultRecordKey("base"))), Is.True); + } + } + + var flattenedIds = AllIds(MessagePackSerializer.Deserialize(DiskRecord(path, "v").Payload)); + Assert.That(flattenedIds, Is.Not.Empty, "the flattened record holds the base's ids"); + Assert.That(flattenedIds, Has.All.Not.Empty); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "the file holds ids, so it is marked"); + } + + // A conditional commit lands onto the file as it is: it sees only its own batch, so ids only this cache minted (they are on no + // disk) do not mark the file. + [Test] + public void AConditionalCommitOntoLoadMintedIdsDoesNotMarkTheFile() + { + Type[] types = { typeof(IdDeck), typeof(PreCut2FixtureItem) }; + var preId = PathOf("conditional-minted.cc"); + WritePreIdStore(preId, null, ("a", "a", 1)); + using (var cache = OpenWith(preId, false, types)) + { + Assert.That(cache.Commit(batch => + { + batch.Expect(new CultRecordKey("plain"), null); + batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "plain" }, new CultRecordKey("plain")); + }), Is.True); + } + + Assert.That(HeaderOf(preId), Is.EqualTo("cultcache.store.v1"), "a's ids exist only in memory; the file holds none"); + } + + // The last id leaves the store in this commit: an unconditional commit writes the store it will hold, not the one it held. + [Test] + public void AnUnconditionalCommitThatRemovesOrReplacesTheLastIdWritesV1() + { + var removed = PathOf("last-removed.cc"); + using (var cache = Open(removed)) + { + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d"))); + Assert.That(HeaderOf(removed), Is.EqualTo("cultcache.store.v3")); + cache.Commit(batch => batch.Remove(new CultRecordKey("d"))); + } + + Assert.That(HeaderOf(removed), Is.EqualTo("cultcache.store.v1")); + + var replaced = PathOf("last-replaced.cc"); + using (var cache = Open(replaced)) + { + cache.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d"))); + cache.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("d"), new CultRecordKey("d"))); + } + + Assert.That(HeaderOf(replaced), Is.EqualTo("cultcache.store.v1")); + } + + // Commits one plain item through a cache over `path` and reports how many copied records that write decoded to ask whether + // they hold an element id, and the header it left. + private (int Decoded, string Header) CommitItemCountingDecodes(string path, params Type[] types) + { + var registry = CultDocumentRegistry.ForTypes(types); + var store = new SingleFileMessagePackBackingStore(path); + using var cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry)); + cache.AddBackingStore(store); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + store.CopiedRecordsDecoded = 0; + cache.Commit(batch => batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "p" }, new CultRecordKey("p"))); + return (store.CopiedRecordsDecoded, HeaderOf(path)); + } + + // The copy check decodes a record only where the answer can change: each gate, removed, makes one of these counts wrong. + [Test] + public void TheCopyCheckDecodesOnlyWhatCouldMarkTheStore() + { + // A v3 header is already marked: nothing to decide. + var marked = PathOf("gate-marked.cc"); + WritePreIdStore(marked, null, ("a", "a", 1), ("b", "b", 1), ("c", "c", 1)); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(marked)); + snapshot.FormatVersion = CultPersistedStoreSnapshot.FormatV3; + File.WriteAllBytes(marked, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + Assert.That(CommitItemCountingDecodes(marked, typeof(IdDeck), typeof(PreCut2FixtureItem)), Is.EqualTo((0, "cultcache.store.v3")), "a marked store decodes nothing"); + + // Records whose type reaches no object list can hold no id. + var noLists = PathOf("gate-no-lists.cc"); + using (var seed = OpenWith(noLists, false, typeof(PreCut2FixtureItem))) + { + foreach (var key in new[] { "x", "y", "z" }) + UpsertPlain(seed, key); + } + + Assert.That(CommitItemCountingDecodes(noLists, typeof(PreCut2FixtureItem)), Is.EqualTo((0, "cultcache.store.v1")), "a type with no object list is not decoded"); + + // A written record that holds an id already marks the store, whichever of the written records it is. + var stagedPath = PathOf("gate-staged.cc"); + WritePreIdStore(stagedPath, null, ("a", "a", 1), ("b", "b", 1), ("c", "c", 1)); + var stagedStore = new SingleFileMessagePackBackingStore(stagedPath); + var stagedRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck), typeof(PreCut2FixtureItem) }); + using (var cache = new CultCache(stagedRegistry, CultCacheMessagePack.CreateCodec(stagedRegistry))) + { + cache.AddBackingStore(stagedStore); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + stagedStore.CopiedRecordsDecoded = 0; + cache.Commit(batch => + { + batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "plain" }, new CultRecordKey("plain")); + batch.Upsert(typeof(IdDeck), Deck("fresh"), new CultRecordKey("fresh")); + }); + } + + Assert.That(stagedStore.CopiedRecordsDecoded, Is.EqualTo(0), "a staged record holding an id marks without asking"); + Assert.That(HeaderOf(stagedPath), Is.EqualTo("cultcache.store.v3")); + + // Id-capable records without ids: each is decoded once, and the store stays unmarked. + var capable = PathOf("gate-capable.cc"); + WritePreIdStore(capable, null, ("a", "a", 1), ("b", "b", 1), ("c", "c", 1)); + Assert.That(CommitItemCountingDecodes(capable, typeof(IdDeck), typeof(PreCut2FixtureItem)), Is.EqualTo((3, "cultcache.store.v1"))); + + // A record the writer's registry does not claim is foreign: no type, no decode. + var foreign = PathOf("gate-foreign.cc"); + WritePreIdStore(foreign, null, ("a", "a", 1), ("b", "b", 1), ("c", "c", 1)); + Assert.That(CommitItemCountingDecodes(foreign, typeof(PreCut2FixtureItem)), Is.EqualTo((0, "cultcache.store.v1")), "a foreign record is not decoded"); + + // A variant is copied as its bytes: only a C# write of one holding ids marks the store, and that write marked it already. + var variant = PathOf("gate-variant.cc"); + WriteV2StoreWithAnIdLessVariant(variant); + Assert.That(CommitItemCountingDecodes(variant, typeof(IdDeck), typeof(PreCut2FixtureItem)), Is.EqualTo((1, "cultcache.store.v2")), "the plain base is decoded, the variant is not"); + } + + // Soul's probe: a C1 v2 store whose variant override holds no ids. Loading mints them in memory; the store keeps the delta + // it read, so the file holds none and no write of a plain record beside it may mark the file. + private static void WriteV2StoreWithAnIdLessVariant(string path) + { + using (var seed = OpenWith(path, false, typeof(IdDeck), typeof(PreCut2FixtureItem))) + { + seed.Commit(batch => + { + batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base")); + batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), + new[] { seed.Override(nameof(IdDeck.Name), "v"), seed.Override(nameof(IdDeck.Reels), new List { new() }) }); + }); + } + + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var record = snapshot.Records.Single(entry => entry.Key == "v"); + record.Variant = new CultVariantDelta(record.Variant!.BaseKey, record.Variant.Overrides + .Select(entry => entry.Path[0].Slot == 1 ? CultVariantOverride.Set(1, OldShapedReels("old")) : entry).ToArray()); + snapshot.FormatVersion = CultPersistedStoreSnapshot.FormatV2; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + } + + [TestCase("unconditional")] + [TestCase("conditional")] + [TestCase("flush")] + public void AVariantWhoseIdsExistOnlyInMemoryDoesNotMarkTheFileAPlainWriteLandsIn(string write) + { + var path = PathOf("variant-in-memory-" + write + ".cc"); + WriteV2StoreWithAnIdLessVariant(path); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2")); + + using (var cache = OpenWith(path, false, typeof(IdDeck), typeof(PreCut2FixtureItem))) + { + Assert.That(AllIds(cache.Get(new CultRecordKey("v"))!), Has.All.Not.Empty, "the variant holds minted ids in memory"); + var key = new CultRecordKey("p"); + switch (write) + { + case "unconditional": + UpsertPlain(cache, "p"); + break; + case "conditional": + Assert.That(cache.Commit(batch => + { + batch.Expect(key, null); + batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "p" }, key); + }), Is.True); + break; + default: + cache.UpsertAsync(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "p" }, key).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + break; + } + } + + Assert.That(DiskRecord(path, "p"), Is.Not.Null); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"), "the file holds no id, whatever this cache minted in memory"); + } + + // Soul's A/B probe: a conditional commit lands onto the file as it is, so a stale view of what the file once held does not + // decide the header. A holds a deck with ids; B removes it; A commits a plain record conditionally. + [Test] + public void AConditionalCommitIsNotMarkedByIdsItsStaleViewHoldsAndTheFileNoLongerDoes() + { + var path = PathOf("stale-view.cc"); + Type[] types = { typeof(IdDeck), typeof(PreCut2FixtureItem) }; + using var a = OpenWith(path, false, types); + a.Commit(batch => batch.Upsert(typeof(IdDeck), Deck("d"), new CultRecordKey("d"))); + using (var b = OpenWith(path, false, types)) + b.Commit(batch => batch.Remove(new CultRecordKey("d"))); + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "B's whole-store commit left no id"); + Assert.That(a.Get(new CultRecordKey("d")), Is.Not.Null, "A has not pulled: its view is stale"); + + Assert.That(a.Commit(batch => + { + batch.Expect(new CultRecordKey("plain"), null); + batch.Upsert(typeof(PreCut2FixtureItem), new PreCut2FixtureItem { Name = "plain" }, new CultRecordKey("plain")); + }), Is.True); + + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "the merged file holds no id"); + } + + // Any override holding an id marks the variant, wherever it sits among the overrides. + [Test] + public void AVariantWhoseIdBearingOverrideIsNotTheLastIsStillMarked() + { + var path = PathOf("variant-order.cc"); + using (var cache = Open(path)) + { + cache.Commit(batch => + { + batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base")); + batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), + new[] { cache.Override(nameof(IdDeck.Reels), new List { new() { Label = "r" } }), cache.Override(nameof(IdDeck.Name), "v") }); + }); + } + + Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3")); + } + + [Test] + public void AnElementObjectTwiceInOneListIsRefusedAtWriteAndNothingIsPersisted() + { + var path = PathOf("shared.cc"); + using (var cache = Open(path)) + { + var shared = new IdMark { Text = "s" }; + var deck = new IdDeck { Name = "shared", Reels = { new IdReel { Label = "r", Marks = { shared, shared } } } }; + var refusal = Assert.Throws(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), deck, new CultRecordKey("shared"))))!; + Assert.That(refusal.Message, Does.Contain("shared").And.Contain("twice")); + Assert.That(refusal.RecordKey, Is.EqualTo("shared")); + Assert.That(shared.Id, Is.Empty, "the refusal minted nothing"); + Assert.That(cache.Get(new CultRecordKey("shared")), Is.Null); + } + + Assert.That(File.Exists(path), Is.False, "nothing was persisted"); + } + + [Test] + public void ADerivedIdWhoseSourceIsEmptyIsRefusedAtWriteNamingTheSourceMember() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(LabelDoc) }); + using var cache = CultCacheMessagePack.Create(PathOf("label.cc"), new CultCacheOpenOptions { Registry = registry }); + var doc = new LabelDoc { Name = "l", Labels = { new IdLabel { Label = "ok" }, new IdLabel { Label = null } } }; + var refusal = Assert.Throws(() => cache.Commit(batch => batch.Upsert(typeof(LabelDoc), doc, new CultRecordKey("l"))))!; + Assert.That(refusal.Member, Is.EqualTo("Label")); + Assert.That(refusal.RecordKey, Is.EqualTo("l")); + Assert.That(cache.Get(new CultRecordKey("l")), Is.Null); + } + + [CultDocument("tests.element_id_label_doc", "tests.element_id_label_doc.v1")] + [MessagePackObject] + public sealed class LabelDoc + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + [Key(1)] public List Labels { get; set; } = new(); + } + + [MessagePackObject] + public sealed class IdLabel + { + [Key(0)] public string? Label { get; set; } + [Key(1)] [CultElementId(nameof(Label))] public string Id { get; set; } = ""; + } + + [CultDocument("tests.element_id_span_doc", "tests.element_id_span_doc.v1")] + [MessagePackObject] + public sealed class SpanDoc + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + [Key(1)] public List Spans { get; set; } = new(); + } + + [MessagePackObject] + public sealed class IdSpan + { + [Key(0)] public long Offset { get; set; } + [Key(1)] public int Size { get; set; } + [Key(2)] [CultElementId(nameof(Offset))] public string Id { get; set; } = ""; + } + + [MessagePackObject] + public sealed class IdMark + { + [Key(0)] public string Text { get; set; } = ""; + [Key(1)] [CultElementId] public string Id { get; set; } = ""; + } + + [MessagePackObject] + public sealed class IdReel + { + [Key(0)] public string Label { get; set; } = ""; + [Key(1)] public List Marks { get; set; } = new(); + [Key(2)] [CultElementId] public string Id { get; set; } = ""; + } + + [MessagePackObject] + [Union(0, typeof(IdSlash))] + [Union(1, typeof(IdStab))] + public abstract class IdMove + { + [Key(0)] [CultElementId] public string Id { get; set; } = ""; + } + + [MessagePackObject] + public sealed class IdSlash : IdMove + { + [Key(1)] public int Reach { get; set; } + } + + [MessagePackObject] + public sealed class IdStab : IdMove + { + [Key(1)] public int Depth { get; set; } + } + + [CultDocument("tests.element_id_whole_holder", "tests.element_id_whole_holder.v1")] + [MessagePackObject] + public sealed class IdWholeHolder + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + [Key(1)] public ImmutableArray Reels { get; set; } = ImmutableArray.Empty; + } + + [CultDocument("tests.element_id_deck", "tests.element_id_deck.v1")] + [MessagePackObject] + public sealed class IdDeck + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + [Key(1)] public List Reels { get; set; } = new(); + [Key(2)] public List Moves { get; set; } = new(); + } + + // The next version of IdDeck: the same slots under a new schema version. + [CultDocument("tests.element_id_deck", "tests.element_id_deck.v2")] + [MessagePackObject] + public sealed class IdDeckNext + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + [Key(1)] public List Reels { get; set; } = new(); + [Key(2)] public List Moves { get; set; } = new(); + } + + // The shapes before ids: same slots, no id member. Not documents, so never registered. + [MessagePackObject] + public sealed class OldMark + { + [Key(0)] public string Text { get; set; } = ""; + } + + [MessagePackObject] + public sealed class OldReel + { + [Key(0)] public string Label { get; set; } = ""; + [Key(1)] public List Marks { get; set; } = new(); + } + + [MessagePackObject] + public sealed class OldDeck + { + [Key(0)] public string Name { get; set; } = ""; + [Key(1)] public List Reels { get; set; } = new(); + } + } +} diff --git a/tests/GameCult.Caching.Tests/EmittedDocumentTypes.cs b/tests/GameCult.Caching.Tests/EmittedDocumentTypes.cs new file mode 100644 index 00000000..ac6a6dad --- /dev/null +++ b/tests/GameCult.Caching.Tests/EmittedDocumentTypes.cs @@ -0,0 +1,56 @@ +#nullable enable +using System; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; +using MessagePack; + +namespace GameCult.Caching.Tests +{ + // Document types built at run time, each in its own dynamic assembly, which CultDocumentRegistry.Shared never scans. A + // fixture emits a type here when declaring it would change what Shared holds: a pair Shared refuses (one schema declared + // two ways), an alias whose resolved type would depend on scan order, or a type a test needs to itself. + internal static class EmittedDocumentTypes + { + internal readonly record struct Field(string Name, Type Type, int Key, bool IsName = false); + + internal static Type Emit(string typeName, string schemaName, string schemaVersion, Field[]? fields = null, string[]? compatibleSchemaIds = null) + { + var assembly = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName("GameCult.Caching.EmittedDocuments." + Guid.NewGuid().ToString("N")), + AssemblyBuilderAccess.Run); + var type = assembly.DefineDynamicModule("main").DefineType(typeName, TypeAttributes.Class | TypeAttributes.Public | TypeAttributes.Sealed); + var documentConstructor = typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!; + type.SetCustomAttribute(compatibleSchemaIds == null + ? new CustomAttributeBuilder(documentConstructor, new object[] { schemaName, schemaVersion }) + : new CustomAttributeBuilder( + documentConstructor, + new object[] { schemaName, schemaVersion }, + new[] { typeof(CultDocumentAttribute).GetProperty(nameof(CultDocumentAttribute.CompatibleSchemaIds))! }, + new object[] { compatibleSchemaIds })); + type.SetCustomAttribute(new CustomAttributeBuilder( + typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, + new object[] { false })); + foreach (var field in fields ?? Array.Empty()) + { + var builder = type.DefineField(field.Name, field.Type, FieldAttributes.Public); + builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { field.Key })); + if (field.IsName) + builder.SetCustomAttribute(new CustomAttributeBuilder(typeof(CultNameAttribute).GetConstructor(Type.EmptyTypes)!, Array.Empty())); + } + + return type.CreateType()!; + } + + // An instance of an emitted type with the named fields set. + internal static object New(Type type, params (string Field, object? Value)[] values) + { + var document = Activator.CreateInstance(type)!; + foreach (var (field, value) in values) + type.GetField(field)!.SetValue(document, value); + return document; + } + + internal static object? Read(object document, string field) => document.GetType().GetField(field)!.GetValue(document); + } +} diff --git a/tests/GameCult.Caching.Tests/FirstCallTests.cs b/tests/GameCult.Caching.Tests/FirstCallTests.cs new file mode 100644 index 00000000..8540d9d0 --- /dev/null +++ b/tests/GameCult.Caching.Tests/FirstCallTests.cs @@ -0,0 +1,78 @@ +#nullable enable +using System; +using System.IO; +using NUnit.Framework; + +// snippet:begin +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; + +[CultDocument("gamecult.first_call_item", "gamecult.first_call_item.v1")] +[MessagePackObject] +public sealed class Item +{ + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] [CultIndex] public string Category = string.Empty; + [Key(2)] public int Value; +} + +public static class FirstCall +{ + public static async Task Run(string path) + { + var store = new SingleFileMessagePackBackingStore(path); + var cache = new CultCache(); + cache.AddBackingStore(store); + var handle = await cache.AddAsync(new Item { Name = "Potion", Category = "Consumable", Value = 50 }); + store.PushAll(); // write the .cc file + + var reopened = new CultCache(); + reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path)); + await reopened.PullAllBackingStoresAsync(); // read it back + var potion = reopened.Get(handle.Key); // by key + var byName = reopened.GetByName("Potion"); // by [CultName] + return byName ?? potion; + } +} +// snippet:end + +namespace GameCult.Caching.Tests +{ + // The C# first-call snippet that docs/start/README.md shows verbatim: define a typed document, store it + // in a .cc file, read it back through a second cache. DocsShowTheTestedSnippet fails when they differ. + public class FirstCallTests + { + [Test] + public async Task DefineStoreAndReadBackATypedDocument() + { + var path = Path.Combine(Path.GetTempPath(), $"first-call-{Guid.NewGuid():N}.cc"); + try + { + var potion = await FirstCall.Run(path); + Assert.That(potion, Is.Not.Null); + Assert.That(potion!.Value, Is.EqualTo(50)); + Assert.That(potion.Category, Is.EqualTo("Consumable")); + } + finally + { + File.Delete(path); + } + } + + [Test] + public void DocsShowTheTestedSnippet() + { + var source = File.ReadAllText(SourcePath()); + const string begin = "// snippet:begin\n"; + var start = source.IndexOf(begin, StringComparison.Ordinal) + begin.Length; + var snippet = source[start..source.IndexOf("// snippet:end\n", StringComparison.Ordinal)]; + var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(SourcePath())!, "..", "..", "docs", "start", "README.md")); + Assert.That(docs.Replace("\r\n", "\n"), Does.Contain("```csharp\n" + snippet + "```"), + "docs/start/README.md no longer shows tests/GameCult.Caching.Tests/FirstCallTests.cs verbatim"); + } + + static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path; + } +} diff --git a/tests/GameCult.Caching.Tests/ForeignRecordTests.cs b/tests/GameCult.Caching.Tests/ForeignRecordTests.cs new file mode 100644 index 00000000..61aa236e --- /dev/null +++ b/tests/GameCult.Caching.Tests/ForeignRecordTests.cs @@ -0,0 +1,1659 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using GameCult.Caching.MessagePack; +using NUnit.Framework; +using static GameCult.Caching.Tests.EmittedDocumentTypes; + +namespace GameCult.Caching.Tests +{ + // A record under a schema id no registered type owns or lists as compatible is foreign. CultCache never destroys or relabels + // it: both store kinds carry it byte for byte, list it as foreign, and refuse any write that would replace or remove it. + public class ForeignRecordTests + { + private static readonly Type Deck = Emit("ForeignSuiteDeck", "tests.foreign_deck", "tests.foreign_deck.v1", new[] { new Field("Name", typeof(string), 0, IsName: true) }); + // The deck's schema declaring another runtime's id compatible (OtherRuntimeId below): one under the deck's own version, and a + // later version that a registry holding the deck can register beside it. + private static readonly Type ClaimingDeck = Emit("ForeignSuiteClaimingDeck", "tests.foreign_deck", "tests.foreign_deck.v1", new[] { new Field("Name", typeof(string), 0, IsName: true) }, new[] { "other.runtime.deck" }); + private static readonly Type LateClaimingDeck = Emit("ForeignSuiteLateClaimingDeck", "tests.foreign_deck", "tests.foreign_deck.v2", new[] { new Field("Name", typeof(string), 0, IsName: true) }, new[] { "other.runtime.deck" }); + private static readonly Type Widget = Emit("ForeignSuiteWidget", "tests.foreign_widget", "tests.foreign_widget.v1", new[] { new Field("Label", typeof(string), 0, IsName: true) }); + // A second type a cache registers, so that a second backing store has a home of its own. + private static readonly Type Gizmo = Emit("ForeignSuiteGizmo", "tests.foreign_gizmo", "tests.foreign_gizmo.v1", new[] { new Field("Tag", typeof(string), 0, IsName: true) }); + + // A build that has both types, and one that has only the deck: to it the widget is foreign. + private static readonly CultDocumentRegistry Full = CultDocumentRegistry.ForTypes(new[] { Deck, Widget }); + private static readonly CultDocumentRegistry DeckOnly = CultDocumentRegistry.ForTypes(new[] { Deck }); + private static readonly string WidgetId = Full.GetRequired(Widget).SchemaId; + + // Another writer's deck (an older or newer build, another runtime) under an id the deck does not declare. It declares the + // deck's own id compatible, so it may rewrite the deck's records; the deck still reads them through that listing, but only by + // dropping the Note slot it lacks, so it does not own them and holds them read-only. + private static readonly Type MovingDeck = Emit("ForeignSuiteMovingDeck", "tests.foreign_deck", "tests.foreign_deck.v3", new[] { new Field("Name", typeof(string), 0, IsName: true), new Field("Note", typeof(string), 1) }, new[] { DeckOnly.GetRequired(Deck).SchemaId }); + private static readonly CultDocumentRegistry Moving = CultDocumentRegistry.ForTypes(new[] { MovingDeck }); + private static readonly string MovingId = Moving.GetRequired(MovingDeck).SchemaId; + + private static readonly CultRecordKey D = new("d"); + private static readonly CultRecordKey E = new("e"); + private static readonly CultRecordKey W = new("w"); + private static readonly CultRecordKey V = new("v"); + private const string Canary = "CANARY-7f3c-payload-value"; + + public enum Write + { + Flush, + Commit, + ConditionalCommit + } + + private string _directory = ""; + + [SetUp] + public void SetUp() + { + _directory = Path.Combine(Path.GetTempPath(), "cultlib-foreign-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, true); + } + + private string PathOf(string name) => Path.Combine(_directory, name); + + private static CultCache Open(string path, CultDocumentRegistry registry, bool directory) => + CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry, UseDirectoryStore = directory }); + + // The other writer retypes a key: a removal and an add in separate commits (variants:ruling:cache-refuses-retype). + private static void Retype(CultCache writer, Type type, object document, CultRecordKey key) + { + Assert.That(writer.Commit(batch => batch.Remove(key)), Is.True); + Assert.That(writer.Commit(batch => batch.Upsert(type, document, key)), Is.True); + } + + private static object DeckOf(string name) => New(Deck, ("Name", name)); + + private static object WidgetOf(string label) => New(Widget, ("Label", label)); + + private static CultPersistedStoreSnapshot Read(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + + // Every file of the store, by name, with its bytes: a refused write leaves all of them as they were. + private static string[] Fingerprint(string path) + { + var files = new[] { path }.Concat(Directory.Exists(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path)) + ? Directory.GetFiles(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path), "*.msgpack") + : Array.Empty()) + .OrderBy(file => file, StringComparer.Ordinal); + return files.Select(file => Path.GetFileName(file) + ":" + Convert.ToBase64String(File.ReadAllBytes(file))).ToArray(); + } + + // What the store holds for one record as the file (single-file) or manifest (directory: the page hash) names it. + private static string Stored(string path, CultRecordKey key) + { + var record = Read(path).Records.Single(candidate => candidate.Key == key.Value); + return $"{record.SchemaId}|{record.StoredAt}|{Convert.ToBase64String(record.Payload)}|{record.Variant == null}"; + } + + // A store holding a deck d and a widget w, written by the build that has both types. + private string Seeded(string name, bool directory) + { + var path = PathOf(name); + using var cache = Open(path, Full, directory); + cache.Commit(batch => + { + batch.Upsert(Deck, DeckOf("d"), D); + batch.Upsert(Widget, WidgetOf("w"), W); + }); + return path; + } + + private static CacheBackingStore StoreOf(CultCache cache) => cache.BackingStores.Single(); + + private static void Land(CultCache cache, Write write, CultRecordKey key, object document) + { + switch (write) + { + case Write.Flush: + cache.UpsertAsync(document.GetType(), document, key).GetAwaiter().GetResult(); + cache.FlushAsync().GetAwaiter().GetResult(); + break; + case Write.Commit: + Assert.That(cache.Commit(batch => batch.Upsert(document.GetType(), document, key)), Is.True); + break; + case Write.ConditionalCommit: + Assert.That(cache.Commit(batch => + { + batch.Expect(D, cache.Get(D)); + batch.Upsert(document.GetType(), document, key); + }), Is.True); + break; + } + } + + private static void Refused(CultCache cache, Write write, CultRecordKey key, object document, CultRecordKey foreignKey) + { + var refusal = Assert.Throws(() => Land(cache, write, key, document))!; + Assert.Multiple(() => + { + Assert.That(refusal.RecordKey, Is.EqualTo(foreignKey.Value)); + Assert.That(refusal.SchemaId, Is.EqualTo(WidgetId)); + Assert.That(refusal.SchemaNames, Is.EqualTo(new[] { "tests.foreign_widget" })); + Assert.That(refusal.Message, Does.Contain(foreignKey.Value).And.Contain(WidgetId)); + Assert.That(refusal.Message, Does.Not.Contain(Canary), "a refusal never echoes the document it refused"); + }); + } + + [TestCase(false)] + [TestCase(true)] + public void AForeignRecordLoadsAsForeignAndTheCacheDoesNotHoldIt(bool directory) + { + var path = Seeded("seeded.cc", directory); + var storedAt = Read(path).Records.Single(record => record.Key == W.Value).StoredAt; + + using var cache = Open(path, DeckOnly, directory); + + var foreign = StoreOf(cache).ForeignRecords.Single(); + Assert.Multiple(() => + { + Assert.That(foreign.Key, Is.EqualTo(W.Value)); + Assert.That(foreign.SchemaId, Is.EqualTo(WidgetId)); + Assert.That(foreign.SchemaName, Is.EqualTo("tests.foreign_widget")); + Assert.That(foreign.StoredAt, Is.EqualTo(storedAt)); + Assert.That(cache.Get(W), Is.Null, "the cache never holds a foreign record"); + Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("d"), "the records it claims load as usual"); + }); + } + + // Every write path lays the foreign record back as the file holds it: the single-file store's whole view (a flush and an + // unconditional commit), its commit onto the file, and the directory store's generation. + [Test] + public void AWriteOfAnotherRecordKeepsTheForeignRecordByteForByte( + [Values(false, true)] bool directory, + [Values] Write write) + { + var path = Seeded("kept.cc", directory); + var before = Stored(path, W); + + using (var cache = Open(path, DeckOnly, directory)) + Land(cache, write, E, DeckOf("e")); + + Assert.That(Stored(path, W), Is.EqualTo(before)); + Assert.That(Read(path).Records.Select(record => record.Key), Is.EquivalentTo(new[] { "d", "e", "w" })); + using var full = Open(path, Full, directory); + Assert.That(EmittedDocumentTypes.Read(full.Get(W)!, "Label"), Is.EqualTo("w"), "the build that has the type still reads it"); + Assert.That(StoreOf(full).ForeignRecords, Is.Empty); + } + + [Test] + public void AWriteThatWouldReplaceAForeignRecordIsRefusedAndNothingIsWritten( + [Values(false, true)] bool directory, + [Values] Write write) + { + var path = Seeded("replace.cc", directory); + var before = Fingerprint(path); + + using var cache = Open(path, DeckOnly, directory); + Refused(cache, write, W, DeckOf(Canary), W); + + Assert.That(Fingerprint(path), Is.EqualTo(before)); + Assert.That(cache.Get(W), Is.Null); + } + + // Another writer replaced a record this cache holds with a record of a type it does not have. A write that would lay this + // cache's record over it is refused; the file keeps the other writer's record. The refusal reloads it as a pull would: the + // store lists it foreign, so the next write of it is refused before staging. + [Test] + public void AWriteOverARecordAnotherWriterMadeForeignIsRefused([Values] bool directory, [Values(Write.Flush, Write.Commit)] Write write) + { + var path = Seeded("replaced.cc", directory); + using var cache = Open(path, DeckOnly, directory); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), D); + var before = Fingerprint(path); + + var refusal = Assert.Throws(() => Land(cache, write, D, DeckOf(Canary)))!; + + Assert.That(refusal.RecordKey, Is.EqualTo(D.Value)); + Assert.That(refusal.Message, Does.Not.Contain(Canary)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + Assert.That(StoreOf(cache).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "d", "w" })); + Assert.Throws(() => cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult()); + Assert.That(cache.IsDirty, Is.False); + } + + // A refusal does not poison the store: what the cache held for the refused record goes with the refusal, so a later write + // to another record lands and the file keeps the other writer's record. + [Test] + public void AfterARefusedWriteAWriteToAnotherRecordLands( + [Values(false, true)] bool directory, + [Values(Write.Flush, Write.Commit)] Write refused) + { + var path = Seeded("unstuck.cc", directory); + using var cache = Open(path, DeckOnly, directory); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), D); + var widget = Stored(path, D); + + Assert.Throws(() => Land(cache, refused, D, DeckOf(Canary))); + Land(cache, Write.Commit, E, DeckOf("e")); + + Assert.That(Stored(path, D), Is.EqualTo(widget), "the other writer's record is as it was"); + Assert.That(EmittedDocumentTypes.Read(cache.Get(E)!, "Name"), Is.EqualTo("e")); + Assert.That(Read(path).Records.Select(record => record.Key), Does.Contain("e")); + } + + // The cache and the store agree on what a refusal left: the refused record is not served, not after a later write, not after + // a pull, and not by a fresh open. What the cache served before the refusal was never what the file holds. + [Test] + public void ARefusedRecordIsNotServedAfterTheRefusalAfterALaterWriteOrAfterAPull( + [Values(false, true)] bool directory, + [Values(Write.Flush, Write.Commit)] Write refused) + { + var path = Seeded("not-served.cc", directory); + using var cache = Open(path, DeckOnly, directory); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), D); + + var refusal = Assert.Throws(() => Land(cache, refused, D, DeckOf(Canary)))!; + Assert.That(refusal.Message, Does.StartWith("Record 'd' (schema id '" + WidgetId + "'"), "one refused key reads as one record"); + var afterRefusal = cache.Get(D); + Land(cache, Write.Commit, E, DeckOf("e")); + var afterLaterWrite = cache.Get(D); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + var afterPull = cache.Get(D); + using var fresh = Open(path, DeckOnly, directory); + + Assert.Multiple(() => + { + Assert.That(afterRefusal, Is.Null, "after the refusal"); + Assert.That(afterLaterWrite, Is.Null, "after a later write"); + Assert.That(afterPull, Is.Null, "after a pull"); + Assert.That(fresh.Get(D), Is.Null, "a fresh open"); + Assert.That(StoreOf(cache).ForeignRecords.Select(record => record.Key), Is.EqualTo(StoreOf(fresh).ForeignRecords.Select(record => record.Key))); + }); + } + + // A refusal names every key it refused, and every one of them leaves the cache; a record the write did not touch stays. + [Test] + public void ARefusalOfSeveralRecordsNamesEachAndTheCacheDropsEach( + [Values(false, true)] bool directory, + [Values(Write.Flush, Write.Commit)] Write refused) + { + var path = PathOf("several.cc"); + var d2 = new CultRecordKey("d2"); + using (var seed = Open(path, Full, directory)) + seed.Commit(batch => + { + batch.Upsert(Deck, DeckOf("d"), D); + batch.Upsert(Deck, DeckOf("d2"), d2); + batch.Upsert(Deck, DeckOf("e"), E); + }); + using var cache = Open(path, DeckOnly, directory); + using (var other = Open(path, Full, directory)) + { + Retype(other, Widget, WidgetOf("w1"), D); + Retype(other, Widget, WidgetOf("w2"), d2); + } + + var refusal = Assert.Throws(() => + { + if (refused == Write.Flush) + { + cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult(); + cache.UpsertAsync(Deck, DeckOf(Canary), d2).GetAwaiter().GetResult(); + cache.FlushAsync().GetAwaiter().GetResult(); + } + else + { + cache.Commit(batch => + { + batch.Upsert(Deck, DeckOf(Canary), D); + batch.Upsert(Deck, DeckOf(Canary), d2); + }); + } + })!; + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + + Assert.Multiple(() => + { + Assert.That(refusal.RecordKey, Is.EqualTo("d"), "the first refused key in key order"); + Assert.That(refusal.Message, Does.Contain("Records 'd' (schema id '" + WidgetId + "'").And.Contain("'d2' (schema id '" + WidgetId + "'").And.Not.Contain(Canary)); + Assert.That(cache.Get(D), Is.Null); + Assert.That(cache.Get(d2), Is.Null); + Assert.That(EmittedDocumentTypes.Read(cache.Get(E)!, "Name"), Is.EqualTo("e"), "a record the write did not touch stays"); + }); + } + + // A write that never touches a record another writer made foreign is not refused for holding a stale copy of it: it lands + // the first time, in both store kinds, and the next pull drops the copy. + [Test] + public void AWriteThatNeverTouchedARecordAnotherWriterMadeForeignLandsAtOnce( + [Values(false, true)] bool directory, + [Values(Write.Flush, Write.Commit)] Write write) + { + var path = Seeded("untouched.cc", directory); + using var cache = Open(path, DeckOnly, directory); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), D); + var widget = Stored(path, D); + + Land(cache, write, E, DeckOf("e")); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + + Assert.Multiple(() => + { + Assert.That(Stored(path, D), Is.EqualTo(widget), "the other writer's record is as it was"); + Assert.That(Read(path).Records.Select(record => record.Key), Does.Contain("e")); + Assert.That(cache.Get(D), Is.Null, "the pull drops the stale copy"); + }); + } + + // The write carried the file's record for d; it did not store this cache's copy, so the copy's stamp is still what the + // cache read, and a condition on it still fails against the other writer's record. + [Test] + public void ACarriedForeignRecordDoesNotRestampTheStaleCopyTheCacheHolds( + [Values(false, true)] bool directory, + [Values(Write.Flush, Write.Commit)] Write write) + { + var path = Seeded("restamp.cc", directory); + using var cache = Open(path, DeckOnly, directory); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), D); + + Land(cache, write, E, DeckOf("e")); + + Assert.That(cache.Commit(batch => + { + batch.Expect(D, cache.Get(D)); + batch.Upsert(Deck, DeckOf("e2"), E); + }), Is.False, "the cache never read the other writer's record"); + } + + // The same when the stale copy was only held, never written: a flush after the other writer's record landed refuses once. + [Test] + public void AFlushRefusedForAStaleCopyOfAForeignRecordRefusesOnce([Values(false, true)] bool directory) + { + var path = Seeded("stale.cc", directory); + using var cache = Open(path, DeckOnly, directory); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), D); + var widget = Stored(path, D); + + Assert.Throws(() => Land(cache, Write.Commit, D, DeckOf(Canary))); + Land(cache, Write.Flush, E, DeckOf("e")); + Land(cache, Write.Flush, E, DeckOf("e again")); + + Assert.That(Stored(path, D), Is.EqualTo(widget)); + } + + // A key staged and not yet written is refused by any write that carries it, whatever key that write names: an unconditional + // commit writes what a flush would. + [Test] + public void AStagedWriteIsRefusedByACommitOfAnotherRecord([Values(false, true)] bool directory) + { + var path = Seeded("staged-commit.cc", directory); + using var cache = Open(path, DeckOnly, directory); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), D); + var widget = Stored(path, D); + + cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult(); + var refusal = Assert.Throws(() => cache.Commit(batch => batch.Upsert(Deck, DeckOf("e"), E)))!; + + Assert.That(refusal.RecordKey, Is.EqualTo(D.Value)); + Assert.That(Stored(path, D), Is.EqualTo(widget)); + } + + // What a write landed is no longer staged: a record that becomes foreign afterwards is the other writer's, and writing + // another record is not refused for it. + [Test] + public void AWrittenRecordIsNoLongerStagedSoALaterForeignRecordDoesNotRefuseOtherWrites( + [Values(false, true)] bool directory, + [Values(Write.Flush, Write.Commit)] Write landed) + { + var path = Seeded("landed.cc", directory); + using var cache = Open(path, DeckOnly, directory); + Land(cache, landed, D, DeckOf("d again")); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), D); + var widget = Stored(path, D); + + Land(cache, Write.Commit, E, DeckOf("e")); + + Assert.That(Stored(path, D), Is.EqualTo(widget)); + Assert.That(Read(path).Records.Select(record => record.Key), Does.Contain("e")); + } + + // A commit writes the whole view, so what it landed that was staged is no longer staged either. + [Test] + public void AStagedWriteLandedByACommitIsNoLongerStaged([Values(false, true)] bool directory) + { + var path = Seeded("staged-landed.cc", directory); + using var cache = Open(path, DeckOnly, directory); + cache.UpsertAsync(Deck, DeckOf("d staged"), D).GetAwaiter().GetResult(); + Land(cache, Write.Commit, E, DeckOf("e")); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), D); + var widget = Stored(path, D); + + Land(cache, Write.Commit, new CultRecordKey("e2"), DeckOf("e2")); + + Assert.That(Stored(path, D), Is.EqualTo(widget)); + Assert.That(Read(path).Records.Select(record => record.Key), Does.Contain("e2")); + } + + // A staged removal is a write of its own: a refusal takes it too. + [TestCase(false)] + [TestCase(true)] + public void AStagedRemovalOfARecordAnotherWriterMadeForeignIsRefusedOnceAndThenGone(bool directory) + { + var path = Seeded("staged-removal.cc", directory); + using var cache = Open(path, DeckOnly, directory); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), D); + var widget = Stored(path, D); + + cache.Remove(D); + var refusal = Assert.Throws(() => cache.FlushAsync().GetAwaiter().GetResult())!; + Land(cache, Write.Commit, E, DeckOf("e")); + + Assert.That(refusal.RecordKey, Is.EqualTo(D.Value)); + Assert.That(Stored(path, D), Is.EqualTo(widget)); + } + + // What a refusal leaves staged is exactly what it did not refuse: the other record's write still lands on the next flush, + // and a store with nothing left staged is clean. + [TestCase(false)] + [TestCase(true)] + public void ARefusalLeavesStagedWhatItDidNotRefuse(bool directory) + { + var path = Seeded("pending.cc", directory); + using var cache = Open(path, DeckOnly, directory); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), D); + + cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult(); + Assert.Throws(() => cache.FlushAsync().GetAwaiter().GetResult()); + Assert.That(cache.IsDirty, Is.False, "nothing is left staged"); + + cache.Commit(batch => batch.Upsert(Deck, DeckOf("e"), E)); + cache.UpsertAsync(Deck, DeckOf(Canary), E).GetAwaiter().GetResult(); + cache.UpsertAsync(Deck, DeckOf("v"), V).GetAwaiter().GetResult(); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), E); + Assert.Throws(() => cache.FlushAsync().GetAwaiter().GetResult()); + Assert.That(cache.IsDirty, Is.True, "v is still staged"); + cache.FlushAsync().GetAwaiter().GetResult(); + + Assert.That(Read(path).Records.Select(record => record.Key), Does.Contain("v")); + Assert.That(cache.IsDirty, Is.False); + } + + [TestCase(false)] + [TestCase(true)] + public void RemovingARecordAnotherWriterMadeForeignIsRefused(bool directory) + { + var path = Seeded("removed.cc", directory); + using var cache = Open(path, DeckOnly, directory); + cache.Commit(batch => batch.Upsert(Deck, DeckOf("e"), E)); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), D); + var before = Fingerprint(path); + + // A condition keeps the single-file commit on the file as it is, so only the removal reaches d. + var refusal = Assert.Throws(() => cache.Commit(batch => + { + batch.Expect(E, cache.Get(E)); + batch.Remove(D); + }))!; + + Assert.That(refusal.RecordKey, Is.EqualTo(D.Value)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + [TestCase(false)] + [TestCase(true)] + public void ARecordAnotherWriterMadeForeignLeavesTheCacheAtThePull(bool directory) + { + var path = Seeded("pulled.cc", directory); + using var cache = Open(path, DeckOnly, directory); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), D); + + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + + Assert.That(cache.Get(D), Is.Null); + Assert.That(StoreOf(cache).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "d", "w" })); + } + + // A directory load without a lease that a writer's commit forces to retry lists each foreign record once, from the manifest + // the retry read. + [Test] + public void ARetriedDirectoryLoadListsEachForeignRecordOnce() + { + var path = Seeded("retried.cc", directory: true); + var store = new DirectoryMessagePackBackingStore(path); + using var cache = new CultCache(DeckOnly); + cache.AddBackingStore(store); + File.Delete(Path.Combine(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path), ".commit.lock")); + var commits = 0; + // The writer is another cache, so it commits from another thread; it commits once, under the first unleased read. + store.UnleasedManifestRead = () => + { + if (commits++ > 0) + return; + System.Threading.Tasks.Task.Run(() => + { + using var writer = Open(path, Full, directory: true); + writer.Commit(batch => batch.Upsert(Deck, DeckOf("e"), E)); + }).GetAwaiter().GetResult(); + }; + + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + + Assert.That(cache.Get(E), Is.Not.Null, "the load retried and read the writer's generation"); + Assert.That(store.ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" })); + } + + // The file decides what is carried, not what this cache loaded: a foreign record another writer added since is kept too. + [TestCase(false)] + [TestCase(true)] + public void AForeignRecordAddedAfterTheLoadIsKept(bool directory) + { + var path = Seeded("added.cc", directory); + using var cache = Open(path, DeckOnly, directory); + var z = new CultRecordKey("z"); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("z"), z); + var before = Stored(path, z); + + Land(cache, Write.Flush, E, DeckOf("e")); + + Assert.That(Stored(path, z), Is.EqualTo(before)); + } + + // A writer cannot see whether a foreign record holds element ids, so a store marked as holding them stays marked while it + // carries one: a write that copies any record forward keeps the mark. + [Test] + public void AMarkedHeaderStaysMarkedWhileTheStoreCarriesAForeignRecord([Values(Write.Flush, Write.Commit)] Write write) + { + var path = Seeded("marked.cc", directory: false); + var snapshot = Read(path); + snapshot.FormatVersion = CultPersistedStoreSnapshot.FormatV3; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + + using (var cache = Open(path, DeckOnly, directory: false)) + Land(cache, write, E, DeckOf("e")); + + Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); + } + + private const string OtherRuntimeId = "other.runtime.deck"; + + // A deck another runtime wrote under its own id for the deck's schema, with a slot this build lacks. This build reads it + // through the catalog's schema name, but only by dropping that slot, so it does not own the id. + private string OtherRuntimeStore(string name, bool withVariant = false) + { + var path = PathOf(name); + using (var cache = Open(path, DeckOnly, directory: false)) + { + cache.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D)); + if (withVariant) + cache.Commit(batch => batch.UpsertVariant(V, D, new[] { cache.Override(Deck, "Name", "v") })); + } + + var snapshot = Read(path); + var entry = snapshot.SchemaCatalog.Single(); + entry.SchemaId = OtherRuntimeId; + entry.ContentHash = OtherRuntimeId; + entry.CompatibleSchemaIds = new[] { OtherRuntimeId }; + entry.Members = entry.Members.Append(new CultSchemaMemberCatalogEntry { Slot = 99, MemberName = "Dropped", TypeName = "System.String" }).ToArray(); + foreach (var record in snapshot.Records) + record.SchemaId = OtherRuntimeId; + // An older entry that also lists the id, first in the catalog: the entry that owns the id is the one that publishes it. + snapshot.SchemaCatalog = new[] + { + new CultSchemaCatalogEntry + { + SchemaId = "decoy.older", SchemaName = "tests.decoy", SchemaVersion = "tests.decoy.v1", ContentHash = "decoy.older", + CompatibleSchemaIds = new[] { "decoy.older", OtherRuntimeId } + }, + entry + }; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + return path; + } + + // A store whose file is gone holds nothing, foreign records included. + [Test] + public void AStoreWhoseFileIsGoneListsNoForeignRecords() + { + var path = Seeded("gone.cc", directory: false); + using var cache = Open(path, DeckOnly, directory: false); + File.Delete(path); + + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + + Assert.That(StoreOf(cache).ForeignRecords, Is.Empty); + } + + // The registry's public resolution still refuses an id no registered type claims, naming the schema and the id: only a store + // carries such a record. + [Test] + public void ResolvingAnUnclaimedIdThroughTheRegistryRefusesByName() + { + var entry = Full.GetRequired(Widget).ToCatalogEntry(); + + var refusal = Assert.Throws(() => DeckOnly.ResolvePersistedSchema(WidgetId, new[] { entry }))!; + + Assert.That(refusal.Message, Does.Contain("tests.foreign_widget").And.Contain(WidgetId)); + } + + private static byte[] CatalogEntryBytes(string path, string schemaId) => + CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot + { + SchemaCatalog = Read(path).SchemaCatalog.Where(entry => entry.SchemaId == schemaId).ToArray() + }); + + // A write of another record copies a record under an id this build does not own as the store holds it: its id, storedAt, + // bytes and the catalog entry that publishes its id. + [Test] + public void AWriteOfAnotherRecordCopiesARecordUnderAnIdThisBuildDoesNotOwn([Values] Write write) + { + var path = OtherRuntimeStore("other-runtime.cc"); + var before = Stored(path, D); + var entry = CatalogEntryBytes(path, OtherRuntimeId); + + using (var cache = Open(path, DeckOnly, directory: false)) + { + Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("d"), "this build reads it"); + Land(cache, write, E, DeckOf("e")); + } + + Assert.That(Stored(path, D), Is.EqualTo(before)); + Assert.That(CatalogEntryBytes(path, OtherRuntimeId), Is.EqualTo(entry)); + using var reopened = Open(path, DeckOnly, directory: false); + Assert.That(EmittedDocumentTypes.Read(reopened.Get(E)!, "Name"), Is.EqualTo("e"), "the write beside the read-only record landed"); + } + + // The entry that publishes a copied record is the one the store holds when the write lands, not the one the record loaded with. + [Test] + public void ACopiedRecordIsPublishedByTheCatalogEntryTheStoreHoldsNow([Values(Write.Flush, Write.Commit)] Write write) + { + var path = OtherRuntimeStore("redescribed.cc"); + using var cache = Open(path, DeckOnly, directory: false); + var snapshot = Read(path); + snapshot.SchemaCatalog.Single(entry => entry.SchemaId == OtherRuntimeId).SchemaVersion = "tests.foreign_deck.redescribed"; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var current = CatalogEntryBytes(path, OtherRuntimeId); + + Land(cache, write, E, DeckOf("e")); + + Assert.That(CatalogEntryBytes(path, OtherRuntimeId), Is.EqualTo(current)); + } + + // A write over a foreign record is refused at the write itself, in both store kinds, not first at the flush: nothing is staged + // and the cache never holds the record. + [Test] + public void AWriteOverAForeignRecordIsRefusedBeforeStaging([Values] bool directory) + { + var path = Seeded(directory ? "staged-dir.cc" : "staged-file.cc", directory); + var before = Fingerprint(path); + + using (var cache = Open(path, DeckOnly, directory)) + { + var refusal = Assert.Throws(() => cache.UpsertAsync(Deck, DeckOf(Canary), W).GetAwaiter().GetResult())!; + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId))); + Assert.That(refusal.Message, Does.Not.Contain(Canary)); + Assert.That(cache.IsDirty, Is.False, "nothing was staged"); + Assert.That(cache.Get(W), Is.Null); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + // A record read through the catalog's schema name under an id this build neither owns nor declares is read-only: a write of + // it is refused typed before anything is staged, the store is left byte for byte, and the record is still served. + [Test] + public void ARecordReadByNameIsReadOnly([Values(Write.Flush, Write.Commit)] Write write) + { + var path = OtherRuntimeStore("read-only.cc"); + var before = Fingerprint(path); + + using (var cache = Open(path, DeckOnly, directory: false)) + { + var refusal = Assert.Throws(() => Land(cache, write, D, DeckOf("changed")))!; + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((D.Value, OtherRuntimeId))); + Assert.That(refusal.SchemaNames, Is.EqualTo(new[] { "tests.foreign_deck" }), "it names the type that reads it"); + Assert.That(cache.IsDirty, Is.False); + Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("d")); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + public enum ReadOnlyWrite + { + Upsert, + UpsertVariantAtItsKey, + Flatten, + Remove, + CommitUpsert, + CommitRemove + } + + // Every cache write path refuses a staged write or removal of a read-only record before staging: the typed conflict, nothing + // dirty, the store byte for byte after a later flush, and the record still served. + [Test] + public void EveryWritePathRefusesAReadOnlyRecord([Values] ReadOnlyWrite path) + { + var file = OtherRuntimeStore("every-path-" + path + ".cc", withVariant: true); + var before = Fingerprint(file); + + using (var cache = Open(file, DeckOnly, directory: false)) + { + var key = path is ReadOnlyWrite.UpsertVariantAtItsKey or ReadOnlyWrite.Flatten ? V : D; + var refusal = Assert.Throws(() => + { + switch (path) + { + case ReadOnlyWrite.Upsert: + cache.UpsertAsync(Deck, DeckOf("changed"), D).GetAwaiter().GetResult(); + break; + case ReadOnlyWrite.UpsertVariantAtItsKey: + cache.UpsertVariantAsync(V, D, new[] { cache.Override(Deck, "Name", "changed") }).GetAwaiter().GetResult(); + break; + case ReadOnlyWrite.Flatten: + cache.FlattenAsync(V).GetAwaiter().GetResult(); + break; + case ReadOnlyWrite.Remove: + cache.Remove(D); + break; + case ReadOnlyWrite.CommitUpsert: + cache.Commit(batch => batch.Upsert(Deck, DeckOf("changed"), D)); + break; + case ReadOnlyWrite.CommitRemove: + cache.Commit(batch => batch.Remove(D)); + break; + } + })!; + + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((key.Value, OtherRuntimeId))); + Assert.That(cache.IsDirty, Is.False, "nothing was staged"); + Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("d")); + Assert.That(EmittedDocumentTypes.Read(cache.Get(V)!, "Name"), Is.EqualTo("v")); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + Assert.That(Fingerprint(file), Is.EqualTo(before)); + } + + // A type that declares the id compatible owns the record: its write lands under the type's own id and reopens. + [Test] + public void DeclaringTheIdClaimsTheRecord() + { + var path = OtherRuntimeStore("declared.cc"); + var claiming = CultDocumentRegistry.ForTypes(new[] { ClaimingDeck }); + + using (var cache = Open(path, claiming, directory: false)) + cache.Commit(batch => batch.Upsert(ClaimingDeck, New(ClaimingDeck, ("Name", "claimed")), D)); + + Assert.That(Read(path).Records.Single().SchemaId, Is.EqualTo(claiming.GetRequired(ClaimingDeck).SchemaId)); + using var reopened = Open(path, claiming, directory: false); + Assert.That(EmittedDocumentTypes.Read(reopened.Get(D)!, "Name"), Is.EqualTo("claimed")); + } + + // Whether a record is writable is decided at the write, not fixed at load, and by the writing type alone: a type registered + // after the load that declares the id writes the record and stores it under its own id, and a registry holding it does not make + // another type the record's writer. + [Test] + public void ATypeRegisteredAfterLoadThatDeclaresTheIdMakesItWritable() + { + var path = OtherRuntimeStore("late.cc"); + var registry = CultDocumentRegistry.ForTypes(new[] { Deck }); + + using (var cache = Open(path, registry, directory: false)) + { + Assert.Throws(() => cache.Commit(batch => batch.Upsert(Deck, DeckOf("refused"), D))); + registry.GetRequired(LateClaimingDeck); + Assert.Throws(() => cache.Commit(batch => batch.Upsert(Deck, DeckOf("still refused"), D)), "the registry's other type does not make the record the writing type's"); + cache.Commit(batch => batch.Upsert(LateClaimingDeck, New(LateClaimingDeck, ("Name", "claimed")), D)); + } + + Assert.That(Read(path).Records.Single().SchemaId, Is.EqualTo(registry.GetRequired(LateClaimingDeck).SchemaId)); + using var reopened = Open(path, CultDocumentRegistry.ForTypes(new[] { LateClaimingDeck }), directory: false); + Assert.That(EmittedDocumentTypes.Read(reopened.Get(D)!, "Name"), Is.EqualTo("claimed")); + } + + // The removal rule asks the registry as it is when the removal is judged: a record read-only at load becomes removable once a + // type that declares its id is registered. The write rule is not the removal rule (the test above): the late type may remove + // the record, and a removal is judged by what the registry declares, not by the type that last wrote the key. + [Test] + public void ATypeRegisteredAfterLoadThatDeclaresTheIdMakesTheRecordRemovable() + { + var path = OtherRuntimeStore("late-remove.cc"); + var registry = CultDocumentRegistry.ForTypes(new[] { Deck }); + + using (var cache = Open(path, registry, directory: false)) + { + Assert.Throws(() => cache.Commit(batch => batch.Remove(D)), "no registered type declares the id yet"); + registry.GetRequired(LateClaimingDeck); + Assert.That(cache.Commit(batch => batch.Remove(D)), Is.True); + Assert.That(cache.Get(D), Is.Null); + } + + Assert.That(Read(path).Records, Is.Empty); + } + + // A commit onto the file that involves a variant is judged on the set the file will hold. A foreign record is not decoded + // for that judgement, so it neither fails the commit nor joins the set. + [Test] + public void ACommitInvolvingAVariantIsNotRefusedByAForeignRecord() + { + var path = Seeded("variant.cc", directory: false); + var before = Stored(path, W); + using var cache = Open(path, DeckOnly, directory: false); + var v = new CultRecordKey("v"); + cache.UpsertVariantAsync(v, D, new[] { cache.Override(Deck, "Name", "v") }).GetAwaiter().GetResult(); + cache.FlushAsync().GetAwaiter().GetResult(); + + Land(cache, Write.ConditionalCommit, E, DeckOf("e")); + + Assert.That(Stored(path, W), Is.EqualTo(before)); + Assert.That(Read(path).Records.Select(record => record.Key), Is.EquivalentTo(new[] { "d", "e", "v", "w" })); + } + + // A variant's base that another writer replaced with a foreign record is gone from the set the file will hold, so a commit + // onto that file is refused as it would be if the base had been removed. + [Test] + public void ACommitOntoAFileWhereAForeignRecordReplacedAVariantsBaseIsRefused() + { + var path = Seeded("base.cc", directory: false); + using var cache = Open(path, DeckOnly, directory: false); + var v = new CultRecordKey("v"); + cache.UpsertVariantAsync(v, D, new[] { cache.Override(Deck, "Name", "v") }).GetAwaiter().GetResult(); + cache.FlushAsync().GetAwaiter().GetResult(); + var snapshot = Read(path); + var widget = snapshot.Records.Single(record => record.Key == W.Value); + snapshot.Records = snapshot.Records + .Select(record => record.Key == D.Value + ? new CultPersistedRecord { Key = D.Value, SchemaId = widget.SchemaId, StoredAt = widget.StoredAt, Payload = widget.Payload } + : record) + .ToArray(); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var before = Fingerprint(path); + + var refusal = Assert.Throws(Is.InstanceOf(), () => cache.Commit(batch => + { + batch.Expect(E, null); + batch.Upsert(Deck, DeckOf("e"), E); + }))!; + + Assert.That(refusal.Message, Does.Contain(v.Value).And.Contain(D.Value)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + // The other writer rewrites a record under its own id, which this build reads but does not declare. + private static void Move(string path, CultRecordKey key, string name, bool directory) + { + using var other = Open(path, Moving, directory); + Assert.That(other.Commit(batch => batch.Upsert(MovingDeck, New(MovingDeck, ("Name", name)), key)), Is.True); + } + + private static void RefusedOnDisk(CultSchemaConflictException refusal, CultRecordKey key) + { + Assert.Multiple(() => + { + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((key.Value, MovingId))); + Assert.That(refusal.SchemaNames, Is.EqualTo(new[] { "tests.foreign_deck" })); + Assert.That(refusal.Message, Does.Not.Contain(Canary), "a refusal never echoes the document it refused"); + }); + } + + // The staging half cannot see a record another writer moved to an undeclared id after this cache loaded it: the write is + // refused where it lands, under the store's lock, in both store kinds. Nothing is written, and the cache reloads the record as + // the store holds it, read-only, so the next write of it is refused before staging. + [Test] + public void AWriteOverARecordAnotherWriterMovedToAnUndeclaredIdIsRefusedAndReloadedReadOnly( + [Values] bool directory, + [Values(Write.Flush, Write.Commit)] Write write) + { + var path = PathOf("moved.cc"); + using var cache = Open(path, DeckOnly, directory); + cache.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D)); + if (write == Write.Flush) + cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult(); + Move(path, D, "moved", directory); + var before = Fingerprint(path); + + var refusal = Assert.Throws(() => + { + if (write == Write.Flush) + cache.FlushAsync().GetAwaiter().GetResult(); + else + cache.Commit(batch => batch.Upsert(Deck, DeckOf(Canary), D)); + })!; + + RefusedOnDisk(refusal, D); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("moved"), "the cache serves what the store holds"); + Assert.That(cache.IsDirty, Is.False); + RefusedOnDisk(Assert.Throws(() => cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult())!, D); + Assert.That(cache.IsDirty, Is.False, "the second write was refused at staging"); + cache.FlushAsync().GetAwaiter().GetResult(); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + [Test] + public void AStagedRemovalOfARecordMovedToAnUndeclaredIdIsRefused([Values] bool directory) + { + var path = PathOf("moved-removal.cc"); + using var cache = Open(path, DeckOnly, directory); + cache.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D)); + Assert.That(cache.Remove(D), Is.True); + Move(path, D, "moved", directory); + var moved = Stored(path, D); + + RefusedOnDisk(Assert.Throws(() => cache.FlushAsync().GetAwaiter().GetResult())!, D); + + Assert.That(Stored(path, D), Is.EqualTo(moved)); + Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("moved")); + Assert.That(cache.IsDirty, Is.False); + } + + // A key this cache never held, which another writer created under an undeclared id after the load: the write is refused, and + // the cache then holds the record read-only. + [Test] + public void ARecordThatAppearedUnderAnUndeclaredIdAtAWrittenKeyIsRefusedAndHeld([Values] bool directory) + { + var path = PathOf("appeared.cc"); + using var cache = Open(path, DeckOnly, directory); + cache.Commit(batch => batch.Upsert(Deck, DeckOf("e"), E)); + Move(path, D, "appeared", directory); + var before = Fingerprint(path); + + cache.UpsertAsync(Deck, DeckOf(Canary), D).GetAwaiter().GetResult(); + RefusedOnDisk(Assert.Throws(() => cache.FlushAsync().GetAwaiter().GetResult())!, D); + + Assert.That(Fingerprint(path), Is.EqualTo(before)); + Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("appeared")); + RefusedOnDisk(Assert.Throws(() => cache.Commit(batch => batch.Upsert(Deck, DeckOf(Canary), D)))!, D); + Assert.That(cache.IsDirty, Is.False); + } + + // A refusal reads the record it refused: in a store holding a variant, where a write lands only onto the store as this cache + // last read it, the next write of another record lands at once instead of being refused for the record already refused. + [Test] + public void AfterAnUndeclaredRefusalTheNextWriteIntoAVariantStoreLands([Values(Write.Flush, Write.Commit)] Write write) + { + var path = PathOf("variant-store.cc"); + using var cache = Open(path, DeckOnly, directory: false); + cache.Commit(batch => + { + batch.Upsert(Deck, DeckOf("d"), D); + batch.Upsert(Deck, DeckOf("e"), E); + batch.UpsertVariant(V, E, new[] { cache.Override(Deck, "Name", "v") }); + }); + Move(path, D, "moved", directory: false); + + RefusedOnDisk(Assert.Throws(() => Land(cache, write, D, DeckOf(Canary)))!, D); + Land(cache, write, new CultRecordKey("f"), DeckOf("f")); + + Assert.That(Read(path).Records.Select(record => record.Key), Is.EquivalentTo(new[] { "d", "e", "f", "v" })); + Assert.That(Read(path).Records.Single(record => record.Key == D.Value).SchemaId, Is.EqualTo(MovingId)); + } + + public enum DirectWrite + { + Push, + Delete, + CommitUpsert, + CommitDelete + } + + // The store's own write API reaches no staging refusal: a read-only record written through it directly is refused where the + // write lands, in both store kinds, and the store keeps the other writer's record under its id. + [Test] + public void ADirectStoreWriteOfAReadOnlyRecordIsRefusedAndNeverRelabelsIt([Values] bool directory, [Values] DirectWrite write) + { + var path = PathOf("direct.cc"); + Move(path, D, "theirs", directory); + var before = Fingerprint(path); + using var cache = Open(path, DeckOnly, directory); + var store = StoreOf(cache); + var mine = new CultStoredDocument(D, DateTimeOffset.UtcNow.ToString("O"), DeckOnly.GetRequired(Deck), DeckOf(Canary)); + var held = cache.GetStored(D)!; + + var refusal = Assert.Throws(() => + { + switch (write) + { + case DirectWrite.Push: + store.Push(mine); + store.PushAll(); + break; + case DirectWrite.Delete: + store.Delete(held); + store.PushAll(); + break; + case DirectWrite.CommitUpsert: + store.CommitBatch(new CultCommitRequest(new[] { mine }, Array.Empty(), Array.Empty(), Array.Empty<(CultRecordKey, string?)>(), false), wait: true); + break; + case DirectWrite.CommitDelete: + store.CommitBatch(new CultCommitRequest(Array.Empty(), new[] { held }, Array.Empty(), Array.Empty<(CultRecordKey, string?)>(), false), wait: true); + break; + } + })!; + + RefusedOnDisk(refusal, D); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + Assert.That(Read(path).Records.Single().SchemaId, Is.EqualTo(MovingId), "never relabelled"); + Assert.That(store.IsDirty, Is.False); + Assert.That(EmittedDocumentTypes.Read(cache.Get(D)!, "Name"), Is.EqualTo("theirs")); + } + + // A removal of a foreign record, which the cache does not hold, is refused typed where the store judges it under its lock: at + // the commit that carries it, or the flush that follows a Remove, which itself costs nothing and returns false. The caller is + // never told a write removed what the store still carries. The store is read at that judgement, so a foreign record another + // writer added after the load is refused too, and listed foreign from then on. + [Test] + public void RemovingAForeignRecordIsRefusedNotReportedAsDone([Values] bool directory, [Values] bool batch, [Values] bool appearedAfterLoad) + { + var path = PathOf(directory ? "remove-foreign-dir.cc" : "remove-foreign-file.cc"); + using (var seed = Open(path, Full, directory)) + { + seed.Commit(stage => stage.Upsert(Deck, DeckOf("d"), D)); + if (!appearedAfterLoad) + seed.Commit(stage => stage.Upsert(Widget, WidgetOf("w"), W)); + } + + using (var cache = Open(path, DeckOnly, directory)) + { + if (appearedAfterLoad) + { + using var other = Open(path, Full, directory); + other.Commit(stage => stage.Upsert(Widget, WidgetOf("w"), W)); + } + + var before = Fingerprint(path); + var refusal = Assert.Throws(() => + { + if (batch) + cache.Commit(stage => stage.Remove(W)); + else + { + Assert.That(cache.Remove(W), Is.False, "a key the cache does not hold costs nothing at the call"); + cache.FlushAsync().GetAwaiter().GetResult(); + } + })!; + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId))); + Assert.That(cache.IsDirty, Is.False); + Assert.That(StoreOf(cache).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" })); + Assert.DoesNotThrow(() => cache.FlushAsync().GetAwaiter().GetResult(), "a refusal forgets the check it refused"); + Assert.That(cache.Remove(new CultRecordKey("absent")), Is.False, "a key nothing holds is still no removal"); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + } + + // A write that removes a key the cache does not hold is judged under the store's lock, after the load: a foreign record another + // writer committed in between refuses the whole commit, typed, and nothing the commit would have written lands. + [Test] + public void AForeignRecordThatLandsBetweenTheLoadAndTheCommitRefusesTheCommitThatRemovesIt([Values] bool directory) + { + var path = PathOf("window.cc"); + using (var seed = Open(path, Full, directory)) + seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D)); + using var a = Open(path, DeckOnly, directory); + using (var b = Open(path, Full, directory)) + b.Commit(batch => batch.Upsert(Widget, WidgetOf("w"), W)); + var before = Fingerprint(path); + + var refusal = Assert.Throws(() => a.Commit(batch => + { + batch.Upsert(Deck, DeckOf(Canary), D); + batch.Remove(W); + }))!; + + Assert.Multiple(() => + { + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId))); + Assert.That(refusal.Message, Does.Not.Contain(Canary)); + Assert.That(Fingerprint(path), Is.EqualTo(before), "nothing was written, d included"); + Assert.That(a.IsDirty, Is.False); + }); + } + + // Removing a key nothing holds reads no store: the judgement is the flush's, so a store that cannot be read costs the removal + // nothing and the flush is where it is read. + [Test] + public void RemovingAnUnheldKeyReadsNothingUntilTheFlush([Values] bool directory) + { + var path = Seeded("unreadable.cc", directory); + using var cache = Open(path, DeckOnly, directory); + var original = File.ReadAllBytes(path); + File.WriteAllBytes(path, new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }); + try + { + Assert.DoesNotThrow(() => Assert.That(cache.Remove(new CultRecordKey("absent")), Is.False)); + Assert.Throws(() => cache.FlushAsync().GetAwaiter().GetResult()); + } + finally + { + File.WriteAllBytes(path, original); + } + } + + // A checked key is judged and never removed: another writer's declared record at it stays where it is. + [Test] + public void ACheckedKeyIsNeverRemoved([Values] bool directory) + { + var path = PathOf("checked.cc"); + using (var seed = Open(path, Full, directory)) + seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D)); + using var a = Open(path, DeckOnly, directory); + a.UpsertAsync(Deck, DeckOf("e"), E).GetAwaiter().GetResult(); + Assert.That(a.Remove(W), Is.False); + using (var b = Open(path, Full, directory)) + Retype(b, Deck, DeckOf("theirs"), W); + var theirs = Stored(path, W); + + Assert.DoesNotThrow(() => a.FlushAsync().GetAwaiter().GetResult()); + + Assert.That(Read(path).Records.Select(record => record.Key), Is.EquivalentTo(new[] { "d", "e", "w" }), "the staged write landed beside the checked key"); + Assert.That(Stored(path, W), Is.EqualTo(theirs), "the record at the checked key is still the other writer's bytes"); + using (var c = Open(path, Full, directory)) + Retype(c, Widget, WidgetOf("now a widget"), W); + Assert.DoesNotThrow(() => a.FlushAsync().GetAwaiter().GetResult(), "a flush clears what it judged: the next one has nothing to judge"); + } + + // ExpectUnchanged compares the foreign records a store carries at the versions it read them: it holds right after the load, + // and fails, writing nothing, once another writer changed one. + [Test] + public void ExpectUnchangedHoldsOverAStoreCarryingAForeignRecord([Values] bool directory) + { + var path = Seeded("expect-unchanged.cc", directory); + using var a = Open(path, DeckOnly, directory); + Assert.That(StoreOf(a).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" })); + + Assert.That(a.Commit(batch => + { + batch.ExpectUnchanged(); + batch.Upsert(Deck, DeckOf("e"), E); + }), Is.True, "nothing changed since the load"); + + using (var b = Open(path, Full, directory)) + b.Commit(batch => batch.Upsert(Widget, WidgetOf("changed"), W)); + var before = Fingerprint(path); + + Assert.That(a.Commit(batch => + { + batch.ExpectUnchanged(); + batch.Upsert(Deck, DeckOf("f"), new CultRecordKey("f")); + }), Is.False, "another writer changed the foreign record"); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + // A foreign key listed at the load that another writer then replaced with a record this cache reads but does not declare: the + // refused removal (judged at the flush) reloads it read-only and takes it off the foreign list. + [Test] + public void AListedForeignKeyBecomesReadOnlyWhenARemovalOfItIsRefused([Values] bool directory) + { + var path = Seeded("delist.cc", directory); + using var a = Open(path, DeckOnly, directory); + Assert.That(StoreOf(a).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" })); + // The writer declares the widget's id, so it may replace the widget with a deck record under an id a reads but does not declare. + using (var other = Open(path, CultDocumentRegistry.ForTypes(new[] { MovingDeck, Widget }), directory)) + Retype(other, MovingDeck, New(MovingDeck, ("Name", "moved")), W); + + Assert.That(a.Remove(W), Is.False); + var refusal = Assert.Throws(() => a.FlushAsync().GetAwaiter().GetResult())!; + RefusedOnDisk(refusal, W); + + Assert.That(StoreOf(a).ForeignRecords, Is.Empty, "no longer foreign"); + Assert.That(a.Get(W), Is.Not.Null, "held, read-only"); + Assert.Throws(() => a.Remove(W), "refused at the call now: the cache holds it"); + } + + // A single-file store holding a variant refuses a write after another writer changed it; a changed key whose record this cache + // does not read is listed as foreign by that refusal, as a load lists it. + [Test] + public void AVariantStoreRefusalListsTheRecordItRefusedAsForeign([Values(Write.Flush, Write.Commit)] Write write) + { + var path = PathOf("variant-refusal.cc"); + var gears = CultDocumentRegistry.ForTypes(new[] { typeof(VariantTests.VariantGear) }); + var withWidget = CultDocumentRegistry.ForTypes(new[] { typeof(VariantTests.VariantGear), Widget }); + using var a = Open(path, gears, directory: false); + var baseKey = new CultRecordKey("laser"); + a.Commit(batch => + { + batch.Upsert(typeof(VariantTests.VariantGear), new VariantTests.VariantGear { Name = "laser", Power = 10, Code = "l1" }, baseKey); + batch.UpsertVariant(new CultRecordKey("laser-big"), baseKey, new[] + { + a.Override(nameof(VariantTests.VariantGear.Name), "laser big"), + a.Override(nameof(VariantTests.VariantGear.Power), 25), + a.Override(nameof(VariantTests.VariantGear.Code), "laser big") + }); + }); + using (var b = Open(path, withWidget, directory: false)) + b.Commit(batch => batch.Upsert(Widget, WidgetOf("w"), W)); + Assert.That(StoreOf(a).ForeignRecords, Is.Empty); + + var conflict = Assert.Throws(() => + { + var next = new VariantTests.VariantGear { Name = "next", Power = 1, Code = "n" }; + var key = new CultRecordKey("n"); + if (write == Write.Flush) + { + a.UpsertAsync(typeof(VariantTests.VariantGear), next, key).GetAwaiter().GetResult(); + a.FlushAsync().GetAwaiter().GetResult(); + } + else + { + a.Commit(batch => batch.Upsert(typeof(VariantTests.VariantGear), next, key)); + } + })!; + + Assert.That(conflict.ChangedKeys, Does.Contain(W.Value)); + var listed = StoreOf(a).ForeignRecords.Single(); + Assert.That((listed.Key, listed.SchemaId), Is.EqualTo((W.Value, WidgetId)), "the refused record is listed as the store holds it"); + Assert.That(a.Get(W), Is.Null); + } + + private static CacheBackingStore StoreAt(string path, bool directory, bool readOnly) => directory + ? new DirectoryMessagePackBackingStore(path, readOnly: readOnly) + : new SingleFileMessagePackBackingStore(path, readOnly); + + // A batch with nothing to write and no key to check commits through no store: a staged write waits for its own flush. + [Test] + public void ABatchWithNothingToWriteOrCheckCommitsNothingToTheStore([Values] bool directory) + { + var path = Seeded("empty-batch.cc", directory); + using var cache = Open(path, DeckOnly, directory); + cache.UpsertAsync(Deck, DeckOf("e"), E).GetAwaiter().GetResult(); + + Assert.That(cache.Commit(batch => { }), Is.True); + + Assert.That(Read(path).Records.Select(record => record.Key), Is.EquivalentTo(new[] { "d", "w" }), "e is still only staged"); + cache.FlushAsync().GetAwaiter().GetResult(); + Assert.That(Read(path).Records.Select(record => record.Key), Is.EquivalentTo(new[] { "d", "e", "w" })); + } + + // A write that only checked keys has nothing to write: no file is replaced and no generation is written, by a commit or a flush. + [Test] + public void AWriteThatOnlyCheckedKeysWritesNoFile([Values] bool directory, [Values] bool viaCommit) + { + var path = Seeded("check-only.cc", directory); + using var cache = Open(path, DeckOnly, directory); + var past = new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(path, past); + var before = Fingerprint(path); + + if (viaCommit) + Assert.That(cache.Commit(batch => batch.Remove(new CultRecordKey("absent"))), Is.True); + else + { + Assert.That(cache.Remove(new CultRecordKey("absent")), Is.False); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + Assert.That(Fingerprint(path), Is.EqualTo(before)); + Assert.That(File.GetLastWriteTimeUtc(path), Is.EqualTo(past), "the store was not replaced"); + Assert.That(cache.IsDirty, Is.False); + } + + // A flush or commit that only checked keys clears every check it judged: the next one has nothing to judge, so it does not + // refuse a record another writer put at a checked key afterwards. + [Test] + public void AWriteThatOnlyCheckedKeysClearsTheChecksItJudged([Values] bool directory, [Values] bool viaCommit) + { + var path = Seeded("check-cleared.cc", directory); + using var cache = Open(path, DeckOnly, directory); + var staged = new CultRecordKey("staged"); + Assert.That(cache.Remove(staged), Is.False); + if (viaCommit) + Assert.That(cache.Commit(batch => batch.Remove(new CultRecordKey("requested"))), Is.True); + else + cache.FlushAsync().GetAwaiter().GetResult(); + using (var other = Open(path, Full, directory)) + Retype(other, Widget, WidgetOf("now a widget"), staged); + + Assert.DoesNotThrow(() => cache.FlushAsync().GetAwaiter().GetResult()); + } + + // A disposed cache and a disposed store judge the checks a removal staged, as their flush does. + [Test] + public void DisposingAStoreJudgesTheChecksARemovalStaged([Values] bool directory, [Values] bool viaCache) + { + var path = Seeded("dispose.cc", directory); + var cache = Open(path, DeckOnly, directory); + cache.FlushAttachedStoresOnDispose = true; + StoreOf(cache).FlushOnDispose = true; + Assert.That(cache.Remove(W), Is.False); + var before = Fingerprint(path); + + var refusal = Assert.Throws(() => + { + if (viaCache) + cache.Dispose(); + else + StoreOf(cache).Dispose(); + })!; + + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId))); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + try { cache.Dispose(); } catch (CultSchemaConflictException) { } + } + + // A read-only store has no lock to judge under, so it answers a removal of a key the cache does not hold at the call, from + // what it last loaded. + [Test] + public void AReadOnlyStoreRefusesTheRemovalOfAForeignRecordAtTheCall([Values] bool directory) + { + var path = Seeded("read-only.cc", directory); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = DeckOnly, UseDirectoryStore = directory, ReadOnly = true }); + + var refusal = Assert.Throws(() => cache.Remove(W))!; + + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId))); + Assert.That(cache.Remove(new CultRecordKey("absent")), Is.False); + } + + // A batch that only checks keys commits through the one writable store; a read-only store answers the checks from its last load. + [Test] + public void AReadOnlyStoreAnswersTheChecksFromItsView([Values] bool directory) + { + var writable = PathOf("home.cc"); + using (var seed = Open(writable, Full, directory)) + seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D)); + var readOnly = PathOf("other.cc"); + using (var seed = Open(readOnly, CultDocumentRegistry.ForTypes(new[] { Gizmo, Widget }), directory)) + seed.Commit(batch => + { + batch.Upsert(Gizmo, New(Gizmo, ("Tag", "g")), new CultRecordKey("g")); + batch.Upsert(Widget, WidgetOf("w"), W); + }); + using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { Deck, Gizmo })); + cache.AddBackingStore(StoreAt(writable, directory, readOnly: false), Deck); + cache.AddBackingStore(StoreAt(readOnly, directory, readOnly: true), Gizmo); + var before = Fingerprint(writable); + + var refusal = Assert.Throws(() => cache.Commit(batch => batch.Remove(W)))!; + + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId))); + Assert.That(Fingerprint(writable), Is.EqualTo(before)); + Assert.That(cache.Commit(batch => batch.Remove(new CultRecordKey("absent"))), Is.True, "a key no store holds is free"); + } + + // The home store judges a check under its lock, from the record it holds then: a foreign record it listed at the load that + // another writer since replaced with a declared one no longer refuses the commit, and is not removed. + [Test] + public void TheHomeStoreJudgesACheckUnderItsLockNotFromItsLoad([Values] bool directory) + { + var path = Seeded("home-judges.cc", directory); + using var cache = Open(path, DeckOnly, directory); + Assert.That(StoreOf(cache).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" })); + using (var other = Open(path, Full, directory)) + Retype(other, Deck, DeckOf("theirs"), W); + var theirs = Stored(path, W); + + Assert.That(cache.Commit(batch => batch.Remove(W)), Is.True); + + Assert.That(Stored(path, W), Is.EqualTo(theirs), "the declared record the checked key now holds is not removed"); + } + + // A cache disposed with several stores flushes when any one of them has checks staged, not only when all do (a read-only store + // never has any). + [Test] + public void DisposingACacheFlushesWhenOnlyOneStoreHasChecksStaged([Values] bool directory) + { + var writable = Seeded("dispose-home.cc", directory); + var other = PathOf("dispose-other.cc"); + using (var seed = Open(other, CultDocumentRegistry.ForTypes(new[] { Gizmo }), directory)) + seed.Commit(batch => batch.Upsert(Gizmo, New(Gizmo, ("Tag", "g")), new CultRecordKey("g"))); + var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { Deck, Gizmo })) { FlushAttachedStoresOnDispose = true }; + cache.AddBackingStore(StoreAt(writable, directory, readOnly: false), Deck); + cache.AddBackingStore(StoreAt(other, directory, readOnly: true), Gizmo); + Assert.That(cache.Remove(W), Is.False); + + var refusal = Assert.Throws(() => cache.Dispose())!; + + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId))); + try { cache.Dispose(); } catch (CultSchemaConflictException) { } + } + + // Two writable stores: home.cc holds a deck and takes Deck, other.cc holds a gizmo and takes Gizmo, and, when asked, a widget at + // w that no registered type declares, so the cache lists it foreign at the load. + private (CultCache Cache, string Home, string Other) TwoWritableStores(bool directory, bool otherListsWidget) + { + var home = PathOf("home.cc"); + using (var seed = Open(home, DeckOnly, directory)) + seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D)); + var other = PathOf("other.cc"); + using (var seed = Open(other, CultDocumentRegistry.ForTypes(new[] { Gizmo, Widget }), directory)) + { + seed.Commit(batch => batch.Upsert(Gizmo, New(Gizmo, ("Tag", "g")), new CultRecordKey("g"))); + if (otherListsWidget) + seed.Commit(batch => batch.Upsert(Widget, WidgetOf("w"), W)); + } + + var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { Deck, Gizmo })); + cache.AddBackingStore(StoreAt(home, directory, readOnly: false), Deck); + cache.AddBackingStore(StoreAt(other, directory, readOnly: false), Gizmo); + return (cache, home, other); + } + + // Every writable store judges a batch's checks under its own lock, whichever store the batch commits through: a record another + // writer put at a checked key in a store the batch does not write, after the load, refuses the whole commit and writes nothing. + [Test] + public void EveryWritableStoreRefusesACommitThatRemovesItsLateForeignRecord([Values] bool directory, [Values] bool withWrite) + { + var (cache, home, other) = TwoWritableStores(directory, otherListsWidget: false); + using var _ = cache; + using (var late = Open(other, CultDocumentRegistry.ForTypes(new[] { Gizmo, Widget }), directory)) + late.Commit(batch => batch.Upsert(Widget, WidgetOf("late"), W)); + var homeBefore = Fingerprint(home); + var otherBefore = Fingerprint(other); + + var refusal = Assert.Throws(() => cache.Commit(batch => + { + if (withWrite) + batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2")); + batch.Remove(W); + }))!; + + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId))); + Assert.That(Fingerprint(home), Is.EqualTo(homeBefore)); + Assert.That(Fingerprint(other), Is.EqualTo(otherBefore)); + Assert.That(Read(home).Records.Select(record => record.Key), Does.Not.Contain("d2")); + Assert.That(cache.BackingStores[1].ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" })); + } + + // The non-home judgement reads the store under its lock, not its listing: a foreign record that store listed at the load, + // since replaced by a declared one, no longer refuses the commit and is not removed. + [Test] + public void ANonHomeStoreJudgesACheckUnderItsLockNotFromItsLoad([Values] bool directory) + { + var (cache, home, other) = TwoWritableStores(directory, otherListsWidget: true); + using var _ = cache; + Assert.That(cache.BackingStores[1].ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" })); + using (var late = Open(other, CultDocumentRegistry.ForTypes(new[] { Gizmo, Widget }), directory)) + Retype(late, Gizmo, New(Gizmo, ("Tag", "theirs")), W); + var theirs = Stored(other, W); + + Assert.That(cache.Commit(batch => + { + batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2")); + batch.Remove(W); + }), Is.True); + + Assert.That(Read(home).Records.Select(record => record.Key), Does.Contain("d2")); + Assert.That(Stored(other, W), Is.EqualTo(theirs), "the declared record the checked key now holds is not removed"); + } + + // A store that is not the batch's home and cannot take its lock makes the commit contended before the home writes. + [Test] + public void ANonHomeStoreHeldByAnotherWriterMakesTryCommitContended() + { + var (cache, home, other) = TwoWritableStores(directory: false, otherListsWidget: false); + using var _ = cache; + var homeBefore = Fingerprint(home); + + using (new FileStream(other + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)) + { + var outcome = cache.TryCommit(batch => + { + batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2")); + batch.Remove(W); + }); + + Assert.That(outcome, Is.EqualTo(CultCommitOutcome.Contended)); + } + + Assert.That(Fingerprint(home), Is.EqualTo(homeBefore)); + } + + private static void CopyStoreOver(string from, string to, bool directory) + { + if (directory) + { + var source = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(from); + var target = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(to); + Directory.CreateDirectory(target); + foreach (var file in Directory.GetFiles(source, "*.msgpack")) + File.Copy(file, Path.Combine(target, Path.GetFileName(file)), true); + } + File.Copy(from, to, true); + } + + private static FileStream HoldStoreLock(string path, bool directory) => directory + ? new FileStream(Path.Combine(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path), ".commit.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None) + : new FileStream(path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + + // A non-home store is judged only after the commit holds that store's own lock: a foreign record another writer lands at the + // checked key while the commit waits for the lock is refused, whichever kind the store is and whether or not the batch writes. + [Test] + public void ANonHomeStoreIsReadOnlyAfterItsLockIsTaken([Values] bool directory, [Values] bool withWrite) + { + var (cache, home, other) = TwoWritableStores(directory, otherListsWidget: false); + using var _ = cache; + var copy = PathOf("copy.cc"); + CopyStoreOver(other, copy, directory); + using (var late = Open(copy, CultDocumentRegistry.ForTypes(new[] { Gizmo, Widget }), directory)) + late.Commit(batch => batch.Upsert(Widget, WidgetOf("late"), W)); + var homeBefore = Fingerprint(home); + + Exception? thrown = null; + var held = HoldStoreLock(other, directory); + var worker = new System.Threading.Thread(() => + { + try + { + cache.Commit(batch => + { + if (withWrite) + batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2")); + batch.Remove(W); + }); + } + catch (Exception ex) { thrown = ex; } + }); + worker.Start(); + // The lock wait is a Thread.Sleep retry loop, so the worker sleeping is the worker waiting for the held lock. + Assert.That(System.Threading.SpinWait.SpinUntil( + () => (worker.ThreadState & System.Threading.ThreadState.WaitSleepJoin) != 0, TimeSpan.FromSeconds(10)), Is.True, "the commit waits on the held lock"); + CopyStoreOver(copy, other, directory); + var otherAfterSwap = Fingerprint(other); + held.Dispose(); + + Assert.That(worker.Join(TimeSpan.FromSeconds(40)), Is.True, "the commit finished"); + Assert.That(thrown, Is.TypeOf()); + Assert.That(Fingerprint(home), Is.EqualTo(homeBefore), "the home is unwritten"); + Assert.That(Fingerprint(other), Is.EqualTo(otherAfterSwap), "the non-home store is unwritten"); + } + + // A directory store judging a check honours wait=false: its lease held by another writer makes TryCommit contended at once. + [Test] + public void ADirectoryNonHomeStoreWhoseLeaseIsHeldMakesTryCommitContended() + { + var (cache, home, other) = TwoWritableStores(directory: true, otherListsWidget: false); + using var _ = cache; + var homeBefore = Fingerprint(home); + + CultCommitOutcome outcome; + using (HoldStoreLock(other, directory: true)) + outcome = cache.TryCommit(batch => + { + batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2")); + batch.Remove(W); + }); + + Assert.That(outcome, Is.EqualTo(CultCommitOutcome.Contended)); + Assert.That(Fingerprint(home), Is.EqualTo(homeBefore)); + } + + // A non-home store the cache attached before anything was written to it has no directory and no lease yet: judging a check + // there creates what it locks, finds nothing, and writes no store. + [Test] + public void ANonHomeStoreNothingWasEverWrittenToJudgesAChecksOnlyCommit([Values] bool directory) + { + var home = PathOf("home.cc"); + using (var seed = Open(home, DeckOnly, directory)) + seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D)); + var other = PathOf("fresh" + Path.DirectorySeparatorChar + "other.cc"); + using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { Deck, Gizmo })); + cache.AddBackingStore(StoreAt(home, directory, readOnly: false), Deck); + cache.AddBackingStore(StoreAt(other, directory, readOnly: false), Gizmo); + + Assert.That(cache.Commit(batch => + { + batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2")); + batch.Remove(W); + }), Is.True); + + Assert.That(Read(home).Records.Select(record => record.Key), Does.Contain("d2")); + Assert.That(File.Exists(other), Is.False, "judging a check writes no store"); + } + + // A batch with no check asks no other store anything: a lock another writer holds on a store the batch does not write does + // not stand in its way. + [Test] + public void ABatchWithoutChecksIsNotHeldUpByAnotherStoreLock() + { + var (cache, home, other) = TwoWritableStores(directory: false, otherListsWidget: false); + using var _ = cache; + + using (new FileStream(other + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)) + Assert.That(cache.TryCommit(batch => batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2"))), Is.EqualTo(CultCommitOutcome.Committed)); + + Assert.That(Read(home).Records.Select(record => record.Key), Does.Contain("d2")); + } + + // A foreign record a refusal lists carries the version the refusal read, so a conditional commit over the stores that + // list it holds when nothing has changed since. + [Test] + public void ExpectUnchangedHoldsAfterARefusalListedTheForeignRecord([Values] bool directory) + { + var path = PathOf("refusal-listed.cc"); + using (var seed = Open(path, Full, directory)) + seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D)); + using var cache = Open(path, DeckOnly, directory); + using (var late = Open(path, Full, directory)) + late.Commit(batch => batch.Upsert(Widget, WidgetOf("late"), W)); + Assert.That(cache.Remove(W), Is.False); + Assert.Throws(() => cache.FlushAsync().GetAwaiter().GetResult()); + Assert.That(StoreOf(cache).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" })); + + Assert.That(cache.Commit(batch => + { + batch.ExpectUnchanged(); + batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2")); + }), Is.True); + } + } +} diff --git a/tests/GameCult.Caching.Tests/NumericLeafVectorTests.cs b/tests/GameCult.Caching.Tests/NumericLeafVectorTests.cs index de94c253..812109df 100644 --- a/tests/GameCult.Caching.Tests/NumericLeafVectorTests.cs +++ b/tests/GameCult.Caching.Tests/NumericLeafVectorTests.cs @@ -36,7 +36,7 @@ namespace GameCult.Caching.Tests [MessagePackObject] public sealed class ArrayStringSlot { [Key(0)] public string[]? V; } [MessagePackObject] public sealed class NullableInt32Slot { [Key(0)] public int? V; } [MessagePackObject] public sealed class ListInt32Slot { [Key(0)] public List? V; } - [MessagePackObject] public sealed class LeafInner { [Key(0)] public string N = string.Empty; [Key(1)] public float W; } + [MessagePackObject] public sealed class LeafInner { [Key(0)] public string N = string.Empty; [Key(1)] public float W; [Key(2)] [CultElementId] public string Id = string.Empty; } [MessagePackObject] public sealed class RecordSlot { [Key(0)] public LeafInner? V; } [MessagePackObject] public sealed class ListRecordSlot { [Key(0)] public List? V; } [Union(0, typeof(LeafCircle))] public interface ILeafShape { } @@ -308,7 +308,7 @@ namespace GameCult.Caching.Tests { Id = "leaves", Flag = true, Blob = new byte[] { 1, 2, 3 }, I = -7, L = 5_000_000_000, F = 0.1f, D = 0.1, MaybeI = 9, Tags = new List { "a", "b" }, Names = new[] { "x", "y" }, Any = "text", - Nested = new LeafInner { N = "inner", W = 0.1f }, Items = new List { new() { N = "one", W = 0.1f }, new() { N = "two", W = 1.5f } }, + Nested = new LeafInner { N = "inner", W = 0.1f, Id = "000000000011" }, Items = new List { new() { N = "one", W = 0.1f, Id = "000000000012" }, new() { N = "two", W = 1.5f, Id = "000000000013" } }, Shape = new LeafCircle { R = 0.1f }, Floats = new[] { 0.1f, 1.5f }, MaybeF = 0.1f, }; diff --git a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs index e9d8797d..df7ce6a4 100644 --- a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs +++ b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs @@ -108,7 +108,7 @@ namespace GameCult.Caching.Tests using var cache = new CultCache(); var error = Assert.Catch(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(file))); Assert.That(error, Is.Not.Null); - Assert.That(error!.ToString(), Does.Contain(nameof(NotSupportedException))); + Assert.That(error, Is.TypeOf()); return error.ToString(); } finally @@ -123,6 +123,433 @@ namespace GameCult.Caching.Tests Assert.That(Refusal("unknown-header.msgpack"), Does.Contain("cultcache.store.v9")); } + // Slots of the current catalog layout that do not decode are refused by name, as a malformed store is. + [TestCase("current-catalog-bad-canonical.msgpack")] + [TestCase("current-catalog-bad-members.msgpack")] + [TestCase("current-catalog-bad-compat.msgpack")] + [TestCase("current-catalog-bad-slot5-nil.msgpack")] + [TestCase("current-catalog-bad-slot5-int.msgpack")] + public void SingleFileRefusesAMalformedCurrentCatalogEntry(string vector) + { + Assert.That(Refusal(vector), Is.Not.Empty); + } + + // The release-now-c2a-bumps-format ruling rests on this: a store stamped with a newer format version is refused at + // open, so this runtime cannot rewrite it. The file stays byte-identical through the refusal and through later + // writes to the cache, whose refused store was never attached. + [TestCase("cultcache.store.v4")] + [TestCase("cultcache.store.v9")] + public async Task SingleFileStampedWithANewerFormatIsRefusedAndNeverRewritten(string newerFormat) + { + var root = CopyFixture("single-file-v1"); + try + { + var file = Path.Combine(root, "store.msgpack"); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file)); + snapshot.FormatVersion = newerFormat; + File.WriteAllBytes(file, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var before = File.ReadAllBytes(file); + + using var cache = new CultCache(); + var error = Assert.Throws(() => + cache.AddBackingStore(new SingleFileMessagePackBackingStore(file)))!; + Assert.That(error.Message, Does.Contain(newerFormat)); + Assert.That(cache.BackingStores, Is.Empty); + + await cache.AddAsync(new PreCut2FixtureNote { Text = "written after the refusal" }); + await cache.FlushAsync(); + + Assert.That(File.ReadAllBytes(file), Is.EqualTo(before)); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + // Two processes: this one attached a v1 store, then a later build rewrote it as v4. A plain flush, a staged write + // and a staged delete must each refuse to downgrade it, leaving the file as the other process wrote it. + [Test] + public async Task SingleFileFlushRefusesAStoreAnotherProcessStampedNewerSinceAttach() + { + var root = CopyFixture("single-file-v1"); + try + { + var file = Path.Combine(root, "store.msgpack"); + using var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(file)); + await cache.PullAllBackingStoresAsync(); + + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file)); + snapshot.FormatVersion = "cultcache.store.v4"; + File.WriteAllBytes(file, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var before = File.ReadAllBytes(file); + + await cache.AddAsync(new PreCut2FixtureNote { Text = "staged after the other process wrote v4" }); + Assert.Throws(() => cache.FlushAllBackingStores()); + Assert.That(File.ReadAllBytes(file), Is.EqualTo(before)); + + cache.Remove(new CultRecordKey("item:anvil")); + Assert.Throws(() => cache.BackingStores.Single().PushAll()); + Assert.That(File.ReadAllBytes(file), Is.EqualTo(before)); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + // A write set cannot merge with a disk it cannot read: a flush refuses a store whose stamp is unreadable and leaves it + // as it is. + [Test] + public async Task SingleFileFlushRefusesAStoreWhoseStampIsUnreadable() + { + var root = CopyFixture("single-file-v1"); + try + { + var file = Path.Combine(root, "store.msgpack"); + using var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(file)); + await cache.PullAllBackingStoresAsync(); + var damaged = new byte[] { 0xc1, 0xc1 }; + File.WriteAllBytes(file, damaged); + + await cache.AddAsync(new PreCut2FixtureNote { Text = "refused" }); + Assert.Throws(() => cache.FlushAllBackingStores()); + + Assert.That(File.ReadAllBytes(file), Is.EqualTo(damaged)); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + private static byte[] Raw(params object[] parts) + { + var bytes = new System.Collections.Generic.List(); + foreach (var part in parts) + { + switch (part) + { + case byte single: bytes.Add(single); break; + case string text: + var encoded = System.Text.Encoding.UTF8.GetBytes(text); + bytes.Add(0xd9); bytes.Add((byte)encoded.Length); bytes.AddRange(encoded); break; + case byte[] binary: + bytes.Add(0xc4); bytes.Add((byte)binary.Length); bytes.AddRange(binary); break; + default: throw new ArgumentException(part.GetType().Name); + } + } + return bytes.ToArray(); + } + + // The format decision comes from the header alone. A stamp this runtime does not read refuses whatever follows it, + // so a body that will not parse can never make a newer store look like damage a flush may heal. + private static System.Collections.Generic.IEnumerable NewerStampOverUnreadableBody() + { + byte garbage = 0xc1; + yield return new TestCaseData(Raw((byte)0x93, "cultcache.store.v4", garbage, garbage), "cultcache.store.v4").SetName("v4 header over a bad body"); + yield return new TestCaseData(Raw((byte)0x93, System.Text.Encoding.UTF8.GetBytes("cultcache.store.v3"), garbage, garbage), "binary").SetName("bin header v3 over a bad body"); + yield return new TestCaseData(Raw((byte)0x93, System.Text.Encoding.UTF8.GetBytes("cultcache.store.v9"), garbage, garbage), "binary").SetName("bin header v9 over a bad body"); + yield return new TestCaseData(Raw((byte)0x93, "cultcache.store.v4", (byte)0x91, garbage, (byte)0x90), "cultcache.store.v4").SetName("v4 header over a bad catalog"); + yield return new TestCaseData(Raw((byte)0x92, "cultcache.store.v4", garbage), "cultcache.store.v4").SetName("v4 header over garbage"); + yield return new TestCaseData(Raw((byte)0x93, "cultcache.store.v5", garbage, garbage), "cultcache.store.v5").SetName("v5 header over a bad body"); + yield return new TestCaseData(BinaryStampOverAValidBody(), "binary").SetName("bin header v1 over a valid body"); + } + + // The single-file-v1 fixture's own bytes with the stamp re-encoded from a string to bin8: nothing else differs, so only the + // stamp's encoding can refuse it. + private static byte[] BinaryStampOverAValidBody() + { + var bytes = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "pre-cut2", "single-file-v1", "store.msgpack")); + var stamp = System.Text.Encoding.UTF8.GetBytes("cultcache.store.v1"); + var at = 1; + Assert.That(bytes[0], Is.EqualTo(0x93), "the fixture opens with a three-slot array"); + Assert.That(bytes[at], Is.EqualTo((byte)(0xa0 | stamp.Length)), "the stamp is a fixstr"); + var rebuilt = new System.Collections.Generic.List { bytes[0], 0xc4, (byte)stamp.Length }; + rebuilt.AddRange(stamp); + rebuilt.AddRange(bytes.Skip(at + 1 + stamp.Length)); + return rebuilt.ToArray(); + } + + [TestCaseSource(nameof(NewerStampOverUnreadableBody))] + public async Task SingleFileFlushRefusesANewerStampWhateverItsBodyHolds(byte[] swapped, string named) + { + var root = CopyFixture("single-file-v1"); + try + { + var file = Path.Combine(root, "store.msgpack"); + using var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(file)); + await cache.PullAllBackingStoresAsync(); + File.WriteAllBytes(file, swapped); + + await cache.AddAsync(new PreCut2FixtureNote { Text = "must not overwrite" }); + var flush = Assert.Throws(() => cache.FlushAllBackingStores())!; + Assert.That(flush.Message, Does.Contain(named)); + Assert.That(File.ReadAllBytes(file), Is.EqualTo(swapped)); + var pull = Assert.Throws(() => new SingleFileMessagePackBackingStore(file).PullAll())!; + Assert.That(pull.Message, Does.Contain(named)); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task DirectoryWritesRefuseANewerStampWhateverTheManifestBodyHolds() + { + var root = CopyFixture("directory-v4"); + try + { + var manifest = Path.Combine(root, "store.cc"); + using var cache = new CultCache(); + cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); + await cache.PullAllBackingStoresAsync(); + var swapped = Raw((byte)0x93, "cultcache.store.v6.directory-content-addressed-pages", (byte)0xc1, (byte)0xc1); + File.WriteAllBytes(manifest, swapped); + + await cache.AddAsync(new PreCut2FixtureNote { Text = "must not overwrite" }); + Assert.Throws(() => cache.FlushAllBackingStores()); + Assert.That(File.ReadAllBytes(manifest), Is.EqualTo(swapped)); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + // A file that is read but carries no readable stamp (empty, or cut off) is not a store: a flush refuses it, whatever its + // length, and leaves it as it is. + [TestCase(0)] + [TestCase(1)] + [TestCase(10)] + [TestCase(-1)] + public async Task SingleFileFlushRefusesAnEmptyOrTruncatedFile(int keep) + { + var root = CopyFixture("single-file-v1"); + try + { + var file = Path.Combine(root, "store.msgpack"); + using var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(file)); + await cache.PullAllBackingStoresAsync(); + var whole = File.ReadAllBytes(file); + var damaged = whole.Take(keep < 0 ? whole.Length / 2 : keep).ToArray(); + File.WriteAllBytes(file, damaged); + + await cache.AddAsync(new PreCut2FixtureNote { Text = "refused" }); + Assert.Throws(() => cache.FlushAllBackingStores()); + + Assert.That(File.ReadAllBytes(file), Is.EqualTo(damaged)); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + // A store subclass that overrides nothing beyond its abstract members is guarded by the base, not by an opt-in. + private sealed class PlainCustomStore : SingleFileBackingStore + { + public PlainCustomStore(string path) : base(path) { } + + protected override byte[] SerializeStore(string formatVersion, System.Collections.Generic.IReadOnlyList catalogEntries, System.Collections.Generic.IReadOnlyList records) => + CultDocumentMessagePackSerialization.SerializeStore(formatVersion, catalogEntries, records); + + protected override byte[] SerializeRecord(CultPersistedRecord record) => + CultDocumentMessagePackSerialization.SerializePersistedRecord(record); + + protected override byte[] SerializeCatalogEntry(CultSchemaCatalogEntry entry) => + CultDocumentMessagePackSerialization.SerializeCatalogEntry(entry); + + protected override (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) ReadStore(byte[] data) + { + var store = CultDocumentMessagePackSerialization.ReadStore(data); + CultDocumentMessagePackSerialization.RequireSingleFileFormat(store.Snapshot); + return store; + } + + protected override byte[] SerializePayload(object document, Type documentType) => + CultDocumentMessagePackSerialization.SerializeUntyped(document, documentType, Registry); + + protected override object DeserializePayload(Type documentType, byte[] payload) => + CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry); + } + + private static void StampHeader(string file, string header) + { + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file)); + snapshot.FormatVersion = header; + File.WriteAllBytes(file, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + } + + [Test] + public async Task CustomSingleFileStoreThatOverridesNothingIsRefusedAtFlushOfANewerStore() + { + var root = CopyFixture("single-file-v1"); + try + { + var file = Path.Combine(root, "store.msgpack"); + using var cache = new CultCache(); + cache.AddBackingStore(new PlainCustomStore(file)); + await cache.PullAllBackingStoresAsync(); + StampHeader(file, "cultcache.store.v4"); + var before = File.ReadAllBytes(file); + + await cache.AddAsync(new PreCut2FixtureNote { Text = "staged" }); + Assert.Throws(() => cache.FlushAllBackingStores()); + Assert.That(File.ReadAllBytes(file), Is.EqualTo(before)); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + // Attach, commit and flush share one rule for which formats are readable: a header the reader refuses at attach is + // refused at flush, whatever its spelling. + [TestCase("cultcache.store.v3-ids")] + [TestCase("cultcache.store.v1000000")] + [TestCase("cultcache.v3")] + [TestCase("cultcache.store.v3")] + [TestCase("cultcache.store.v2")] + [TestCase("cultcache.store.v1")] + public async Task FlushRefusesExactlyTheHeadersAttachRefuses(string header) + { + var root = CopyFixture("single-file-v1"); + try + { + var file = Path.Combine(root, "store.msgpack"); + using var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(file)); + await cache.PullAllBackingStoresAsync(); + StampHeader(file, header); + var before = File.ReadAllBytes(file); + + bool attachRefuses; + using (var fresh = new CultCache()) + { + try { fresh.AddBackingStore(new SingleFileMessagePackBackingStore(file)); attachRefuses = false; } + catch (NotSupportedException) { attachRefuses = true; } + } + + await cache.AddAsync(new PreCut2FixtureNote { Text = "staged" }); + var flushRefuses = false; + try { cache.FlushAllBackingStores(); } + catch (NotSupportedException) { flushRefuses = true; } + + Assert.That(flushRefuses, Is.EqualTo(attachRefuses), header); + if (attachRefuses) Assert.That(File.ReadAllBytes(file), Is.EqualTo(before), header); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + // The refusal comes from the read, not from a failed write onto the held file: the store never reaches serialising. + private sealed class WriteCountingStore : SingleFileMessagePackBackingStore + { + public int Serialisations; + + public WriteCountingStore(string path) : base(path) { } + + protected override byte[] SerializeStore(string formatVersion, System.Collections.Generic.IReadOnlyList catalogEntries, System.Collections.Generic.IReadOnlyList records) + { + Serialisations++; + return base.SerializeStore(formatVersion, catalogEntries, records); + } + } + + // The public parser judges no stamp, as in 1.0.61: a v4 directory manifest and a v3 header both parse. + [Test] + public void PublicParserReadsAnyStampAsEveryReleaseHas() + { + var root = CopyFixture("directory-v4"); + try + { + var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(Path.Combine(root, "store.cc"))); + Assert.That(manifest.FormatVersion, Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages")); + + var v3 = new CultPersistedStoreSnapshot { FormatVersion = "cultcache.store.v3", SchemaCatalog = Array.Empty(), Records = Array.Empty() }; + var parsed = CultDocumentMessagePackSerialization.DeserializeSnapshot(CultDocumentMessagePackSerialization.SerializeSnapshot(v3)); + Assert.That(parsed.FormatVersion, Is.EqualTo("cultcache.store.v3")); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + // A read that fails on I/O is not an unreadable stamp to heal over: the flush refuses and the file is untouched. + [Test] + public async Task SingleFileFlushRefusesWhenReadingTheDiskFailsOnIO() + { + var root = CopyFixture("single-file-v1"); + try + { + var file = Path.Combine(root, "store.msgpack"); + using var cache = new CultCache(); + var store = new WriteCountingStore(file); + cache.AddBackingStore(store); + await cache.PullAllBackingStoresAsync(); + var before = File.ReadAllBytes(file); + + await cache.AddAsync(new PreCut2FixtureNote { Text = "staged" }); + store.Serialisations = 0; + using (new FileStream(file, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + Assert.Throws(() => cache.FlushAllBackingStores()); + } + Assert.That(store.Serialisations, Is.Zero, "the refusal is the read failing, not the write onto the held file failing"); + Assert.That(File.ReadAllBytes(file), Is.EqualTo(before)); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + [TestCase("cultcache.store.v6.directory-content-addressed-pages")] + [TestCase("cultcache.store.v9")] + public async Task DirectoryWritesRefuseAStoreAnotherProcessStampedNewerSinceAttach(string newer) + { + var root = CopyFixture("directory-v4"); + try + { + var manifest = Path.Combine(root, "store.cc"); + using var cache = new CultCache(); + cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); + await cache.PullAllBackingStoresAsync(); + + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(manifest)); + snapshot.FormatVersion = newer; + File.WriteAllBytes(manifest, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + Func> files = () => Directory + .GetFiles(root, "*", SearchOption.AllDirectories) + .Where(path => !path.EndsWith(".lock", StringComparison.Ordinal)) + .ToDictionary(path => path, File.ReadAllBytes); + var before = files(); + + await cache.AddAsync(new PreCut2FixtureNote { Text = "staged after the other process wrote the newer format" }); + Assert.Throws(() => cache.FlushAllBackingStores()); + Assert.That(files().Keys, Is.EquivalentTo(before.Keys)); + foreach (var pair in before) Assert.That(File.ReadAllBytes(pair.Key), Is.EqualTo(pair.Value), pair.Key); + + cache.Remove(new CultRecordKey("item:anvil")); + Assert.Throws(() => cache.FlushAllBackingStores()); + Assert.That(files().Keys, Is.EquivalentTo(before.Keys)); + foreach (var pair in before) Assert.That(File.ReadAllBytes(pair.Key), Is.EqualTo(pair.Value), pair.Key); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + [Test] public void SingleFileRefusesExtraRecordSlotNamingTheRecord() { @@ -164,7 +591,7 @@ namespace GameCult.Caching.Tests var bytes = new byte[] { 0x93, 0xc0, 0x90, 0x90 }; Assert.That(() => CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes), - Throws.TypeOf().With.Message.Contains("format version")); + Throws.TypeOf().With.Message.Contains("format version")); } // Every runtime reads a store file the same way: a missing file is an empty store, an existing empty file is not @@ -210,7 +637,8 @@ namespace GameCult.Caching.Tests public void SingleFileRefusalEchoesAHeaderOnlyInTheKnownShape(string header, bool echoed) { var snapshot = new CultPersistedStoreSnapshot { FormatVersion = header }; - var error = Assert.Throws(() => CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot))!; + var error = Assert.Throws(() => CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot))!; + Assert.That(error.Message, Does.Contain($"this runtime reads {CultPersistedStoreSnapshot.FormatV1}, {CultPersistedStoreSnapshot.FormatV2} and {CultPersistedStoreSnapshot.FormatV3}.")); if (echoed) { Assert.That(error.Message, Does.Contain("format " + header + " is not")); @@ -222,6 +650,28 @@ namespace GameCult.Caching.Tests } } + // The stamp decides readability and the body never does: a newer stamp is named whatever follows it, without the body parsed. + [Test] + public void ReadStoreNamesANewerSingleFileStampWithoutParsingTheBody() + { + var garbage = (byte)0xc1; + var bytes = Raw((byte)0x93, "cultcache.store.v4", garbage, garbage); + + var error = Assert.Throws(() => + CultDocumentMessagePackSerialization.ReadStore(bytes, CultDocumentMessagePackSerialization.RequireReadableSingleFileStamp))!; + + Assert.That(error.Message, Does.Contain("format cultcache.store.v4 is not readable")); + } + + // A snapshot with no format version has no stamp to write, so serialising it is refused rather than writing a nil slot 0. + [Test] + public void SerializeSnapshotRefusesASnapshotWithNoFormatVersion() + { + var snapshot = new CultPersistedStoreSnapshot { FormatVersion = null! }; + + Assert.Throws(() => CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + } + [Test] public void V1StoreWrittenAtTheBaseCommitStillDecodesByteForByte() { @@ -256,8 +706,12 @@ namespace GameCult.Caching.Tests using var cache = new CultCache(); - Assert.That(() => cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)), - Throws.TypeOf().With.Message.Contains(formatVersion)); + // The refusal names the manifest, and shows the header only when it is cultcache.store.v: the suffixed + // names a directory store once wrote are stored text like any other. + var shown = System.Text.RegularExpressions.Regex.IsMatch(formatVersion, @"^cultcache\.store\.v[0-9]+$"); + var refusal = Assert.Throws(() => cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)))!; + Assert.That(refusal.Path, Is.EqualTo(manifest)); + Assert.That(refusal.Message.Contains(" is " + formatVersion + ";", StringComparison.Ordinal), Is.EqualTo(shown), refusal.Message); Assert.That(cache.BackingStores, Is.Empty); Assert.That(File.ReadAllBytes(manifest), Is.EqualTo(bytes)); } @@ -266,5 +720,86 @@ namespace GameCult.Caching.Tests if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } + + // A header is named whole only in the shape cultcache.store.v<1-6 digits>, with a suffix counted and never echoed, and + // described by its length otherwise. + [TestCase("cultcache.store.v3", "cultcache.store.v3")] + [TestCase("cultcache.store.v10", "cultcache.store.v10")] + [TestCase("cultcache.store.v40", "cultcache.store.v40")] + [TestCase("cultcache.store.v123456", "cultcache.store.v123456")] + [TestCase("cultcache.store.v1234567", "an unrecognised cultcache.store.* header of 24 bytes")] + [TestCase("cultcache.store.v", "an unrecognised cultcache.store.* header of 17 bytes")] + [TestCase("cultcache.store.vx", "an unrecognised cultcache.store.* header of 18 bytes")] + [TestCase("cultcache.store.v3.extra", "cultcache.store.v3 with a suffix of 6 bytes")] + public void DescribeHeaderNamesOnlyTheKnownShape(string header, string described) + { + Assert.That(CultDocumentMessagePackSerialization.DescribeHeader(header), Is.EqualTo(described)); + } + + [Test] + public void DescribeHeaderCountsASuffixInBytes() + { + const string suffix = ".directory-content-addressed-pages"; + var header = "cultcache.store.v6" + suffix; + + Assert.That(CultDocumentMessagePackSerialization.DescribeHeader(header), + Is.EqualTo($"cultcache.store.v6 with a suffix of {System.Text.Encoding.UTF8.GetByteCount(suffix)} bytes")); + } + + [Test] + public void DirectoryStoreNamesASuffixedNewerStampByItsLength() + { + var root = Path.Combine(Path.GetTempPath(), $"cultlib-suffix-{Guid.NewGuid():N}"); + var manifest = Path.Combine(root, "store.cc"); + Directory.CreateDirectory(root); + try + { + File.WriteAllBytes(manifest, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot + { + FormatVersion = "cultcache.store.v6.directory-content-addressed-pages", + SchemaCatalog = Array.Empty(), + Records = Array.Empty() + })); + + var refusal = Assert.Throws(() => new DirectoryMessagePackBackingStore(manifest).PullAll())!; + + var suffixBytes = System.Text.Encoding.UTF8.GetByteCount(".directory-content-addressed-pages"); + Assert.That(refusal.Message, Does.Contain($" is cultcache.store.v6 with a suffix of {suffixBytes} bytes;")); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + // An array with no stamp parses to the default v1, which no directory reads: the manifest is refused, and the pages that + // belong to the store beside it are neither rewritten nor deleted. + [Test] + public void DirectoryManifestThatIsAnEmptyArrayIsRefused() + { + var root = CopyFixture("directory-v4"); + try + { + var manifest = Path.Combine(root, "store.cc"); + var emptyArray = new byte[] { 0x90 }; + File.WriteAllBytes(manifest, emptyArray); + var pages = Directory.GetFiles(root, "*", SearchOption.AllDirectories) + .Where(file => file != manifest) + .ToDictionary(file => file, File.ReadAllBytes); + Assert.That(pages, Is.Not.Empty, "the fixture's pages exist"); + + var refusal = Assert.Throws(() => new DirectoryMessagePackBackingStore(manifest).PullAll())!; + + Assert.That(refusal.Path, Is.EqualTo(manifest)); + Assert.That(File.ReadAllBytes(manifest), Is.EqualTo(emptyArray)); + Assert.That(Directory.GetFiles(root, "*", SearchOption.AllDirectories).Where(file => file != manifest), Is.EquivalentTo(pages.Keys)); + foreach (var page in pages) + Assert.That(File.ReadAllBytes(page.Key), Is.EqualTo(page.Value), page.Key); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } } } diff --git a/tests/GameCult.Caching.Tests/PythonWrittenStoreTests.cs b/tests/GameCult.Caching.Tests/PythonWrittenStoreTests.cs index 6b26e90b..f83ae98d 100644 --- a/tests/GameCult.Caching.Tests/PythonWrittenStoreTests.cs +++ b/tests/GameCult.Caching.Tests/PythonWrittenStoreTests.cs @@ -53,6 +53,7 @@ namespace GameCult.Caching.Tests Assert.That(k[4], Is.EqualTo("s")); Assert.That(doc.Nested, Is.Null); Assert.That(doc.Items.Select(i => i.N), Is.EqualTo(new[] { "one", "two" })); + Assert.That(doc.Items.Select(i => i.Id), Is.EqualTo(new[] { "000000000021", "000000000022" }), "a Python-written list of records keeps its element ids"); Assert.That(Bits(doc.Items[0].W), Is.EqualTo(Bits((float)(9007199254740992L + 536870913L)))); Assert.That(Bits(doc.Items[1].W), Is.EqualTo(Bits(1e-40f))); Assert.That(Bits(((NumericLeafVectorTests.LeafCircle)doc.Shape!).R), Is.EqualTo(Bits(0.1f))); @@ -66,6 +67,181 @@ namespace GameCult.Caching.Tests } } + private static string HeaderOf(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion; + + private static async Task<(CultCache Cache, SingleFileMessagePackBackingStore Store)> PulledAsync(string path) + { + var cache = new CultCache(); + var store = new SingleFileMessagePackBackingStore(path); + cache.AddBackingStore(store); + await cache.PullAllBackingStoresAsync(); + return (cache, store); + } + + // cultcache-py writes element ids under an unmarked v1 header. A C# write that carries such a record forward marks the + // store, because a reader from before ids would otherwise rewrite the elements without them. + [Test] + public async Task ACSharpWriteThatCopiesAPythonRecordHoldingIdsMarksTheStore() + { + var scratch = Directory.CreateTempSubdirectory().FullName; + try + { + var path = Store("leaf-record.cc", scratch); + Assert.That(HeaderOf(path), Is.EqualTo(CultPersistedStoreSnapshot.FormatV1), "cultcache-py leaves the header unmarked"); + var (cache, store) = await PulledAsync(path); + await cache.AddAsync(new NumericLeafVectorTests.LeafRecord { Id = "other" }); + store.PushAll(); + Assert.That(HeaderOf(path), Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); + + var (reloaded, _) = await PulledAsync(path); + var py = reloaded.AllEntries.OfType().Single(record => record.Id == "py"); + Assert.That(py.Items.Select(item => item.Id), Is.EqualTo(new[] { "000000000021", "000000000022" })); + } + finally + { + Directory.Delete(scratch, true); + } + } + + // What a load saw is a fact about those bytes at that version. Another writer replaced the record since, so the copy this + // write makes is not the record the load saw, and the header is not judged by it. + [Test] + public async Task ACopiedRecordAnotherWriterChangedIsNotJudgedByTheOldRead() + { + var scratch = Directory.CreateTempSubdirectory().FullName; + try + { + var path = Store("leaf-record.cc", scratch); + var (reader, readerStore) = await PulledAsync(path); + + // Another writer replaces the record with one that holds no id. + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var replaced = snapshot.Records.Single(record => record.Key == "py"); + replaced.Payload = CultDocumentMessagePackSerialization.SerializeUntyped( + new NumericLeafVectorTests.LeafRecord { Id = "py" }, typeof(NumericLeafVectorTests.LeafRecord)); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + Assert.That(HeaderOf(path), Is.EqualTo(CultPersistedStoreSnapshot.FormatV1), "the replacement holds no id"); + + await reader.AddAsync(new NumericLeafVectorTests.LeafRecord { Id = "other" }); + readerStore.PushAll(); + Assert.That(HeaderOf(path), Is.EqualTo(CultPersistedStoreSnapshot.FormatV1)); + } + finally + { + Directory.Delete(scratch, true); + } + } + + // A v1 store holding only this record c0, opened by a cache that has pulled it; then another runtime appends the python + // record (and its payload rewritten by `change`) to the file raw, header still v1, the way a writer that keeps the v1 it + // found does. Returns the open cache and its store. + private static async Task<(CultCache Cache, SingleFileMessagePackBackingStore Store)> PulledThenAppended( + string path, string pySource, Action? change = null) + { + using (var seed = new CultCache()) + { + var seedStore = new SingleFileMessagePackBackingStore(path); + seed.AddBackingStore(seedStore); + await seed.AddAsync(new NumericLeafVectorTests.LeafRecord { Id = "c0" }); + seedStore.PushAll(); + } + + Assert.That(HeaderOf(path), Is.EqualTo(CultPersistedStoreSnapshot.FormatV1)); + var opened = await PulledAsync(path); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var pySnapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(pySource)); + var py = pySnapshot.Records.Single(record => record.Key == "py"); + change?.Invoke(py); + snapshot.Records = snapshot.Records.Concat(new[] { py }).ToArray(); + snapshot.SchemaCatalog = snapshot.SchemaCatalog.Concat(pySnapshot.SchemaCatalog).GroupBy(entry => entry.SchemaId).Select(group => group.First()).ToArray(); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + Assert.That(HeaderOf(path), Is.EqualTo(CultPersistedStoreSnapshot.FormatV1), "the foreign writer keeps the v1 it found"); + return opened; + } + + // The cache never read this record: it was appended after the pull. A per-instance memory of what the pull saw cannot + // know about it; the write decides from the bytes the file holds under its lock. + [Test] + public async Task ARecordAForeignWriterAddsAfterThePullMarksTheStoreWhenCopied() + { + var scratch = Directory.CreateTempSubdirectory().FullName; + try + { + var path = Path.Combine(scratch, "after-pull.cc"); + var (cache, store) = await PulledThenAppended(path, NumericLeafVectorTests.VectorPath(Path.Combine("py-store", "leaf-record.cc"))); + await cache.AddAsync(new NumericLeafVectorTests.LeafRecord { Id = "c1" }); + store.PushAll(); + Assert.That(HeaderOf(path), Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); + + var (reloaded, _) = await PulledAsync(path); + var py = reloaded.AllEntries.OfType().Single(record => record.Id == "py"); + Assert.That(py.Items.Select(item => item.Id), Is.EqualTo(new[] { "000000000021", "000000000022" })); + } + finally + { + Directory.Delete(scratch, true); + } + } + + // Ids a load minted in memory are not on disk: the check judges the file's bytes, never the document the cache holds. + [Test] + public async Task ARecordWhoseIdsExistOnlyInMemoryLeavesTheStoreUnmarked() + { + var scratch = Directory.CreateTempSubdirectory().FullName; + try + { + var path = Path.Combine(scratch, "in-memory.cc"); + var source = Store("leaf-record.cc", scratch); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(source)); + var idless = new NumericLeafVectorTests.LeafRecord { Id = "py" }; + idless.Items.Add(new NumericLeafVectorTests.LeafInner { N = "one" }); + idless.Items.Add(new NumericLeafVectorTests.LeafInner { N = "two" }); + snapshot.Records.Single().Payload = CultDocumentMessagePackSerialization.SerializeUntyped(idless, typeof(NumericLeafVectorTests.LeafRecord)); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var before = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Single().Payload; + + var (cache, store) = await PulledAsync(path); + Assert.That(cache.AllEntries.OfType().Single().Items.Select(item => item.Id), Has.All.Length.EqualTo(12), "the load minted ids in memory"); + await cache.AddAsync(new NumericLeafVectorTests.LeafRecord { Id = "other" }); + store.PushAll(); + + Assert.That(HeaderOf(path), Is.EqualTo(CultPersistedStoreSnapshot.FormatV1)); + var after = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Single(record => record.Key == "py").Payload; + Assert.That(after, Is.EqualTo(before), "the copied record is the bytes the file held"); + } + finally + { + Directory.Delete(scratch, true); + } + } + + // The write-set rule copies records without interpreting them, so the check must never refuse a write: a record this + // cache claims but cannot decode is copied as its bytes and decides nothing. + [Test] + public async Task ACopiedRecordThatDoesNotDecodeIsCopiedAndDecidesNothing() + { + var scratch = Directory.CreateTempSubdirectory().FullName; + try + { + var path = Path.Combine(scratch, "undecodable.cc"); + var garbage = new byte[] { 0xc1, 0xc1, 0xc1 }; + var (cache, store) = await PulledThenAppended( + path, NumericLeafVectorTests.VectorPath(Path.Combine("py-store", "leaf-record.cc")), record => record.Payload = garbage); + + await cache.AddAsync(new NumericLeafVectorTests.LeafRecord { Id = "c1" }); + Assert.DoesNotThrow(() => store.PushAll()); + + Assert.That(HeaderOf(path), Is.EqualTo(CultPersistedStoreSnapshot.FormatV1)); + var py = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Single(record => record.Key == "py"); + Assert.That(py.Payload, Is.EqualTo(garbage)); + } + finally + { + Directory.Delete(scratch, true); + } + } + [TestCase(3)] [TestCase(5)] [TestCase(7)] diff --git a/tests/GameCult.Caching.Tests/RefListDriftTests.cs b/tests/GameCult.Caching.Tests/RefListDriftTests.cs new file mode 100644 index 00000000..9aa180ea --- /dev/null +++ b/tests/GameCult.Caching.Tests/RefListDriftTests.cs @@ -0,0 +1,218 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // 1.0.x persisted a list of record references (CultRecordRef[]) as a plain value slot: isReference false, no target. + // 2.0 classifies the same slot as a reference to T. The payload is the same array of key strings, so a store written by + // 1.0.x opens: gaining reference classification on an otherwise identical slot is compatible drift. A reference that is + // lost, retargeted, or that arrives with a different type or cardinality stays a refusal. + public class RefListDriftTests + { + [CultDocument("tests.reflist_target", "tests.reflist_target.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RefListTarget + { + [Key(0)] + [CultName] + public string Name = string.Empty; + } + + [CultDocument("tests.reflist_holder", "tests.reflist_holder.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RefListHolder + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public CultRecordRef[] Items = Array.Empty>(); + } + + // The reverse of the 1.0.x drift: a slot persisted as a reference list, declared by a later version as a plain value. + [CultDocument("tests.reflist_loss", "tests.reflist_loss.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RefListLossBefore + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public CultRecordRef[] Items = Array.Empty>(); + } + + [CultDocument("tests.reflist_loss", "tests.reflist_loss.v2")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RefListLossAfter + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public string[] Items = Array.Empty(); + } + + private static string VectorPath(string name) + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var vectors = Path.Combine(dir.FullName, "tests", "vectors", "ref-list-drift"); + if (Directory.Exists(vectors)) return Path.Combine(vectors, name); + } + + throw new DirectoryNotFoundException($"tests/vectors/ref-list-drift not found above {TestContext.CurrentContext.TestDirectory}."); + } + + [Test] + public async Task A_1_0_Store_With_A_Ref_List_Slot_Opens_And_Rewrites_Under_The_Reference_Schema() + { + var copy = Path.Combine(Path.GetTempPath(), $"reflist-drift-{Guid.NewGuid():N}.cc"); + File.Copy(VectorPath("old-store.cc"), copy); + try + { + using var cache = new CultCache(); + var store = new SingleFileMessagePackBackingStore(copy); + cache.AddBackingStore(store); + await cache.PullAllBackingStoresAsync(); + Assert.That( + store.LastSchemaMigrationReports.Single(report => report.PersistedSchemaName == "tests.reflist_holder").Kind, + Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift)); + + var holder = cache.GetAll().Single(); + Assert.That(holder.Items.Select(item => item.Key.Value), Is.EqualTo(new[] { "alpha", "beta" })); + Assert.That(cache.Get(holder.Items[0].Key)!.Name, Is.EqualTo("alpha")); + + // A write flushes the store under the 2.0 schema; the list reads back unchanged. + await cache.AddAsync(new RefListTarget { Name = "gamma" }); + store.PushAll(); + using var reopened = new CultCache(); + reopened.AddBackingStore(new SingleFileMessagePackBackingStore(copy)); + await reopened.PullAllBackingStoresAsync(); + Assert.That(string.Join(",", reopened.GetAll().Select(target => target.Name).OrderBy(name => name)), Is.EqualTo("alpha,beta,gamma")); + Assert.That(reopened.GetAll().Single().Items.Select(item => item.Key.Value).ToArray(), Is.EqualTo(new[] { "alpha", "beta" })); + } + finally + { + File.Delete(copy); + } + } + + // The store the 1.0.60 writer wrote (tests/vectors/ref-list-drift/v1-0-60-written.cc, made by OldWriterRefListProbe), or the one + // old-reader-refusal.sh gate csharp names in CULTLIB_GATE_OLD_STORE. It attaches the store, upserts alpha, beta and the list + // reversed, flushes, rereads the file and prints the REFLIST-SUMMARY line OldReaderRefListProbe prints at 2.0.1 after the same + // write; the gate diffs them, so the two must stay the same code. Ids are compared, not names: the write is what makes a record's + // schema id the one its type owns, and a record left under its persisted id is a type that cannot write it. + [Test] + public async Task A_1_0_60_Written_Store_Opens_Like_2_0_1() + { + var source = Environment.GetEnvironmentVariable("CULTLIB_GATE_OLD_STORE"); + var copy = Path.Combine(Path.GetTempPath(), $"reflist-1060-{Guid.NewGuid():N}.cc"); + File.Copy(string.IsNullOrEmpty(source) ? VectorPath("v1-0-60-written.cc") : source, copy); + try + { + string[] warnings; + using (var cache = new CultCache()) + { + var store = new SingleFileMessagePackBackingStore(copy); + cache.AddBackingStore(store); + await cache.PullAllBackingStoresAsync(); + warnings = store.LastSchemaMigrationReports.SelectMany(report => report.Warnings).Select(warning => warning.Code).Distinct().OrderBy(code => code, StringComparer.Ordinal).ToArray(); + Assert.That(warnings, Does.Contain("gained_reference_semantics")); + var holder = cache.GetAll().Single(); + Assert.That(holder.Items.Select(item => item.Key.Value), Is.EqualTo(new[] { "alpha", "beta" })); + + // The write both runtimes make: the targets as they are, the holder with its list reversed. + foreach (var target in cache.GetAll().ToArray()) await cache.UpsertAsync(target, cache.TryGetHandle(target)); + holder.Items = holder.Items.AsEnumerable().Reverse().ToArray(); + await cache.UpsertAsync(holder, cache.TryGetHandle(holder)); + await cache.FlushAsync(); + } + + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(copy)); + var records = snapshot.Records.OrderBy(record => record.Key, StringComparer.Ordinal).Select(record => record.Key + "=" + record.SchemaId).ToArray(); + var listId = snapshot.Records.Single(record => record.Key == "list").SchemaId; + var slot = snapshot.SchemaCatalog.Single(entry => entry.SchemaId == listId).Members.Single(member => member.MemberName == "Items"); + using var reopened = new CultCache(); + reopened.AddBackingStore(new SingleFileMessagePackBackingStore(copy)); + await reopened.PullAllBackingStoresAsync(); + var items = reopened.GetAll().Single().Items.Select(item => item.Key.Value).ToArray(); + Assert.That(items, Is.EqualTo(new[] { "beta", "alpha" })); + Assert.That(reopened.Get(reopened.GetAll().Single().Items[0].Key)!.Name, Is.EqualTo("beta")); + TestContext.Out.WriteLine("REFLIST-SUMMARY " + System.Text.Json.JsonSerializer.Serialize(new { records, items, itemsSlot = new { slot.IsReference, slot.TargetSchemaName }, warnings, header = snapshot.FormatVersion })); + } + finally + { + File.Delete(copy); + } + } + + private static CultSchemaMigrationReport Resolve(Action persistedItems) + { + var catalog = CultDocumentRegistry.Shared.GetRequired().ToCatalogEntry(); + catalog.SchemaId = "persisted.tests.reflist_holder.v1"; + catalog.ContentHash = "sha256:persisted-by-1.0.x"; + catalog.CompatibleSchemaIds = new[] { CultDocumentRegistry.Shared.GetRequired().SchemaId }; + persistedItems(catalog.Members.Single(member => member.Slot == 1)); + return CultDocumentRegistry.Shared.ResolvePersistedSchemaReport(catalog.SchemaId, new[] { catalog }); + } + + private static void AsWrittenBy10(CultSchemaMemberCatalogEntry member) + { + member.IsReference = false; + member.TargetSchemaName = null; + } + + [Test] + public void A_Reference_Gained_On_An_Otherwise_Identical_Slot_Is_Compatible_Drift() + { + var report = Resolve(AsWrittenBy10); + Assert.That(report.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift)); + Assert.That(report.Warnings.Select(warning => warning.Code), Does.Contain("gained_reference_semantics")); + } + + [Test] + public void A_Persisted_Reference_Retargeted_Or_Gaining_Over_A_Foreign_Target_Still_Refuses() + { + Assert.That( + () => Resolve(member => member.TargetSchemaName = "tests.other_target"), + Throws.TypeOf().With.Message.Contains("changed target schema")); + Assert.That( + () => Resolve(member => { member.IsReference = false; member.TargetSchemaName = "tests.other_target"; }), + Throws.TypeOf()); + } + + [Test] + public void A_Lost_Reference_Still_Refuses() + { + var registry = CultDocumentRegistry.Shared; + var catalog = registry.GetRequired().ToCatalogEntry(); + catalog.SchemaId = "persisted.tests.reflist_loss.v1"; + catalog.ContentHash = "sha256:persisted-by-2.0"; + catalog.CompatibleSchemaIds = new[] { registry.GetRequired().SchemaId }; + catalog.Members.Single(member => member.Slot == 1).TypeName = "System.String[]"; + Assert.That( + () => registry.ResolvePersistedSchemaReport(catalog.SchemaId, new[] { catalog }), + Throws.TypeOf().With.Message.Contains("changed reference semantics")); + } + + [Test] + public void A_Gained_Reference_Does_Not_Excuse_A_Changed_Type_Or_Cardinality() + { + Assert.That( + () => Resolve(member => { AsWrittenBy10(member); member.TypeName = "System.String[]"; }), + Throws.TypeOf().With.Message.Contains("changed type")); + Assert.That( + () => Resolve(member => { AsWrittenBy10(member); member.IsMany = !member.IsMany; }), + Throws.TypeOf().With.Message.Contains("changed cardinality")); + } + } +} diff --git a/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs b/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs new file mode 100644 index 00000000..b1976aa6 --- /dev/null +++ b/tests/GameCult.Caching.Tests/RestampOnWriteTests.cs @@ -0,0 +1,802 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // variants:ruling:drift-record-write-ownership: a writer owns a record stored under id X when it declares X, or when the type + // that reads X reads it whole; it then writes, relabels and removes it, and a write stores it under the type's own id. A record + // a type reads only by dropping persisted slots stays read-only until its id is declared compatible. + public class RestampOnWriteTests + { + // One schema name at three versions of its type and nothing declared: Grown reads Narrow's records whole (it adds a slot), + // and reads Wide's records only by dropping slot 3. + [CultDocument("tests.restamp_note", "tests.restamp_note.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class NoteNarrow + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + [CultDocument("tests.restamp_note", "tests.restamp_note.v2")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class NoteGrown + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + [Key(2)] public string Colour = string.Empty; + } + + [CultDocument("tests.restamp_note", "tests.restamp_note.v3")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class NoteWide + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + [Key(2)] public string Colour = string.Empty; + [Key(3)] public string Extra = string.Empty; + } + + // Another schema with Narrow's exact shape: never the same type, whatever the members say. + [CultDocument("tests.restamp_other", "tests.restamp_other.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class NoteOther + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + // Reads a Narrow-shaped entry only by dropping Value. + [CultDocument("tests.restamp_short", "tests.restamp_short.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class NoteShort + { + [Key(0)] [CultName] public string Name = string.Empty; + } + + // Another schema with Narrow's shape that lists an old id: it claims records stored under that id. + [CultDocument("tests.restamp_lister", "tests.restamp_lister.v1", CompatibleSchemaIds = new[] { ListerOldId })] + [MessagePackObject(AllowPrivate = true)] + internal sealed class NoteLister + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + private const string ListerOldId = "tests.restamp.lister_old"; + + // Grown with the lossy id declared: declaration is the consent to the loss. + [CultDocument("tests.restamp_note", "tests.restamp_note.v4", CompatibleSchemaIds = new[] { WideId })] + [MessagePackObject(AllowPrivate = true)] + internal sealed class NoteGrownDeclaring + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + [Key(2)] public string Colour = string.Empty; + } + + private const string WideId = "sha256:82c2b721aceb56e00288baab590fabcdd82c3bda44400f7530fa9e29c4e96252"; + + private static readonly CultDocumentRegistry NarrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow) }); + private static readonly CultDocumentRegistry GrownRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteGrown) }); + private static readonly CultDocumentRegistry WideRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteWide) }); + private static readonly CultDocumentRegistry DeclaringRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteGrownDeclaring) }); + private static readonly CultRecordKey Note = new("note"); + private static readonly CultRecordKey Other = new("other"); + + private string _directory = string.Empty; + + [SetUp] + public void CreateDirectory() + { + _directory = Path.Combine(Path.GetTempPath(), $"cultlib-restamp-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + + public static readonly object[] StoreKinds = { false, true }; + + private CacheBackingStore Store(string path, bool directory) => + directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path); + + private CultCache Open(string path, bool directory, CultDocumentRegistry registry) + { + var cache = new CultCache(registry); + cache.AddBackingStore(Store(path, directory)); + return cache; + } + + // The schema id each key is stored under, read back through a fresh cache that declares everything it sees. + private string StoredIdOf(string path, bool directory, CultDocumentRegistry registry, CultRecordKey key) + { + using var cache = Open(path, directory, registry); + return cache.GetStored(key)!.StoredSchemaId; + } + + private static string Fingerprint(string path) + { + using var sha = SHA256.Create(); + if (File.Exists(path)) + return Convert.ToHexString(sha.ComputeHash(File.ReadAllBytes(path))); + var parts = Directory.GetFiles(path, "*", SearchOption.AllDirectories) + .OrderBy(file => file, StringComparer.Ordinal) + .Select(file => Path.GetRelativePath(path, file) + ":" + Convert.ToHexString(sha.ComputeHash(File.ReadAllBytes(file)))); + return string.Join("|", parts); + } + + // Two records written by a writer that knew Narrow: the one under test and a bystander. + private string SeedNarrow(bool directory) + { + var path = Path.Combine(_directory, directory ? "store" : "store.cc"); + using var cache = Open(path, directory, NarrowRegistry); + Assert.That(cache.Commit(batch => + { + batch.Upsert(new NoteNarrow { Name = "note", Value = 1 }, new CultRecordHandle(Note)); + batch.Upsert(new NoteNarrow { Name = "other", Value = 7 }, new CultRecordHandle(Other)); + }), Is.True); + return path; + } + + private string SeedWide(bool directory) + { + var path = Path.Combine(_directory, directory ? "store" : "store.cc"); + using var cache = Open(path, directory, WideRegistry); + Assert.That(cache.Commit(batch => batch.Upsert(new NoteWide { Name = "note", Value = 1, Extra = "kept?" }, new CultRecordHandle(Note))), Is.True); + return path; + } + + [TestCaseSource(nameof(StoreKinds))] + public void ADriftRecordReadWholeIsWrittenUnderItsTypesId(bool directory) + { + var path = SeedNarrow(directory); + var narrowId = NarrowRegistry.GetRequired().SchemaId; + var grownId = GrownRegistry.GetRequired().SchemaId; + Assert.That(StoredIdOf(path, directory, NarrowRegistry, Note), Is.EqualTo(narrowId)); + + using (var cache = Open(path, directory, GrownRegistry)) + { + Assert.That(cache.Get(Note)!.Value, Is.EqualTo(1)); + Assert.That(cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 2, Colour = "red" }, new CultRecordHandle(Note))), Is.True); + } + + Assert.That(StoredIdOf(path, directory, GrownRegistry, Note), Is.EqualTo(grownId), "the written record moved to its type's id"); + Assert.That(StoredIdOf(path, directory, NarrowRegistry, Other), Is.EqualTo(narrowId), "the bystander kept its persisted id (write set)"); + using var reopened = Open(path, directory, GrownRegistry); + Assert.That(reopened.Get(Note)!.Colour, Is.EqualTo("red")); + Assert.That(reopened.Get(Other)!.Value, Is.EqualTo(7)); + } + + [TestCaseSource(nameof(StoreKinds))] + public void RemovingADriftRecordReadWholeSucceeds(bool directory) + { + var path = SeedNarrow(directory); + using (var cache = Open(path, directory, GrownRegistry)) + Assert.That(cache.Commit(batch => batch.Remove(Note)), Is.True); + + using var reopened = Open(path, directory, GrownRegistry); + Assert.That(reopened.Get(Note), Is.Null); + Assert.That(reopened.Get(Other), Is.Not.Null); + } + + [TestCaseSource(nameof(StoreKinds))] + public void ADriftRecordReadOnlyByDroppingASlotStaysReadOnly(bool directory) + { + var path = SeedWide(directory); + var before = Fingerprint(path); + + using var cache = Open(path, directory, GrownRegistry); + Assert.That(cache.Get(Note), Is.Not.Null, "a read-only record is still served"); + var upsert = Assert.Throws(() => + cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 2 }, new CultRecordHandle(Note))))!; + Assert.That(upsert.RecordKey, Is.EqualTo("note")); + Assert.That(upsert.SchemaId, Is.EqualTo(WideRegistry.GetRequired().SchemaId)); + Assert.That(upsert.Message, Does.Contain("would drop persisted slots").And.Contain("to accept the loss."), "the refusal names the narrowed rule"); + var remove = Assert.Throws(() => cache.Commit(batch => batch.Remove(Note)))!; + Assert.That(remove.RecordKey, Is.EqualTo("note")); + Assert.That(Fingerprint(path), Is.EqualTo(before), "a refused write leaves the store as it was"); + } + + // A held record read only by dropping a slot is refused before anything is staged: with the store's lock held by another + // writer, the write is refused typed instead of waiting on the lock and reporting contention. + [TestCaseSource(nameof(StoreKinds))] + public void ALossyDriftRecordIsRefusedBeforeTheStoresLockIsAsked(bool directory) + { + var path = SeedWide(directory); + using var cache = Open(path, directory, GrownRegistry); + using var held = directory + ? new FileStream(Path.Combine(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path), ".commit.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None) + : new FileStream(path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + + Assert.Throws(() => + cache.TryCommit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 2 }, new CultRecordHandle(Note)))); + } + + [TestCaseSource(nameof(StoreKinds))] + public void ADeclaredLossyDriftRecordIsWritable(bool directory) + { + var path = SeedWide(directory); + var wideId = WideRegistry.GetRequired().SchemaId; + Assert.That(wideId, Is.EqualTo(WideId), "the declaration names the lossy record's id: " + wideId); + + using (var cache = Open(path, directory, DeclaringRegistry)) + Assert.That(cache.Commit(batch => batch.Upsert(new NoteGrownDeclaring { Name = "note", Value = 2 }, new CultRecordHandle(Note))), Is.True); + + Assert.That(StoredIdOf(path, directory, DeclaringRegistry, Note), Is.EqualTo(DeclaringRegistry.GetRequired().SchemaId)); + } + + // Another writer rewrites the record after this cache loaded it: ownership is judged again under the store's lock. + [TestCaseSource(nameof(StoreKinds))] + public void AnotherWritersDriftRecordIsJudgedUnderTheLock(bool directory) + { + var path = SeedNarrow(directory); + using var cache = Open(path, directory, GrownRegistry); + Assert.That(cache.Get(Note), Is.Not.Null); + + // A writer that knows Wide replaces the record: this cache's loaded copy was read whole, the durable one is not. + using (var other = Open(path, directory, WideRegistry)) + { + Assert.That(other.Commit(batch => batch.Upsert(new NoteWide { Name = "note", Value = 3, Extra = "x" }, new CultRecordHandle(Note))), Is.True); + } + + var refusal = Assert.Throws(() => + cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 4 }, new CultRecordHandle(Note))))!; + Assert.That(refusal.RecordKey, Is.EqualTo("note")); + Assert.That(StoredIdOf(path, directory, WideRegistry, Note), Is.EqualTo(WideRegistry.GetRequired().SchemaId)); + } + + [Test] + public void ACachelessWriteRestampsADriftRecordOfItsSchema() + { + var path = SeedNarrow(directory: false); + new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteGrown { Name = "note", Value = 2 }, registry: GrownRegistry); + + Assert.That(StoredIdOf(path, false, GrownRegistry, Note), Is.EqualTo(GrownRegistry.GetRequired().SchemaId)); + Assert.That(StoredIdOf(path, false, NarrowRegistry, Other), Is.EqualTo(NarrowRegistry.GetRequired().SchemaId)); + } + + [Test] + public void ACachelessWriteOverALossyDriftRecordIsRefusedAndTheFileIsUntouched() + { + var path = SeedWide(directory: false); + var before = Fingerprint(path); + var refusal = Assert.Throws(() => + new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteGrown { Name = "note", Value = 2 }, registry: GrownRegistry))!; + Assert.That(refusal.Message, Does.Contain("reads it whole").And.Contain("to accept the loss of its unread slots."), "the refusal names the narrowed rule"); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + // A cacheless writer's entry that lists the record's id compatible consents to the loss, as a cache's declaration does. + [Test] + public void ACachelessWriteByAnEntryListingTheIdCompatibleOverwritesALossyDriftRecord() + { + var path = SeedWide(directory: false); + new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteGrownDeclaring { Name = "note", Value = 2 }, registry: DeclaringRegistry); + + Assert.That(StoredIdOf(path, false, DeclaringRegistry, Note), Is.EqualTo(DeclaringRegistry.GetRequired().SchemaId)); + } + + // The type that would read the id cannot be named (two registered versions share the schema name and the id declares neither), + // so the record is not owned and a write over it is refused typed, not as the resolver's own error. + [TestCaseSource(nameof(StoreKinds))] + public void ARecordNoRegisteredTypeCanBeNamedForIsNotOwned(bool directory) + { + var path = SeedNarrow(directory); + var both = CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow), typeof(NoteGrown) }); + using var cache = Open(path, directory, both); + using (var other = Open(path, directory, WideRegistry)) + Assert.That(other.Commit(batch => batch.Upsert(new NoteWide { Name = "note", Value = 3 }, new CultRecordHandle(Note))), Is.True); + + Assert.Throws(() => + cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 4 }, new CultRecordHandle(Note)))); + } + + [TestCaseSource(nameof(StoreKinds))] + public void ExpectAgreesWithRestamp(bool directory) + { + var path = SeedNarrow(directory); + var third = new CultRecordKey("third"); + using var cache = Open(path, directory, GrownRegistry); + + Assert.That(cache.Commit(batch => + { + batch.Expect(Note, cache.Get(Note)); + batch.ExpectUnchanged(); + batch.Upsert(new NoteGrown { Name = "third" }, new CultRecordHandle(third)); + }), Is.True, "an unchanged drift record satisfies Expect and ExpectUnchanged"); + + using var second = Open(path, directory, GrownRegistry); + Assert.That(cache.Commit(batch => + { + batch.Expect(Note, cache.Get(Note)); + batch.Upsert(new NoteGrown { Name = "note", Value = 2 }, new CultRecordHandle(Note)); + }), Is.True, "a batch that Expects the drift record and upserts it commits"); + Assert.That(StoredIdOf(path, directory, GrownRegistry, Note), Is.EqualTo(GrownRegistry.GetRequired().SchemaId), "and restamps it"); + + Assert.That(cache.Commit(batch => + { + batch.ExpectUnchanged(); + batch.Upsert(new NoteGrown { Name = "fourth" }, new CultRecordHandle(new CultRecordKey("fourth"))); + }), Is.True, "the writer's later ExpectUnchanged holds"); + Assert.That(second.Commit(batch => + { + batch.Expect(Note, second.Get(Note)); + batch.Upsert(new NoteGrown { Name = "note", Value = 3 }, new CultRecordHandle(Note)); + }), Is.False, "a cache loaded before the restamp fails its Expect: the bytes changed"); + } + + private static readonly CultRecordKey Base = new("base"); + private static readonly CultRecordKey Delta = new("delta"); + + private CultCache OpenWithCodec(string path, bool directory, CultDocumentRegistry registry) + { + var cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry)); + cache.AddBackingStore(Store(path, directory)); + return cache; + } + + private string StoredIdOfVariantStore(string path, CultDocumentRegistry registry, CultRecordKey key) + { + using var cache = OpenWithCodec(path, false, registry); + return cache.GetStored(key)!.StoredSchemaId; + } + + // A base and a variant of it, written by a writer that knew Narrow; Grown then reads the variant whole as compatible drift. + private string SeedNarrowVariant() + { + var path = Path.Combine(_directory, "store.cc"); + using var cache = OpenWithCodec(path, false, NarrowRegistry); + Assert.That(cache.Commit(batch => batch.Upsert(new NoteNarrow { Name = "base", Value = 1 }, new CultRecordHandle(Base))), Is.True); + Assert.That(cache.Commit(batch => batch.UpsertVariant(Delta, Base, new[] { cache.Override("Name", "delta"), cache.Override("Value", 5) })), Is.True); + return path; + } + + // Variants live in a single file (the directory store refuses them). The staging half asks the flag ToStoredDocument set on a loaded variant; Resolved and Flatten copies are never asked (only the store's loaded view carries the read-whole flag): an owned + // drift variant is rewritten, flattened and removed like a plain drift record, restamping only what is written. + [Test] + public async Task AnOwnedDriftVariantIsRewritten() + { + var path = SeedNarrowVariant(); + using (var cache = OpenWithCodec(path, false, GrownRegistry)) + { + await cache.PullAllBackingStoresAsync(); + Assert.That(cache.Commit(batch => batch.UpsertVariant(Delta, Base, new[] { cache.Override("Name", "delta"), cache.Override("Value", 6) })), Is.True); + } + + Assert.That(StoredIdOfVariantStore(path, GrownRegistry, Delta), Is.EqualTo(GrownRegistry.GetRequired().SchemaId)); + Assert.That(StoredIdOfVariantStore(path, NarrowRegistry, Base), Is.EqualTo(NarrowRegistry.GetRequired().SchemaId), "the base was not written"); + } + + [Test] + public async Task AnOwnedDriftVariantIsFlattened() + { + var path = SeedNarrowVariant(); + using (var cache = OpenWithCodec(path, false, GrownRegistry)) + { + await cache.PullAllBackingStoresAsync(); + Assert.That(cache.Commit(batch => batch.Flatten(Delta)), Is.True); + } + + Assert.That(StoredIdOfVariantStore(path, GrownRegistry, Delta), Is.EqualTo(GrownRegistry.GetRequired().SchemaId)); + } + + [Test] + public async Task AnOwnedDriftVariantIsRemoved() + { + var path = SeedNarrowVariant(); + using (var cache = OpenWithCodec(path, false, GrownRegistry)) + { + await cache.PullAllBackingStoresAsync(); + Assert.That(cache.Commit(batch => batch.Remove(Delta)), Is.True); + } + + using var reopened = OpenWithCodec(path, false, GrownRegistry); + await reopened.PullAllBackingStoresAsync(); + Assert.That(reopened.GetStored(Delta), Is.Null); + Assert.That(reopened.GetStored(Base), Is.Not.Null); + } + + // A store whose entry is a newer writer's rename of Narrow's schema, with identical members and the given compatible ids. + private string SeedRenamed(bool listsNarrow, string? alsoLists = null, string name = "tests.restamp_note_renamed") + { + var path = SeedNarrow(directory: false); + var snapshot = Snapshot(path); + var narrowId = NarrowRegistry.GetRequired().SchemaId; + var entry = snapshot.SchemaCatalog.Single(); + entry.SchemaId = entry.ContentHash = "tests.restamp.renamed"; + entry.SchemaName = name; + entry.SchemaVersion = "tests.restamp_note_renamed.v2"; + entry.CompatibleSchemaIds = new[] { "tests.restamp.renamed" } + .Concat(listsNarrow ? new[] { narrowId } : Array.Empty()) + .Concat(alsoLists == null ? Array.Empty() : new[] { alsoLists }) + .ToArray(); + foreach (var record in snapshot.Records) + record.SchemaId = entry.SchemaId; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + return path; + } + + // One writer of NoteNarrow, with a cache or without one: the same Owns answer for the same record. + private void WriteNarrow(string path, bool cacheless, CultDocumentRegistry? registry = null) + { + registry ??= NarrowRegistry; + if (cacheless) + { + new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteNarrow { Name = "note", Value = 2 }, registry: registry); + return; + } + + using var cache = Open(path, false, registry); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + Assert.That(cache.Commit(batch => batch.Upsert(new NoteNarrow { Name = "note", Value = 2 }, new CultRecordHandle(Note))), Is.True); + } + + [TestCase(false)] + [TestCase(true)] + public void ARenamedEntryListingTheWritersIdIsOwnedByCacheAndCachelessWriterAlike(bool cacheless) + { + var path = SeedRenamed(listsNarrow: true); + WriteNarrow(path, cacheless); + + Assert.That(Snapshot(path).Records.Single(record => record.Key == "note").SchemaId, Is.EqualTo(NarrowRegistry.GetRequired().SchemaId)); + } + + [TestCase(false)] + [TestCase(true)] + public void ARenamedEntryNotListingTheWritersIdIsRefusedByCacheAndCachelessWriterAlike(bool cacheless) + { + var path = SeedRenamed(listsNarrow: false); + var before = Fingerprint(path); + Assert.Throws(() => WriteNarrow(path, cacheless)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + // A writer of one schema never overwrites and retypes a record of another schema, however alike their members. + [TestCase(false)] + [TestCase(true)] + public void AWriterOfOneSchemaNeverRetypesASameShapeRecordOfAnother(bool cacheless) + { + var path = Path.Combine(_directory, "store.cc"); + var otherRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteOther) }); + using (var seed = Open(path, false, otherRegistry)) + Assert.That(seed.Commit(batch => batch.Upsert(new NoteOther { Name = "note", Value = 1 }, new CultRecordHandle(Note))), Is.True); + var before = Fingerprint(path); + + Assert.Throws(() => WriteNarrow(path, cacheless)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + private static readonly CultDocumentRegistry NarrowShortRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow), typeof(NoteShort) }); + + // A write's ownership is the written entry's alone (variants:ruling:own-entry-reads-whole): the stored entry is named and shaped + // like Narrow but lists Short's id, and a registry that holds Short (which would read it by listing the id) changes nothing, for + // a cache and a cacheless writer alike. + [TestCase(false)] + [TestCase(true)] + public void AWrittenEntryReadingTheRecordWholeOwnsItWhateverTheRegistryHolds(bool cacheless) + { + var path = SeedRenamed(listsNarrow: false, alsoLists: NarrowShortRegistry.GetRequired().SchemaId, name: "tests.restamp_note"); + + WriteNarrow(path, cacheless, NarrowShortRegistry); + + Assert.That(Snapshot(path).Records.Single(record => record.Key == "note").SchemaId, Is.EqualTo(NarrowRegistry.GetRequired().SchemaId)); + } + + [TestCase(false)] + [TestCase(true)] + public void ACachelessWriterOwnsWhatItsRegistryOwns(bool cacheless) + { + var path = SeedRenamed(listsNarrow: false, alsoLists: NarrowShortRegistry.GetRequired().SchemaId, name: "tests.restamp_note"); + + WriteNarrow(path, cacheless, NarrowRegistry); + + Assert.That(Snapshot(path).Records.Single(record => record.Key == "note").SchemaId, Is.EqualTo(NarrowRegistry.GetRequired().SchemaId)); + } + + private static readonly CultDocumentRegistry NarrowListerRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow), typeof(NoteLister) }); + + // A store whose Narrow-shaped record sits under an id another registered type (Lister) claims: its old id, which its entry + // lists, or its current id. + private string SeedListerRecord(bool underCurrentId) + { + var path = SeedNarrow(directory: false); + var snapshot = Snapshot(path); + var listerId = NarrowListerRegistry.GetRequired().SchemaId; + var entry = snapshot.SchemaCatalog.Single(); + entry.SchemaId = entry.ContentHash = underCurrentId ? listerId : "tests.restamp.lister_published"; + entry.SchemaName = "tests.restamp_lister"; + entry.SchemaVersion = "tests.restamp_lister.v1"; + entry.CompatibleSchemaIds = underCurrentId ? new[] { listerId } : new[] { "tests.restamp.lister_published", ListerOldId }; + foreach (var record in snapshot.Records) + record.SchemaId = underCurrentId ? listerId : ListerOldId; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + return path; + } + + // A cacheless write owns a record only through a claimant it writes: another registered type that lists the record's id, or + // owns it, and reads it whole keeps the record, whatever the lent registry (or Shared, with none) registers. + // Registry null is Shared, which holds every [CultDocument] type of the test assembly. + [TestCase(false, true)] + [TestCase(false, false)] + [TestCase(true, true)] + [TestCase(true, false)] + public void ACachelessWriterNeverOwnsARecordAnotherRegisteredTypeClaims(bool underCurrentId, bool lendRegistry) + { + var path = SeedListerRecord(underCurrentId); + var before = Fingerprint(path); + + Assert.Throws(() => WriteNarrow(path, cacheless: true, lendRegistry ? NarrowListerRegistry : CultDocumentRegistry.Shared)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + // Claiming is by entry, not by schema name: a record under an id a same-named other version of the written schema lists is + // that version's, and a write of Narrow does not take it. + [Test] + public void ACachelessWriterNeverOwnsARecordASameNamedVersionClaims() + { + var path = SeedRenamed(listsNarrow: false, alsoLists: WideId); + var snapshot = Snapshot(path); + foreach (var record in snapshot.Records) + record.SchemaId = WideId; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var before = Fingerprint(path); + + Assert.Throws(() => WriteNarrow(path, cacheless: true, CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow), typeof(NoteGrownDeclaring) }))); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + private const string ListerDriftId = "tests.restamp.lister_drift"; + + // The Lister type's entry as a caller passes it: its id's own listing, or the id list the caller chooses. + private static CultSchemaCatalogEntry ListerEntry(string[] compatible, CultSchemaMemberCatalogEntry[]? members = null, string hashPrefix = "") + { + var full = NarrowListerRegistry.GetRequired().ToCatalogEntry(); + return new CultSchemaCatalogEntry + { + SchemaId = full.SchemaId, + ContentHash = hashPrefix + full.ContentHash, + SchemaName = full.SchemaName, + SchemaVersion = full.SchemaVersion, + CompatibleSchemaIds = compatible, + Members = members ?? full.Members, + CanonicalSchemaJson = full.CanonicalSchemaJson, + }; + } + + private static CultPersistedRecord ListerRecord(byte[]? payload = null) => new() + { + Key = "note", + SchemaId = NarrowListerRegistry.GetRequired().SchemaId, + StoredAt = "2026-10-22T00:00:00.0000000+00:00", + Payload = payload ?? MessagePackSerializer.Serialize(new NoteLister { Name = "note", Value = 2 }), + }; + + // The one stored entry and its records renamed to the given id, name and compatible ids (ContentHash kept), Narrow-shaped. + private string SeedListerStore(string id, string name, params string[] compatible) + { + var path = SeedRenamed(listsNarrow: false, name: name); + var snapshot = Snapshot(path); + var entry = snapshot.SchemaCatalog.Single(); + entry.SchemaId = id; + entry.CompatibleSchemaIds = compatible; + foreach (var seeded in snapshot.Records) + seeded.SchemaId = id; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + return path; + } + + // A direct WriteRecord is bound by the entry it passes, and by it alone: the entry the registry holds for the same schema id + // changes nothing, lent or not. An entry omitting the old id refuses the record; one listing it owns the record, as a cache would. + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public void ThePassedEntryAloneDecidesTheWrittenTypesIds(bool entryListsOldId, bool lend) + { + var path = SeedListerStore(ListerOldId, "tests.restamp_old", ListerOldId); + var passed = ListerEntry(entryListsOldId ? new[] { NarrowListerRegistry.GetRequired().SchemaId, ListerOldId } : new[] { NarrowListerRegistry.GetRequired().SchemaId }); + var registry = lend ? CultDocumentRegistry.ForTypes(new[] { typeof(NoteLister) }) : null; + var before = Fingerprint(path); + + if (entryListsOldId) + { + new SingleFileMessagePackBackingStore(path).WriteRecord(ListerRecord(), passed, null, registry); + Assert.That(Snapshot(path).Records.Single(stored => stored.Key == "note").SchemaId, Is.EqualTo(passed.SchemaId)); + return; + } + Assert.Throws(() => new SingleFileMessagePackBackingStore(path).WriteRecord(ListerRecord(), passed, null, registry)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + // The written type's own drift, read whole, is owned however the entry lists ids and whatever registry is lent: a registry + // that holds the written type never refuses what its own entry would own. + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public void ALentRegistryNeverRefusesTheWrittenTypesOwnDrift(bool passedListsOwnId, bool lend) + { + var path = SeedListerStore(ListerDriftId, "tests.restamp_lister", ListerDriftId); + var own = NarrowListerRegistry.GetRequired().SchemaId; + var passed = ListerEntry(passedListsOwnId ? new[] { own } : Array.Empty()); + + new SingleFileMessagePackBackingStore(path).WriteRecord(ListerRecord(), passed, null, lend ? NarrowListerRegistry : null); + + Assert.That(Snapshot(path).Records.Single(stored => stored.Key == "note").SchemaId, Is.EqualTo(own)); + } + + // A lent registry never widens the shape of the passed entry: an entry narrower than the stored one reads it only by dropping + // a slot, and the registry's wider entry for the same id is not asked. + [TestCase(false)] + [TestCase(true)] + public void ALentRegistryNeverWidensTheShapeOfThePassedEntry(bool lend) + { + var path = SeedListerStore(ListerDriftId, "tests.restamp_lister", ListerDriftId); + var full = NarrowListerRegistry.GetRequired().ToCatalogEntry(); + var passed = ListerEntry(full.CompatibleSchemaIds, full.Members.Take(1).ToArray(), "narrow-"); + var before = Fingerprint(path); + + Assert.Throws(() => + new SingleFileMessagePackBackingStore(path).WriteRecord(ListerRecord(MessagePackSerializer.Serialize(new[] { "note" })), passed, null, lend ? NarrowListerRegistry : null)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + // An unattached store's Delete then PushAll is a cacheless write that writes no entry, so nothing is declared and no written + // entry can claim the stored record: the removal is refused and the file left as it was. A call that writes nothing owns nothing. + [Test] + public void AnUnattachedStoresRemovalWithNoWrittenEntryOwnsNothing() + { + var path = SeedNarrow(directory: false); + var before = Fingerprint(path); + var descriptor = NarrowRegistry.GetRequired(); + var store = new SingleFileMessagePackBackingStore(path); + + store.Delete(new CultStoredDocument(Note, "2026-10-22T00:00:00.0000000+00:00", descriptor, new NoteNarrow { Name = "note", Value = 1 })); + + Assert.Throws(() => store.PushAll()); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + // The registry's other types change nothing about a record the written type claims itself. + [Test] + public void AnotherRegisteredTypeDoesNotTakeAWritersOwnDriftRecord() + { + var path = SeedRenamed(listsNarrow: true); + + WriteNarrow(path, cacheless: true, NarrowListerRegistry); + + Assert.That(Snapshot(path).Records.Single(record => record.Key == "note").SchemaId, Is.EqualTo(NarrowRegistry.GetRequired().SchemaId)); + } + + // What the lent registry registers beyond the written type changes nothing, in either direction: a second version of the + // written schema's name neither refuses nor widens a record the write owns by name alone. + [Test] + public void ASecondVersionOfTheWrittenNameInTheLentRegistryChangesNothing() + { + var path = SeedRenamed(listsNarrow: false, name: "tests.restamp_note"); + + WriteNarrow(path, cacheless: true, CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow), typeof(NoteGrown) })); + + Assert.That(Snapshot(path).Records.Single(record => record.Key == "note").SchemaId, Is.EqualTo(NarrowRegistry.GetRequired().SchemaId)); + } + + // A record stored under an id its catalog entry only lists (no entry owns it) is published by that entry, and the type that + // reads the entry whole owns the record: a cache and a cacheless writer alike restamp it. + [TestCase(false)] + [TestCase(true)] + public void ARecordUnderAnIdOnlyListedIsPublishedByTheEntryListingIt(bool cacheless) + { + const string listedOnly = "tests.restamp.listedonly"; + var path = SeedRenamed(listsNarrow: true, alsoLists: listedOnly); + var snapshot = Snapshot(path); + foreach (var record in snapshot.Records) + record.SchemaId = listedOnly; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + + WriteNarrow(path, cacheless); + + Assert.That(Snapshot(path).Records.Single(record => record.Key == "note").SchemaId, Is.EqualTo(NarrowRegistry.GetRequired().SchemaId)); + } + + // A raw record write names no registry, so its one entry is the claimant: it reads a renamed entry that lists its id, or has + // its schema name, and nothing else. + [TestCase(true, "tests.restamp_note_renamed", true)] + [TestCase(false, "tests.restamp_note", true)] + [TestCase(false, "tests.restamp_note_renamed", false)] + public void ARecordWriteWithNoRegistryTakesItsOneEntryAsClaimant(bool listsNarrow, string persistedName, bool owned) + { + var record = Snapshot(SeedNarrow(directory: false)).Records.Single(candidate => candidate.Key == "note"); + var path = SeedRenamed(listsNarrow, name: persistedName); + var before = Fingerprint(path); + var write = new SingleFileMessagePackBackingStore(path); + var entry = NarrowRegistry.GetRequired().ToCatalogEntry(); + + if (owned) + { + write.WriteRecord(record, entry); + Assert.That(Snapshot(path).Records.Single(candidate => candidate.Key == "note").SchemaId, Is.EqualTo(entry.SchemaId)); + } + else + { + Assert.Throws(() => write.WriteRecord(record, entry)); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + } + + // An id no catalog entry publishes belongs to nobody a writer can name, so no writer owns it. A store file cannot hold such a + // record (ReadStore refuses it), so the rule is pinned where it is decided, for a cacheless writer's lookup. + [Test] + public void AnUnpublishedIdIsOwnedByNoOne() + { + var narrow = NarrowRegistry.GetRequired().ToCatalogEntry(); + var writes = new[] { narrow }; + Func declares = id => id == narrow.SchemaId; + + Assert.That(CultDocumentRegistry.Owns("tests.restamp.unpublished", writes, declares, new CatalogEntryLookup(writes), entry => entry), Is.False); + Assert.That(CultDocumentRegistry.Owns(narrow.SchemaId, writes, declares, new CatalogEntryLookup(writes), entry => entry), Is.True); + } + + private static string VectorPath(string name) + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var vectors = Path.Combine(dir.FullName, "tests", "vectors", "ref-list-drift"); + if (Directory.Exists(vectors)) return Path.Combine(vectors, name); + } + + throw new DirectoryNotFoundException($"tests/vectors/ref-list-drift not found above {TestContext.CurrentContext.TestDirectory}."); + } + + // The store the 1.0.60 writer wrote: its 'list' holder compares compatible with RefListHolder, gaining reference semantics + // on a slot, and ignores no persisted slot, so RefListHolder reads it whole and may write it. + [Test] + public async Task TheCommitted1060VectorsListRecordIsWrittenUnderTheHoldersOwnId() + { + var path = Path.Combine(_directory, "vector.cc"); + File.Copy(VectorPath("v1-0-60-written.cc"), path); + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(RefListDriftTests.RefListTarget), typeof(RefListDriftTests.RefListHolder) }); + var holderId = registry.GetRequired().SchemaId; + var before = Snapshot(path); + Assert.That(before.Records.Single(record => record.Key == "list").SchemaId, Is.Not.EqualTo(holderId), "the vector's list is under an older id"); + + using (var cache = new CultCache(registry)) + { + cache.AddBackingStore(new SingleFileMessagePackBackingStore(path)); + await cache.PullAllBackingStoresAsync(); + var holder = cache.GetAll().Single(); + Assert.That(cache.Commit(batch => batch.Upsert( + new RefListDriftTests.RefListHolder { Name = holder.Name, Items = holder.Items.Reverse().ToArray() }, + new CultRecordHandle(new CultRecordKey("list")))), Is.True); + } + + var after = Snapshot(path); + Assert.That(after.Records.Single(record => record.Key == "list").SchemaId, Is.EqualTo(holderId)); + Assert.That(after.SchemaCatalog.Single(entry => entry.SchemaId == holderId).Members.Single(member => member.Slot == 1).IsReference, Is.True); + foreach (var kept in new[] { "alpha", "beta" }) + { + Assert.That(after.Records.Single(record => record.Key == kept).SchemaId, Is.EqualTo(before.Records.Single(record => record.Key == kept).SchemaId), kept + " keeps its persisted id"); + Assert.That(after.Records.Single(record => record.Key == kept).Payload, Is.EqualTo(before.Records.Single(record => record.Key == kept).Payload), kept + " keeps its bytes"); + } + + using var reopened = new CultCache(registry); + reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path)); + await reopened.PullAllBackingStoresAsync(); + Assert.That(reopened.GetAll().Single().Items.Select(item => item.Key.Value).ToArray(), Is.EqualTo(new[] { "beta", "alpha" })); + } + + private static CultPersistedStoreSnapshot Snapshot(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + } +} diff --git a/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs b/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs new file mode 100644 index 00000000..9e924d81 --- /dev/null +++ b/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs @@ -0,0 +1,659 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; +using R3; + +namespace GameCult.Caching.Tests +{ + // variants:ruling:one-batch-retype-is-eviction-then-add: a batch that removes a key and adds a record there stages the held + // record's eviction and then the add, so watchers, the batch validators, the store judges and the removal bookkeeping see what they + // see when the two are staged apart (cache.Remove, then UpsertAsync). Where a test runs both shapes it asserts the same answer. + public class RetypeEvictionTests + { + [CultDocument("tests.ev_a", "tests.ev_a.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class EvA + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + [CultDocument("tests.ev_b", "tests.ev_b.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class EvB + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + // A type only the second writer registers. + [CultDocument("tests.ev_c", "tests.ev_c.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class EvC + { + [Key(0)] [CultName] public string Name = string.Empty; + } + + [CultDocument("tests.ev_g", "tests.ev_g.v1")] + [MessagePackObject(AllowPrivate = true)] + [CultGlobal] + internal sealed class EvG + { + [Key(0)] public string Value = string.Empty; + } + + // RbX's schema id, which RbY lists. The test below asserts it, so a changed derivation fails there. + private const string RbXId = "sha256:25087e0d76fd5b9ac950723ccd47f3f3ac1428b53b49ca9bda9aae8b38a0220a"; + + // The rebase pair: RbY lists RbX's id compatible, so a plain write of RbY owns an RbX record; RbZ lists nothing. + [CultDocument("tests.rb_x", "tests.rb_x.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RbX + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + [CultDocument("tests.rb_y", "tests.rb_y.v1", CompatibleSchemaIds = new[] { RbXId })] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RbY + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + [CultDocument("tests.rb_z", "tests.rb_z.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RbZ + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + private static readonly CultDocumentRegistry AB = CultDocumentRegistry.ForTypes(new[] { typeof(EvA), typeof(EvB), typeof(EvG) }); + private static readonly CultDocumentRegistry ABC = CultDocumentRegistry.ForTypes(new[] { typeof(EvA), typeof(EvB), typeof(EvC), typeof(EvG) }); + private static readonly CultDocumentRegistry Rb = CultDocumentRegistry.ForTypes(new[] { typeof(RbX), typeof(RbY), typeof(RbZ) }); + private static readonly CultRecordKey K1 = new("k1"); + private static readonly CultRecordKey K2 = new("k2"); + private static readonly CultRecordKey K3 = new("k3"); + + // The stores a retype is judged in, and no store at all. The directory store holds no variant, so variant tests skip it. + public static readonly object[] Kinds = { "single-file", "directory", "storeless" }; + public static readonly object[] VariantKinds = { "single-file", "storeless" }; + + private string _directory = string.Empty; + private int _paths; + + [SetUp] + public void CreateDirectory() + { + _directory = Path.Combine(Path.GetTempPath(), $"cultlib-retypeeviction-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + + // A fresh store path per call, so one test can run a scenario twice. + private string NewPath(string kind) => Path.Combine(_directory, $"{kind}-{_paths++}", kind == "directory" ? "store" : "store.cc"); + + private static CultCache Open(string path, string kind, CultDocumentRegistry registry) + { + var cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry)); + if (kind != "storeless") + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + cache.AddBackingStore(kind == "directory" ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path)); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + } + + return cache; + } + + private static Type TypeAt(string path, string kind, CultDocumentRegistry registry, CultRecordKey key) + { + using var cache = Open(path, kind, registry); + return cache.GetStored(key)!.Descriptor.DocumentType; + } + + // Retypes a held key as one batch (Remove, then the add the caller stages) or staged apart (cache.Remove, then the add's own call). + private static void Retype(CultCache cache, bool oneBatch, CultRecordKey key, Action add, Action apart) + { + if (oneBatch) + Assert.That(cache.Commit(batch => + { + batch.Remove(key); + add(batch); + }), Is.True); + else + { + Assert.That(cache.Remove(key), Is.True); + apart(); + } + } + + private static void PutB(CultCache cache, CultRecordKey key, int value = 2) => + Assert.That(cache.Commit(batch => batch.Upsert(typeof(EvB), new EvB { Name = key.Value, Value = value }, key)), Is.True); + + // finding retype-not-an-eviction: Watch sees Removed and Watch sees Added, in a batch and staged apart alike. + [TestCaseSource(nameof(Kinds))] + public void ARetypeIsRemovedForTheOldTypeAndAddedForTheNewWhetherOneBatchOrStagedApart(string kind) + { + foreach (var oneBatch in new[] { true, false }) + { + using var cache = Open(NewPath(kind), kind, AB); + PutB(cache, K2); + var removedLog = new List(); + var addedLog = new List(); + using var oldWatch = cache.Watch().Subscribe(change => removedLog.Add(change.Kind)); + using var newWatch = cache.Watch().Subscribe(change => addedLog.Add(change.Kind)); + + Retype(cache, oneBatch, K2, + batch => batch.Upsert(typeof(EvA), new EvA { Name = "k2", Value = 9 }, K2), + () => cache.UpsertAsync(typeof(EvA), new EvA { Name = "k2", Value = 9 }, K2).GetAwaiter().GetResult()); + + Assert.That(removedLog, Is.EqualTo(new[] { CultCacheDocumentChangeKind.Removed }), $"Watch, one batch {oneBatch}"); + Assert.That(addedLog, Is.EqualTo(new[] { CultCacheDocumentChangeKind.Added }), $"Watch, one batch {oneBatch}"); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvA))); + } + } + + // finding retype-not-an-eviction, global key, and finding global-eviction-uncovered: evicting a global frees its slot, and the + // batch that retypes a global's key may land the next global of its type elsewhere, as staged apart. + [TestCaseSource(nameof(Kinds))] + public void EvictingAGlobalFreesItsSlotAndAGlobalKeyRetypesInOneBatch(string kind) + { + using var cache = Open(NewPath(kind), kind, AB); + var g1 = new CultRecordKey("g1"); + var g2 = new CultRecordKey("g2"); + Assert.That(cache.Commit(batch => batch.Upsert(typeof(EvG), new EvG { Value = "one" }, g1)), Is.True); + + Assert.Throws( + () => cache.Commit(batch => batch.Upsert(typeof(EvG), new EvG { Value = "two" }, g2)), "a second global while the first stands is refused"); + + Assert.That(cache.Commit(batch => + { + batch.Remove(g1); + batch.Upsert(typeof(EvG), new EvG { Value = "two" }, g2); + }), Is.True, "the eviction freed the slot"); + Assert.That(cache.GetGlobal()!.Value, Is.EqualTo("two")); + Assert.That(cache.GetStored(g1), Is.Null); + + Assert.That(cache.Commit(batch => + { + batch.Remove(g2); + batch.Upsert(typeof(EvA), new EvA { Name = "g2", Value = 1 }, g2); + batch.Upsert(typeof(EvG), new EvG { Value = "three" }, g1); + }), Is.True, "staged apart the retype of the global's key and the next global commit"); + Assert.That(cache.GetStored(g2)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvA))); + Assert.That(cache.GetGlobal()!.Value, Is.EqualTo("three")); + } + + // finding retype-not-an-eviction (variant key) and variant-ops-drop-the-removal: a variant key retypes to a plain record of + // another type, a plain key becomes a variant of another type's base, and a key removed, added and then added as a variant + // lands as the variant, each answering as it does staged apart. + [TestCaseSource(nameof(VariantKinds))] + public void VariantKeysRetypeInOneBatchAsStagedApart(string kind) + { + foreach (var oneBatch in new[] { true, false }) + { + using var cache = Open(NewPath(kind), kind, AB); + var baseA = new CultRecordKey("base-a"); + Assert.That(cache.Commit(batch => + { + batch.Upsert(typeof(EvA), new EvA { Name = "base-a", Value = 1 }, baseA); + batch.UpsertVariant(K3, baseA, new[] { cache.Override(nameof(EvA.Name), "v3") }); + batch.Upsert(typeof(EvB), new EvB { Name = "k2", Value = 2 }, K2); + batch.Upsert(typeof(EvB), new EvB { Name = "k1", Value = 2 }, K1); + }), Is.True); + + Retype(cache, oneBatch, K3, + batch => batch.Upsert(typeof(EvB), new EvB { Name = "k3", Value = 3 }, K3), + () => cache.UpsertAsync(typeof(EvB), new EvB { Name = "k3", Value = 3 }, K3).GetAwaiter().GetResult()); + Assert.That(cache.GetStored(K3)!.Variant, Is.Null, $"one batch {oneBatch}"); + Assert.That(cache.GetStored(K3)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvB))); + + Retype(cache, oneBatch, K2, + batch => batch.UpsertVariant(K2, baseA, new[] { cache.Override(nameof(EvA.Name), "v2") }), + () => cache.UpsertVariantAsync(K2, baseA, new[] { cache.Override(nameof(EvA.Name), "v2") }).GetAwaiter().GetResult()); + Assert.That(cache.GetStored(K2)!.Variant?.BaseKey, Is.EqualTo(baseA.Value), $"one batch {oneBatch}"); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvA))); + + // Removed and then added as a variant (in one batch, with a plain add of the same type between): the variant lands. + Retype(cache, oneBatch, K1, + batch => + { + batch.Upsert(typeof(EvA), new EvA { Name = "k1", Value = 2 }, K1); + batch.UpsertVariant(K1, baseA, new[] { cache.Override(nameof(EvA.Name), "v1") }); + }, + () => cache.UpsertVariantAsync(K1, baseA, new[] { cache.Override(nameof(EvA.Name), "v1") }).GetAwaiter().GetResult()); + Assert.That(cache.GetStored(K1)!.Variant?.BaseKey, Is.EqualTo(baseA.Value), $"one batch {oneBatch}"); + } + } + + [TestCaseSource(nameof(VariantKinds))] + public void AFlattenAfterARemovalFlattensNothingInOneBatchAndStagedApart(string kind) + { + using var cache = Open(NewPath(kind), kind, AB); + var baseA = new CultRecordKey("base-a"); + cache.Commit(batch => + { + batch.Upsert(typeof(EvA), new EvA { Name = "base-a", Value = 1 }, baseA); + batch.UpsertVariant(K3, baseA, new[] { cache.Override(nameof(EvA.Name), "v3") }); + }); + + var flatten = Assert.Throws(() => cache.Commit(batch => + { + batch.Remove(K3); + batch.Flatten(K3); + }))!; + Assert.That(flatten.Message, Does.Contain("Flatten(k3)").And.Contain("not a variant")); + Assert.That(cache.GetStored(K3)!.Variant, Is.Not.Null, "the refused batch changed nothing"); + Assert.That(cache.Remove(K3), Is.True); + Assert.Throws(() => cache.FlattenAsync(K3).GetAwaiter().GetResult()); + } + + // finding flatten-guard-reads-removals: a Flatten acts on what the key will hold, as staged apart. A variant staged at the key + // (after a removal or not) is flattened as it lands; a plain record staged there leaves nothing to flatten. + [TestCaseSource(nameof(VariantKinds))] + public void AFlattenOfAVariantStagedInTheSameBatchFlattensItAsStagedApart(string kind) + { + foreach (var oneBatch in new[] { true, false }) + { + using var cache = Open(NewPath(kind), kind, AB); + var baseA = new CultRecordKey("base-a"); + Assert.That(cache.Commit(batch => + { + batch.Upsert(typeof(EvA), new EvA { Name = "base-a", Value = 1 }, baseA); + batch.UpsertVariant(K3, baseA, new[] { cache.Override(nameof(EvA.Name), "v3") }); + batch.Upsert(typeof(EvB), new EvB { Name = "k1", Value = 2 }, K1); + }), Is.True); + + // Remove, UpsertVariant, Flatten: the plain record of the variant's resolution, retyped. + if (oneBatch) + Assert.That(cache.Commit(batch => + { + batch.Remove(K1); + batch.UpsertVariant(K1, baseA, new[] { cache.Override(nameof(EvA.Name), "v1") }); + batch.Flatten(K1); + }), Is.True); + else + { + Assert.That(cache.Remove(K1), Is.True); + cache.UpsertVariantAsync(K1, baseA, new[] { cache.Override(nameof(EvA.Name), "v1") }).GetAwaiter().GetResult(); + cache.FlattenAsync(K1).GetAwaiter().GetResult(); + } + + var flattened = cache.GetStored(K1)!; + Assert.That(flattened.Variant, Is.Null, $"one batch {oneBatch}"); + Assert.That(flattened.Descriptor.DocumentType, Is.EqualTo(typeof(EvA))); + Assert.That(((EvA)flattened.Document).Name, Is.EqualTo("v1")); + + // A held variant rebased and flattened in one batch flattens the rebase, not the held record. + if (oneBatch) + Assert.That(cache.Commit(batch => + { + batch.UpsertVariant(K3, baseA, new[] { cache.Override(nameof(EvA.Name), "v3b") }); + batch.Flatten(K3); + }), Is.True); + else + { + cache.UpsertVariantAsync(K3, baseA, new[] { cache.Override(nameof(EvA.Name), "v3b") }).GetAwaiter().GetResult(); + cache.FlattenAsync(K3).GetAwaiter().GetResult(); + } + + Assert.That(cache.GetStored(K3)!.Variant, Is.Null, $"one batch {oneBatch}"); + Assert.That(((EvA)cache.GetStored(K3)!.Document).Name, Is.EqualTo("v3b")); + } + } + + [TestCaseSource(nameof(VariantKinds))] + public void AFlattenOfAPlainRecordStagedInTheSameBatchIsRefusedAsStagedApart(string kind) + { + using var cache = Open(NewPath(kind), kind, AB); + PutB(cache, K2); + + var refused = Assert.Throws(() => cache.Commit(batch => + { + batch.Remove(K2); + batch.Upsert(typeof(EvA), new EvA { Name = "k2" }, K2); + batch.Flatten(K2); + }))!; + Assert.That(refused.Message, Does.Contain("Flatten(k2)").And.Contain("not a variant")); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvB)), "the refused batch changed nothing"); + + Assert.That(cache.Remove(K2), Is.True); + cache.UpsertAsync(typeof(EvA), new EvA { Name = "k2" }, K2).GetAwaiter().GetResult(); + Assert.Throws(() => cache.FlattenAsync(K2).GetAwaiter().GetResult()); + } + + // finding flatten-after-staged-plain-unpinned: where the key held a variant, Remove, a plain Upsert, then Flatten is refused + // as staged apart, and the plain write is never dropped for a flatten of the variant the cache held. + [TestCaseSource(nameof(VariantKinds))] + public void AFlattenAfterARemovalAndAPlainWriteOnAHeldVariantIsRefusedAndKeepsTheHeldVariant(string kind) + { + using var cache = Open(NewPath(kind), kind, AB); + var baseA = new CultRecordKey("base-a"); + cache.Commit(batch => + { + batch.Upsert(typeof(EvA), new EvA { Name = "base-a", Value = 1 }, baseA); + batch.UpsertVariant(K3, baseA, new[] { cache.Override(nameof(EvA.Name), "v3") }); + }); + + var refused = Assert.Throws(() => cache.Commit(batch => + { + batch.Remove(K3); + batch.Upsert(typeof(EvA), new EvA { Name = "plain" }, K3); + batch.Flatten(K3); + }))!; + Assert.That(refused.Message, Does.Contain("Flatten(k3)").And.Contain("not a variant")); + Assert.That(cache.GetStored(K3)!.Variant, Is.Not.Null, "the refused batch changed nothing"); + + Assert.That(cache.Remove(K3), Is.True); + cache.UpsertAsync(typeof(EvA), new EvA { Name = "plain" }, K3).GetAwaiter().GetResult(); + var apart = Assert.Throws(() => cache.FlattenAsync(K3).GetAwaiter().GetResult())!; + Assert.That(apart.Message, Is.EqualTo(refused.Message)); + Assert.That(((EvA)cache.GetStored(K3)!.Document).Name, Is.EqualTo("plain")); + } + + // finding op-after-staged-flatten-diverges: an operation after a Flatten at the same key acts on the plain record that flatten + // leaves, so one intent has one answer in a batch and staged apart, in both stores kinds and after a reload from disk. + [TestCaseSource(nameof(VariantKinds))] + public void AnOperationAfterAStagedFlattenAnswersAsStagedApart(string kind) + { + var scenarios = new[] { "F,F", "F,P", "F,V", "V,F,V", "F,P,F", "V,F,P", "V,F,F" }; + foreach (var scenario in scenarios) + { + var steps = scenario.Split(','); + var results = new List(); + foreach (var oneBatch in new[] { true, false }) + { + var path = NewPath(kind); + string outcome; + using (var cache = Open(path, kind, AB)) + { + var baseA = new CultRecordKey("base-a"); + cache.Commit(batch => + { + batch.Upsert(typeof(EvA), new EvA { Name = "base-a", Value = 1 }, baseA); + batch.UpsertVariant(K3, baseA, new[] { cache.Override(nameof(EvA.Name), "v3") }); + }); + var variant = new[] { cache.Override(nameof(EvA.Name), "vv") }; + + try + { + if (oneBatch) + cache.Commit(batch => + { + foreach (var step in steps) + { + if (step == "F") batch.Flatten(K3); + else if (step == "P") batch.Upsert(typeof(EvA), new EvA { Name = "plain" }, K3); + else batch.UpsertVariant(K3, baseA, variant); + } + }); + else + foreach (var step in steps) + { + if (step == "F") cache.FlattenAsync(K3).GetAwaiter().GetResult(); + else if (step == "P") cache.UpsertAsync(typeof(EvA), new EvA { Name = "plain" }, K3).GetAwaiter().GetResult(); + else cache.UpsertVariantAsync(K3, baseA, variant).GetAwaiter().GetResult(); + } + outcome = "committed"; + } + catch (InvalidOperationException refused) + { + outcome = "refused: " + refused.Message; + } + + outcome += " | " + Describe(cache); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + if (kind != "storeless" && outcome.StartsWith("committed", StringComparison.Ordinal)) + { + using var reloaded = Open(path, kind, AB); + outcome += " | reloaded " + Describe(reloaded); + } + + results.Add(outcome); + } + + // A refused batch changes nothing, while staged apart the steps before the refused one have landed: the answer compared + // is the verdict and its text, and the end state only where both committed. + var batchAnswer = results[0].Split(" | ")[0]; + var apartAnswer = results[1].Split(" | ")[0]; + Assert.That(batchAnswer, Is.EqualTo(apartAnswer), $"{kind} {scenario}"); + if (batchAnswer == "committed") + Assert.That(results[0], Is.EqualTo(results[1]), $"{kind} {scenario}"); + } + + static string Describe(CultCache cache) + { + var held = cache.GetStored(K3)!; + return $"{held.Descriptor.DocumentType.Name} variant={held.Variant?.BaseKey} name={((EvA)held.Document).Name}"; + } + } + + // finding cross-store-retype-decided-by-key-home: a one-batch retype between two home stores is refused by the rule that a commit + // lands in one home store, with that rule's text (an evicted key is absent to the one-key-one-store check, as when staged + // apart); staged apart, as two commits, it leaves the key in exactly one store. + [Test] + public void ARetypeAcrossTwoHomeStoresInOneBatchIsRefusedByTheBatchSpanningRuleAndStagedApartLeavesOneStore() + { + string[] KeysIn(string path) => + new SingleFileMessagePackBackingStore(path).ReadStoreSnapshot()?.Records.Select(record => record.Key).ToArray() ?? Array.Empty(); + + CultCache OpenTwo(string a, string b) + { + var cache = new CultCache(AB, CultCacheMessagePack.CreateCodec(AB)); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(a), typeof(EvA)); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(b), typeof(EvB)); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + return cache; + } + + var pathA = NewPath("single-file"); + var pathB = NewPath("single-file"); + Directory.CreateDirectory(Path.GetDirectoryName(pathA)!); + Directory.CreateDirectory(Path.GetDirectoryName(pathB)!); + using (var cache = OpenTwo(pathA, pathB)) + { + PutB(cache, K1); + Assert.That(KeysIn(pathB), Is.EqualTo(new[] { "k1" })); + + var refused = Assert.Throws(() => cache.Commit(batch => + { + batch.Remove(K1); + batch.Upsert(typeof(EvA), new EvA { Name = "k1" }, K1); + }))!; + Assert.That(refused.Message, Does.Contain("The batch spans").And.Contain("a commit lands in one home store") + .And.Contain("stage its removal and its add apart").And.Not.Contain("second store")); + Assert.That(KeysIn(pathA), Is.Empty, "the refused batch wrote nothing"); + Assert.That(KeysIn(pathB), Is.EqualTo(new[] { "k1" })); + Assert.That(cache.GetStored(K1)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvB))); + + Assert.That(cache.Remove(K1), Is.True); + cache.FlushAsync().GetAwaiter().GetResult(); + cache.UpsertAsync(typeof(EvA), new EvA { Name = "k1" }, K1).GetAwaiter().GetResult(); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + Assert.That(KeysIn(pathA), Is.EqualTo(new[] { "k1" }), "staged apart the key is in the add's store"); + Assert.That(KeysIn(pathB), Is.Empty, "and no longer in the removal's"); + } + + // finding removal-bookkeeping-unpinned, mutant X2(a retype elsewhere in the batch skips every key's staging claim): a batch + // holding two retypes commits, and a cross-type add at a key it did not remove is still refused beside a retype. + [TestCaseSource(nameof(Kinds))] + public void EachKeyOfABatchIsJudgedByItsOwnRemoval(string kind) + { + using var cache = Open(NewPath(kind), kind, AB); + PutB(cache, K1); + PutB(cache, K2); + PutB(cache, K3); + + Assert.That(cache.Commit(batch => + { + batch.Remove(K1); + batch.Upsert(typeof(EvA), new EvA { Name = "k1" }, K1); + batch.Remove(K2); + batch.Upsert(typeof(EvA), new EvA { Name = "k2" }, K2); + }), Is.True, "two retypes"); + + var bad = Assert.Throws(() => cache.Commit(batch => + { + batch.Remove(K1); + batch.Upsert(typeof(EvB), new EvB { Name = "k1" }, K1); + batch.Upsert(typeof(EvA), new EvA { Name = "k3" }, K3); + }))!; + Assert.That(bad.RecordKey, Is.EqualTo(K3.Value), "one key's removal licenses no other key's retype"); + Assert.That(cache.GetStored(K3)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvB))); + Assert.That(cache.GetStored(K1)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvA)), "the refused batch changed nothing"); + + Assert.That(cache.Commit(batch => + { + batch.Upsert(typeof(EvB), new EvB { Name = "k3", Value = 5 }, K3); + batch.Remove(K3); + }), Is.True, "an add and then a removal removes"); + Assert.That(cache.GetStored(K3), Is.Null); + } + + // finding removal-bookkeeping-unpinned, mutant X13 (any second add counts as a replacement): two adds of one batch with no + // removal are an upsert, judged against what the key holds. + [TestCaseSource(nameof(Kinds))] + public void TwoAddsWithNoRemovalAreAnUpsert(string kind) + { + using var cache = Open(NewPath(kind), kind, AB); + PutB(cache, K2); + + var refused = Assert.Throws(() => cache.Commit(batch => + { + batch.Upsert(typeof(EvA), new EvA { Name = "k2", Value = 1 }, K2); + batch.Upsert(typeof(EvA), new EvA { Name = "k2", Value = 2 }, K2); + }))!; + if (kind == "storeless") + Assert.That(refused.Message, Does.Contain("would retype the key").And.Contain("Remove the record first, in the same batch or an earlier commit, then add the other type's.")); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvB))); + } + + // finding removal-bookkeeping-unpinned, mutant X18 (no removal noted for a record this cache wrote itself): a cache that wrote + // the key itself, then removes it and stages another type there, lands that type at the flush. + [TestCaseSource(nameof(Kinds))] + public void ARecordThisCacheWroteItselfRetypesStagedApart(string kind) + { + var path = NewPath(kind); + using (var cache = Open(path, kind, AB)) + { + PutB(cache, K2); + cache.FlushAllBackingStores(); + Assert.That(cache.Remove(K2), Is.True); + cache.UpsertAsync(typeof(EvA), new EvA { Name = "k2", Value = 9 }, K2).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + } + + if (kind != "storeless") + Assert.That(TypeAt(path, kind, AB, K2), Is.EqualTo(typeof(EvA))); + } + + // finding removal-bookkeeping-unpinned, mutant M (a refusal keeps the staged removal): a removal the store refused is gone, so + // a later add at a key another writer has since rewritten is judged as an upsert, not as the stale removal. + [TestCaseSource(nameof(Kinds))] + public void ARefusedRemovalIsNotReusedByALaterAdd(string kind) + { + if (kind == "storeless") + Assert.Ignore("a refusal is a store's."); + var path = NewPath(kind); + using (var seed = Open(path, kind, ABC)) + PutB(seed, K1); + + using var cache = Open(path, kind, AB); + using var other = Open(path, kind, ABC); + Assert.That(cache.Remove(K1), Is.True); + + // Another writer retypes the key to a type this cache does not know: the cache's staged removal meets a record it may not remove. + Assert.That(other.Commit(batch => + { + batch.Remove(K1); + batch.Upsert(typeof(EvC), new EvC { Name = "k1" }, K1); + }), Is.True); + Assert.Throws(() => cache.FlushAllBackingStores()); + + // The writer removes it again and puts a B there; this cache pulls and then stages an A with no removal in front of it. + Assert.That(other.Commit(batch => batch.Remove(K1)), Is.True); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + Assert.That(other.Commit(batch => batch.Upsert(typeof(EvB), new EvB { Name = "k1", Value = 7 }, K1)), Is.True); + cache.UpsertAsync(typeof(EvA), new EvA { Name = "k1", Value = 8 }, K1).GetAwaiter().GetResult(); + + Assert.Throws(() => cache.FlushAllBackingStores()); + Assert.That(TypeAt(path, kind, AB, K1), Is.EqualTo(typeof(EvB))); + } + + // finding removal-bookkeeping-unpinned, mutant X9 (a refusal clears every staged removal): a refusal of one key leaves another + // key's staged retype, and the next flush lands it. + [TestCaseSource(nameof(Kinds))] + public void ARefusedFlushKeepsAnotherKeysStagedRetype(string kind) + { + if (kind == "storeless") + Assert.Ignore("a refusal is a store's."); + var path = NewPath(kind); + using (var seed = Open(path, kind, ABC)) + PutB(seed, K2); + + using var cache = Open(path, kind, AB); + using var other = Open(path, kind, ABC); + Assert.That(cache.Remove(K2), Is.True); + cache.UpsertAsync(typeof(EvA), new EvA { Name = "k2", Value = 9 }, K2).GetAwaiter().GetResult(); + cache.UpsertAsync(typeof(EvA), new EvA { Name = "k1", Value = 1 }, K1).GetAwaiter().GetResult(); + + // Another writer puts a type this cache does not know at k1 before the flush: k1's staged add is refused under the lock. + Assert.That(other.Commit(batch => batch.Upsert(typeof(EvC), new EvC { Name = "k1" }, K1)), Is.True); + var refusal = Assert.Throws(() => cache.FlushAllBackingStores())!; + Assert.That(refusal.RecordKey, Is.EqualTo(K1.Value)); + cache.FlushAllBackingStores(); + + Assert.That(TypeAt(path, kind, ABC, K2), Is.EqualTo(typeof(EvA)), "the retype of k2 survived the refusal of k1"); + } + + // finding rebase-check-was-reachable, ruling variant-rebase-follows-plain-write: a variant rebased onto a base of another type + // follows the plain-write rule. Where RbY lists RbX's id (a plain write of RbY would own an RbX record) the rebase commits and + // the variant becomes an RbY; where it would be refused (RbZ) the rebase is refused and the variant is untouched. + [TestCaseSource(nameof(VariantKinds))] + public void ARebaseChangesTypeExactlyWhereAPlainWriteMay(string kind) + { + Assert.That(Rb.GetRequired(typeof(RbX)).SchemaId, Is.EqualTo(RbXId)); + using var cache = Open(NewPath(kind), kind, Rb); + var baseX = new CultRecordKey("base-x"); + var baseY = new CultRecordKey("base-y"); + var baseZ = new CultRecordKey("base-z"); + var variant = new CultRecordKey("v"); + Assert.That(cache.Commit(batch => + { + batch.Upsert(typeof(RbX), new RbX { Name = "base-x" }, baseX); + batch.Upsert(typeof(RbY), new RbY { Name = "base-y" }, baseY); + batch.Upsert(typeof(RbZ), new RbZ { Name = "base-z" }, baseZ); + batch.UpsertVariant(variant, baseX, new[] { cache.Override(nameof(RbX.Name), "v") }); + }), Is.True); + + // The plain write of the same types gives the same two answers. + Assert.Throws(() => cache.Commit(batch => batch.Upsert(typeof(RbZ), new RbZ { Name = "base-x" }, baseX))); + Assert.Throws( + () => cache.Commit(batch => batch.UpsertVariant(variant, baseZ, new[] { cache.Override(nameof(RbZ.Name), "v") }))); + Assert.That(cache.GetStored(variant)!.Descriptor.DocumentType, Is.EqualTo(typeof(RbX))); + Assert.That(cache.GetStored(variant)!.Variant!.BaseKey, Is.EqualTo(baseX.Value)); + + Assert.That(cache.Commit(batch => batch.UpsertVariant(variant, baseY, new[] { cache.Override(nameof(RbY.Name), "v") })), Is.True); + Assert.That(cache.GetStored(variant)!.Descriptor.DocumentType, Is.EqualTo(typeof(RbY))); + Assert.That(cache.GetStored(variant)!.Variant!.BaseKey, Is.EqualTo(baseY.Value)); + } + } +} diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs new file mode 100644 index 00000000..4aef72a1 --- /dev/null +++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs @@ -0,0 +1,693 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; +using static GameCult.Caching.Tests.ElementIdTests; + +namespace GameCult.Caching.Tests +{ + // A file is replaced by a flush or a commit exactly when this runtime's own reader opens it: one verdict per file, asked by + // open, flush and commit alike. The bytes and the verdict of every runtime are shared: tests/vectors/document-variants-c2a/readability. + public class StoreReadabilityTests + { + private const int CSharp = 1; + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck), typeof(VectorItem) }); + private string _directory = ""; + + [SetUp] + public void SetUp() + { + _directory = Path.Combine(Path.GetTempPath(), "cultlib-readability-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void TearDown() + { + if (!Directory.Exists(_directory)) + return; + // A recursive delete cannot remove a link whose target is gone; take the links out by themselves first. + foreach (var entry in Directory.EnumerateFileSystemEntries(_directory).Where(entry => IsALink(entry))) + { + if ((new FileInfo(entry).Attributes & FileAttributes.Directory) != 0) + Directory.Delete(entry, false); + else + File.Delete(entry); + } + + Directory.Delete(_directory, true); + } + + private static string VectorRoot() + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c2a", "readability"); + if (Directory.Exists(candidate)) + return candidate; + } + + throw new DirectoryNotFoundException($"Shared readability vectors not found above {TestContext.CurrentContext.TestDirectory}."); + } + + public static IEnumerable Vectors() => + File.ReadAllLines(Path.Combine(VectorRoot(), "manifest.txt")) + .Where(line => line.Length > 0 && line[0] != '#') + .Select(line => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + .Select(cells => new TestCaseData(cells[0], cells[CSharp] == "reads").SetName($"{cells[0]} {cells[CSharp]}")); + + private static bool HoldsAVariant(byte[] bytes) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes).Records.Any(record => record.Variant != null); + + private static string Header(string path) => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion; + + private string Seed(string name) + { + var path = Path.Combine(_directory, name); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "d" }, new CultRecordKey("d"))); + return path; + } + + [TestCaseSource(nameof(Vectors))] + public void OpenReadsExactlyTheFilesTheVectorsSayItReads(string vector, bool reads) + { + var path = Path.Combine(_directory, "open.cc"); + File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), vector))); + + void Open() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }).Dispose(); + + if (reads) + Assert.DoesNotThrow(Open); + else + Assert.That(Open, Throws.TypeOf().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path)); + } + + [TestCaseSource(nameof(Vectors))] + public void AFlushReplacesAFileExactlyWhenItReads(string vector, bool reads) + { + var path = Seed("flush.cc"); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); + File.WriteAllBytes(path, bytes); + var store = cache.BackingStores[0]; + + if (reads && HoldsAVariant(bytes)) + { + // The cache has read nothing of this file, and a variant resolves against records it has not read. + Assert.That(() => store.PushAll(), Throws.TypeOf()); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a write into a store holding a variant this cache never read changed it"); + return; + } + + if (reads) + { + store.PushAll(); + // A flush applies its staged writes (none) to the file: it cannot show that a record it did not read holds no + // element id, so the file keeps the header it carries. + Assert.That(Header(path), Is.EqualTo(Header(Path.Combine(VectorRoot(), vector)))); + return; + } + + Assert.That(() => store.PushAll(), Throws.TypeOf()); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten"); + } + + [TestCaseSource(nameof(Vectors))] + public void AnUnconditionalCommitReplacesAFileExactlyWhenItReads(string vector, bool reads) + { + var path = Seed("commit.cc"); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); + File.WriteAllBytes(path, bytes); + + if (reads && HoldsAVariant(bytes)) + { + Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf()); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a write into a store holding a variant this cache never read changed it"); + return; + } + + if (reads) + { + cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))); + Assert.That(Header(path), Is.EqualTo(Header(Path.Combine(VectorRoot(), vector))), + "the commit wrote a record without ids and copied the file's records, which it cannot show hold none"); + return; + } + + Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf()); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten"); + } + + private string DirectoryStore(string name) + { + var path = Path.Combine(_directory, name); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true }); + cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "d" }, new CultRecordKey("d"))); + return path; + } + + // The directory store's refusal names the manifest it refused, as the single-file store names its file. + [Test] + public void AnUnreadableDirectoryManifestIsRefusedNamingIt() + { + var path = DirectoryStore("manifest.cc"); + File.WriteAllBytes(path, new byte[] { 0x01 }); + + Assert.That( + () => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true }), + Throws.TypeOf().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path)); + } + + private static byte[] StoreWithHeader(string header) => + CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot + { + FormatVersion = header, + SchemaCatalog = Array.Empty(), + Records = Array.Empty() + }); + + // A refusal shows a stored header only as cultcache.store.v followed by ASCII digits; any other header is described by its + // length, so what the file says never reaches a log. A single-file store and a directory manifest are refused alike. + [TestCase("cultcache.store.v9", true, false)] + [TestCase("cultcache.store.SECRETHDR", false, false)] + [TestCase("cultcache.store.v", false, false)] + [TestCase("cultcache.store.vÙ£", false, false)] + [TestCase("cultcache.store.v9", true, true)] + [TestCase("cultcache.store.SECRETHDR", false, true)] + [TestCase("cultcache.store.v", false, true)] + [TestCase("cultcache.store.vÙ£", false, true)] + public void ARefusalEchoesAHeaderOnlyAsStoreVersionDigits(string header, bool echoed, bool directoryStore) + { + var path = Path.Combine(_directory, "header.cc"); + File.WriteAllBytes(path, StoreWithHeader(header)); + + var refusal = Assert.Throws(() => + CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directoryStore }))!; + + // The readable formats are named in the same message, so "echoed" is read from how the header is introduced. + Assert.That(refusal.Message.Contains(" is " + header + ";", StringComparison.Ordinal) || refusal.Message.Contains("format " + header + " is not readable", StringComparison.Ordinal), Is.EqualTo(echoed), refusal.Message); + Assert.That(refusal.Message.Contains("an unrecognised cultcache.store.* header of ", StringComparison.Ordinal), Is.EqualTo(!echoed), refusal.Message); + Assert.That(refusal.Message.Contains("SECRETHDR", StringComparison.Ordinal), Is.False, refusal.Message); + if (!echoed) + Assert.That(refusal.Message.Contains("Ù£", StringComparison.Ordinal), Is.False, refusal.Message); + } + + // Only nothing at the path is an empty store. A link whose target is gone is an I/O error on open and on commit, never an + // empty store and never an untyped missing-file or access failure, and the link is left where it was. + [Test] + public void ADanglingDirectoryLinkIsAnIoErrorOnOpenNotAnEmptyStore() + { + var path = Path.Combine(_directory, "dangling.cc"); + CreateDanglingDirectoryLink(path, Path.Combine(_directory, "unmounted")); + + Assert.That(() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }), Throws.TypeOf()); + Assert.That(IsALink(path), "the link was replaced"); + Assert.That(Directory.Exists(Path.Combine(_directory, "unmounted")), Is.False); + } + + [Test] + public void ACommitOntoADanglingDirectoryLinkIsAnIoErrorAndLeavesTheLink() + { + var path = Path.Combine(_directory, "link.cc"); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + CreateDanglingDirectoryLink(path, Path.Combine(_directory, "unmounted")); + + Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf()); + Assert.That(IsALink(path), "the link was replaced"); + Assert.That(Directory.Exists(Path.Combine(_directory, "unmounted")), Is.False, "a write went through the link"); + } + + [Test] + public void ADanglingFileLinkIsAnIoErrorOnOpenNotAnEmptyStore() + { + var path = Path.Combine(_directory, "dangling-file.cc"); + CreateFileLinkOrIgnore(path, Path.Combine(_directory, "unmounted", "store.cc")); + + Assert.That(() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }), Throws.TypeOf()); + Assert.That(new FileInfo(path).LinkTarget, Is.Not.Null, "the link was replaced"); + Assert.That(Directory.Exists(Path.Combine(_directory, "unmounted")), Is.False); + } + + [Test] + public void ACommitOntoAFileLinkThatDanglesAfterOpenIsAnIoErrorAndLeavesTheLink() + { + var target = Path.Combine(_directory, "target.cc"); + var path = Path.Combine(_directory, "file-link.cc"); + File.WriteAllBytes(target, File.ReadAllBytes(Path.Combine(VectorRoot(), "empty-array.bin"))); + CreateFileLinkOrIgnore(path, target); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + File.Delete(target); + + Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf()); + Assert.That(new FileInfo(path).LinkTarget, Is.Not.Null, "the link was replaced"); + Assert.That(File.Exists(target), Is.False, "a write went through the link"); + } + + // A directory at the path is something at the path: it is an I/O error on open and on commit, never an empty store, and + // it is left as it was. + [Test] + public void ADirectoryAtTheStorePathIsAnIoErrorOnOpenAndOnCommitNotAnEmptyStore() + { + var path = Path.Combine(_directory, "directory.cc"); + Directory.CreateDirectory(path); + + Assert.That(() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }), + Throws.TypeOf().With.Message.Contains("directory")); + + var later = Path.Combine(_directory, "later.cc"); + using var cache = CultCacheMessagePack.Create(later, new CultCacheOpenOptions { Registry = Registry }); + Directory.CreateDirectory(later); + Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), + Throws.TypeOf().With.Message.Contains("directory")); + + Assert.That(Directory.GetFileSystemEntries(path), Is.Empty); + Assert.That(Directory.GetFileSystemEntries(later), Is.Empty); + } + + // A link whose target is gone. A directory junction needs no privilege on Windows, where a file symlink needs one this + // workstation lacks; elsewhere a symlink to a missing directory is the same thing. + private static void CreateDanglingDirectoryLink(string path, string target) + { + if (!OperatingSystem.IsWindows()) + { + File.CreateSymbolicLink(path, target); + return; + } + + Directory.CreateDirectory(target); + var mklink = Process.Start(new ProcessStartInfo("cmd.exe", $"/c mklink /J \"{path}\" \"{target}\"") + { + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + })!; + mklink.WaitForExit(); + Assert.That(mklink.ExitCode, Is.Zero, "mklink /J failed"); + Directory.Delete(target); + } + + private static bool IsALink(string path) + { + var attributes = new FileInfo(path).Attributes; + return (int)attributes != -1 && (attributes & FileAttributes.ReparsePoint) != 0; + } + + private static void CreateFileLinkOrIgnore(string path, string target) + { + try + { + File.CreateSymbolicLink(path, target); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Assert.Ignore("A file symlink needs a privilege this account lacks: " + ex.Message); + } + } + + // An id one entry owns and a later entry lists as compatible names the entry that owns it. + [TestCase("own-id-over-compatible-v3.bin")] + [TestCase("compatible-before-owner-v3.bin")] + public void AnIdAnEntryOwnsNamesThatEntryNotOneThatListsItAsCompatible(string vector) + { + var path = Path.Combine(_directory, "own-id.cc"); + File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), vector))); + + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + Assert.That(cache.Get(new CultRecordKey("alpha")), Is.Not.Null, "the record read as the schema that owns its id"); + } + + // The registry resolves a persisted id to the entry that owns it, not to one listed earlier that lists it as compatible. + [Test] + public void ThePersistedSchemaAnIdNamesIsTheEntryThatOwnsIt() + { + CultSchemaCatalogEntry Entry(string id, string name, params string[] compatible) => new() + { + SchemaId = id, SchemaName = name, SchemaVersion = name + ".v1", ContentHash = id, CompatibleSchemaIds = compatible + }; + var catalog = new[] + { + Entry("x.lists", "tests.element_id_deck", "x.own"), + Entry("x.own", "vectors.item", "x.own") + }; + + Assert.That(Registry.ResolvePersistedSchemaReport("x.own", catalog).LocalSchemaName, Is.EqualTo("vectors.item")); + } + + // The catalog a write leaves is derived from its records: one entry per carried id, the entry that owns the id (a registered + // descriptor over an arrived entry), else one that lists it as compatible, written as chosen. + private static CultSchemaCatalogEntry Entry(string id, string name, string hash, params string[] compatible) => new() + { + SchemaId = id, SchemaName = name, SchemaVersion = name + ".v1", ContentHash = hash, CompatibleSchemaIds = compatible + }; + + private static CultPersistedRecord Rec(string key, string schemaId) => new() { Key = key, SchemaId = schemaId, StoredAt = "t" }; + + [Test] + public void ARegisteredDescriptorWinsOverAnArrivedEntryWithTheSameOwnId() + { + var registered = new[] { Entry("x", "new.name", "fresh", "x") }; + var arrived = new[] { Entry("x", "old.name", "stale", "x", "old") }; + + var derived = CultSchemaCatalogEntry.Derive(new[] { Rec("a", "x") }, registered, arrived).Single(); + Assert.That(derived.SchemaName, Is.EqualTo("new.name")); + Assert.That(derived.ContentHash, Is.EqualTo("fresh")); + Assert.That(derived.CompatibleSchemaIds, Is.EqualTo(new[] { "x" }), "written as chosen: no union with the arrived entry's ids"); + } + + [Test] + public void AnEntryThatOwnsAnIdIsChosenOverOneThatListsItAsCompatibleInEveryOrder() + { + var owner = Entry("y", "owner", "h1", "y"); + var lister = Entry("x", "lister", "h2", "x", "y"); + foreach (var arrived in new[] { new[] { owner, lister }, new[] { lister, owner } }) + { + var derived = CultSchemaCatalogEntry.Derive(new[] { Rec("a", "y") }, Array.Empty(), arrived); + Assert.That(derived.Select(entry => entry.SchemaId), Is.EqualTo(new[] { "y" }), "an entry no record needs is not written"); + } + + // A registered entry publishes only the ids it owns: the caller passes it for the ids its write stages, and an id nothing + // staged sits under is published by the arrived entry that lists it, or by none. + var registeredLister = Entry("r", "registered.lister", "h3", "r", "z"); + var arrivedLister = Entry("a", "arrived.lister", "h4", "a", "z"); + var derivedByArrived = CultSchemaCatalogEntry.Derive(new[] { Rec("a", "z") }, new[] { registeredLister }, new[] { arrivedLister }).Single(); + Assert.That(derivedByArrived.SchemaId, Is.EqualTo("a"), "a registered entry that lists the id does not publish it"); + Assert.Throws(() => + CultSchemaCatalogEntry.Derive(new[] { Rec("a", "z") }, new[] { registeredLister }, Array.Empty())); + } + + // Entries of one tier that tie are taken in one fixed order: the catalog does not depend on the order they arrive in. + [Test] + public void EntriesThatTieAreTakenInOneFixedOrder() + { + // The content hash orders them against the order their compatible ids would give: h1 sorts first, its list sorts last. + var one = Entry("x", "n", "h2", "x"); + var two = Entry("x", "n", "h1", "x", "y"); + foreach (var order in new[] { new[] { one, two }, new[] { two, one } }) + { + Assert.That(CultSchemaCatalogEntry.Derive(new[] { Rec("a", "x") }, order, Array.Empty()).Single().ContentHash, Is.EqualTo("h1")); + Assert.That(CultSchemaCatalogEntry.Derive(new[] { Rec("a", "x") }, Array.Empty(), order).Single().ContentHash, Is.EqualTo("h1")); + } + } + + // A registered descriptor that owns an id keeps it against an arrived entry that owns it too and lists the id another + // record sits under, whichever of the two ids sorts first: the arrived entry's list is not merged in, so the record under + // the listed id is published by no chosen entry. + [TestCase("a", "b")] + [TestCase("b", "a")] + public void AnArrivedEntryThatOwnsARegisteredIdDoesNotReplaceTheDescriptorNorPublishWhatItLists(string ownId, string listedId) + { + var registered = new[] { Entry(ownId, "registered", "fresh", ownId) }; + var arrived = new[] { Entry(ownId, "arrived", "stale", ownId, listedId) }; + var records = new[] { Rec("k1", ownId), Rec("k2", listedId) }; + + var refusal = Assert.Throws(() => CultSchemaCatalogEntry.Derive(records, registered, arrived))!; + Assert.That(refusal.SchemaId, Is.EqualTo(listedId)); + Assert.That(refusal.RecordKey, Is.EqualTo("k2")); + } + + // Ids are taken in sorted order, so which entry survives, and so which id the refusal names, does not depend on the order + // the records arrive in. + [Test] + public void TheRefusalNamesTheSameIdWhateverOrderTheRecordsArriveIn() + { + var arrived = new[] { Entry("x", "n", "h1", "x", "y"), Entry("x", "n", "h2", "x", "z") }; + foreach (var records in new[] { new[] { Rec("r1", "y"), Rec("r2", "z") }, new[] { Rec("r2", "z"), Rec("r1", "y") } }) + { + var refusal = Assert.Throws(() => + CultSchemaCatalogEntry.Derive(records, Array.Empty(), arrived))!; + Assert.That(refusal.SchemaId, Is.EqualTo("y")); + Assert.That(refusal.RecordKey, Is.EqualTo("r1")); + } + } + + [Test] + public void TwoArrivedEntriesThatShareAnOwnIdAndDisagreeOnTheSchemaNameRefuseTheWrite() + { + var arrived = new[] { Entry("x", "first", "h1", "x"), Entry("x", "second", "h2", "x") }; + + var refusal = Assert.Throws(() => + CultSchemaCatalogEntry.Derive(new[] { Rec("b", "x"), Rec("a", "x") }, Array.Empty(), arrived))!; + Assert.That(refusal.SchemaId, Is.EqualTo("x")); + Assert.That(refusal.SchemaNames, Is.EquivalentTo(new[] { "first", "second" })); + Assert.That(refusal.RecordKey, Is.EqualTo("a")); + + // A registered descriptor that owns the id settles the disagreement. + Assert.DoesNotThrow(() => CultSchemaCatalogEntry.Derive(new[] { Rec("a", "x") }, new[] { Entry("x", "first", "h3", "x") }, arrived)); + } + + [Test] + public void ARecordNoChosenEntryPublishesRefusesTheWrite() + { + var refusal = Assert.Throws(() => + CultSchemaCatalogEntry.Derive(new[] { Rec("a", "orphan") }, new[] { Entry("x", "n", "h", "x") }, Array.Empty()))!; + Assert.That(refusal.SchemaId, Is.EqualTo("orphan")); + Assert.That(refusal.RecordKey, Is.EqualTo("a")); + } + + // A file entry that names an older schema under the registered schema's id, with a record kept under an older id it lists. + private string LegacyFile(string name) + { + var path = Seed(name); + var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var entry = manifest.SchemaCatalog.Single(); + entry.ContentHash = "stale"; + entry.CompatibleSchemaIds = new[] { entry.SchemaId, "old.id" }; + manifest.Records.Single().SchemaId = "old.id"; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(manifest)); + return path; + } + + // The cache holds the record d, but a commit onto the file writes only its batch: d stays as the file holds it, under an id + // only the file's entry lists, and the registered descriptor does not. No store writer restamps a record; the write is + // refused and the file left as it was. + [Test] + public void ACommitOntoAFileWhoseCachedRecordSitsUnderAnIdOnlyAnArrivedEntryListsIsRefusedAndTheFileLeftAsItWas() + { + var path = Seed("legacy-conditional.cc"); + var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var entry = manifest.SchemaCatalog.Single(); + manifest.Records.Single().SchemaId = "old.id"; + entry.ContentHash = "stale"; + entry.CompatibleSchemaIds = new[] { entry.SchemaId, "old.id" }; + var bytes = CultDocumentMessagePackSerialization.SerializeSnapshot(manifest); + File.WriteAllBytes(path, bytes); + + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + Assert.That(cache.Get(new CultRecordKey("d")), Is.Not.Null, "the cache reads the record under the older id"); + var refusal = Assert.Throws(() => cache.Commit(batch => + { + batch.Expect(new CultRecordKey("e"), null); + batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e")); + }))!; + Assert.That(refusal.SchemaId, Is.EqualTo("old.id")); + Assert.That(refusal.RecordKey, Is.EqualTo("d")); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes)); + } + + // Another writer rewrites the record d under a different id, keeping its storedAt. A cache that read d earlier commits a + // record of its own onto the file: the other writer's d is not the cache's to rewrite, so it is left exactly as written. + [Test] + public void ACommitNeverRewritesARecordAnotherWriterChangedThatKeptItsStoredAt() + { + var path = Seed("lost-update.cc"); + var other = Seed("lost-update-other.cc"); + using (var cache = CultCacheMessagePack.Create(other, new CultCacheOpenOptions { Registry = Registry })) + cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "changed-by-other" }, new CultRecordKey("d"))); + var otherPayload = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(other)).Records.Single().Payload; + + using var reader = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + Assert.That(reader.Get(new CultRecordKey("d"))!.Name, Is.EqualTo("d")); + + var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var entry = manifest.SchemaCatalog.Single(); + var record = manifest.Records.Single(); + record.SchemaId = "other.writer.id"; + record.Payload = otherPayload; + entry.CompatibleSchemaIds = new[] { entry.SchemaId, "other.writer.id" }; + var bytes = CultDocumentMessagePackSerialization.SerializeSnapshot(manifest); + File.WriteAllBytes(path, bytes); + + Assert.Throws(() => reader.Commit(batch => + { + batch.Expect(new CultRecordKey("e"), null); + batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e")); + })); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "the other writer's record is on disk as it wrote it"); + } + + // One rule for both commits: an unconditional commit onto the file copies the record under the older id as it is, and is + // refused for the same reason a conditional one is. + [Test] + public void AnUnconditionalCommitOntoAFileWhoseRecordSitsUnderAnIdOnlyAnArrivedEntryListsIsRefusedAndTheFileLeftAsItWas() + { + var path = LegacyFile("legacy-unconditional.cc"); + var bytes = File.ReadAllBytes(path); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + + var refusal = Assert.Throws(() => + cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))))!; + Assert.That(refusal.SchemaId, Is.EqualTo("old.id")); + Assert.That(refusal.RecordKey, Is.EqualTo("d")); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes)); + } + + // A record the cache does not hold, under an id only an arrived entry publishes while a registered descriptor owns that + // entry's own id, cannot be written readable: the write is refused and the file is left as it was. + [Test] + public void ARecordOnlyAnOverriddenEntryPublishesRefusesTheWriteAndLeavesTheFile() + { + var path = Seed("legacy-orphan.cc"); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var entry = manifest.SchemaCatalog.Single(); + entry.SchemaName = "vectors.legacy"; + entry.CompatibleSchemaIds = new[] { entry.SchemaId, "old.id" }; + var existing = manifest.Records.Single(); + manifest.Records = new[] { existing, new CultPersistedRecord { Key = "z", SchemaId = "old.id", StoredAt = "t", Payload = existing.Payload } }; + var bytes = CultDocumentMessagePackSerialization.SerializeSnapshot(manifest); + File.WriteAllBytes(path, bytes); + + var refusal = Assert.Throws(() => cache.Commit(batch => + { + batch.Expect(new CultRecordKey("e"), null); + batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e")); + }))!; + Assert.That(refusal.SchemaId, Is.EqualTo("old.id")); + Assert.That(refusal.RecordKey, Is.EqualTo("z")); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes)); + } + + // The directory store derives its manifest the same way and restamps nothing: a record kept under an older id only the + // manifest's entry lists is refused, and the manifest is left as it was. + [Test] + public void ADirectoryRewriteWhoseKeptRecordSitsUnderAnIdOnlyTheManifestEntryListsIsRefusedAndTheManifestLeftAsItWas() + { + var path = DirectoryStore("legacy-dir.cc"); + var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var entry = manifest.SchemaCatalog.Single(); + entry.ContentHash = "stale"; + entry.CompatibleSchemaIds = new[] { entry.SchemaId, "old.id" }; + manifest.Records.Single().SchemaId = "old.id"; + var bytes = CultDocumentMessagePackSerialization.SerializeSnapshot(manifest); + File.WriteAllBytes(path, bytes); + + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true })) + { + var refusal = Assert.Throws(() => + cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))))!; + Assert.That(refusal.SchemaId, Is.EqualTo("old.id")); + Assert.That(refusal.RecordKey, Is.EqualTo("d")); + } + + Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes)); + using var reopened = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true }); + Assert.That(reopened.Get(new CultRecordKey("d")), Is.Not.Null); + } + + // A directory manifest in a format the directory store does not read is refused with the typed exception, naming it. + [Test] + public void ADirectoryManifestInAnotherFormatIsRefusedNamingIt() + { + var path = Path.Combine(_directory, "wrong-format.cc"); + File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), "..", "v3-base.msgpack"))); + + Assert.That( + () => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true }), + Throws.TypeOf().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path)); + } + + // A commit that lands onto the file as it is keeps the header the file carries: a store already v3 stays v3. + [TestCase("../v3-base.msgpack", "cultcache.store.v3")] + [TestCase("compatible-id-only-v3.bin", "cultcache.store.v3")] + [TestCase("empty-array.bin", "cultcache.store.v1")] + public void AConditionalCommitOntoAReadableFileKeepsItsHeader(string vector, string header) + { + var path = Seed("conditional.cc"); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), vector))); + + Assert.That(cache.Commit(batch => + { + batch.Expect(new CultRecordKey("e"), null); + batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e")); + }), Is.True); + Assert.That(Header(path), Is.EqualTo(header)); + } + + // A record's version is the SHA-256 of the bytes it is stored as: in a single file the record's own slice of the file, read + // here with the MessagePack reader and not the serializer; in a directory the hash the manifest indexes its page under. + [Test] + public void AVersionIsTheSha256OfTheRecordsStoredBytes([Values] bool directory) + { + var source = File.ReadAllBytes(Path.Combine(VectorRoot(), "..", "v3-base.msgpack")); + var slices = new Dictionary(); + var reader = new MessagePackReader(source); + reader.ReadArrayHeader(); + reader.Skip(); + reader.Skip(); + var count = reader.ReadArrayHeader(); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(source); + for (var i = 0; i < count; i++) + { + var start = (int)reader.Consumed; + reader.Skip(); + slices[snapshot.Records[i].Key] = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(source.AsSpan(start, (int)reader.Consumed - start))).ToLowerInvariant(); + } + + var path = Path.Combine(_directory, "version.cc"); + File.WriteAllBytes(path, source); + if (directory) + { + // The same records, written as a directory store: its versions are the manifest's page hashes. + File.Delete(path); + using (var seed = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true })) + seed.Commit(batch => + { + foreach (var key in slices.Keys) + batch.Upsert(typeof(VectorItem), new VectorItem { Name = key }, new CultRecordKey(key)); + }); + slices = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records + .ToDictionary(record => record.Key, record => Convert.ToHexString(record.Payload).ToLowerInvariant()); + } + + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory }); + + Assert.That(cache.AllStoredDocuments.ToDictionary(stored => stored.Key.Value, stored => stored.StoredVersion), Is.EqualTo(slices)); + Assert.That(slices, Is.Not.Empty); + } + + // Every runtime's tests walk the manifest, so a vector without a row would go untested. + [Test] + public void EveryVectorInTheFolderHasAManifestRow() + { + var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal); + var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal); + Assert.That(rows, Is.EqualTo(files)); + } + + // The refusal carries what the reader choked on. + [Test] + public void ARefusalKeepsItsCauseAsTheInnerException() + { + var error = Assert.Throws(() => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(Path.Combine(VectorRoot(), "truncated.bin"))))!; + Assert.That(error.InnerException, Is.Not.Null); + } + + // The record type of the shared valid store (v3-base.msgpack: alpha and beta), so a cache can open it. + [CultDocument("vectors.item", "vectors.item.v1")] + [MessagePackObject] + public sealed class VectorItem + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + [Key(1)] public int Count { get; set; } + } + } +} diff --git a/tests/GameCult.Caching.Tests/StoreSlices.cs b/tests/GameCult.Caching.Tests/StoreSlices.cs new file mode 100644 index 00000000..a27f2468 --- /dev/null +++ b/tests/GameCult.Caching.Tests/StoreSlices.cs @@ -0,0 +1,27 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching.MessagePack; + +namespace GameCult.Caching.Tests +{ + // The exact bytes a store file holds for its catalog entries and records. A read snapshot carries none: the store that read the + // file owns them, and these read them as the store's reader does. + internal static class StoreSlices + { + internal static byte[] CatalogEntry(string path, string schemaId) + { + var read = CultDocumentMessagePackSerialization.ReadStore(File.ReadAllBytes(path)); + return read.CatalogBytes[Array.FindIndex(read.Snapshot.SchemaCatalog, entry => entry.SchemaId == schemaId)]; + } + + internal static byte[][] CatalogEntries(string path) => + CultDocumentMessagePackSerialization.ReadStore(File.ReadAllBytes(path)).CatalogBytes; + + internal static byte[] Record(string path, string key) + { + var read = CultDocumentMessagePackSerialization.ReadStore(File.ReadAllBytes(path)); + return read.RecordBytes[Array.FindIndex(read.Snapshot.Records, record => record.Key == key)]; + } + } +} diff --git a/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs b/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs index df368bdd..85d17832 100644 --- a/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs +++ b/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs @@ -4,6 +4,7 @@ using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.Serialization; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; @@ -20,7 +21,7 @@ namespace GameCult.Caching.Tests { private const string Canary = "CANARY-9d1e47"; - private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(GuardDoc), typeof(ShapeDoc) }); + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(GuardDoc), typeof(ShapeDoc), typeof(StubDoc) }); private delegate void Emit(ref MessagePackWriter writer); @@ -42,16 +43,18 @@ namespace GameCult.Caching.Tests w.WriteArrayHeader(4); w.Write(id); w.Write(content); w.WriteNil(); w.Write(weight); }; - private static Emit UnitArm(int key) => (ref MessagePackWriter w) => { w.WriteArrayHeader(2); w.Write(key); w.WriteArrayHeader(0); }; + // Unit8: slots [id]. + private static Emit UnitArm(int key) => (ref MessagePackWriter w) => { w.WriteArrayHeader(2); w.Write(key); w.WriteArrayHeader(1); w.Write("000000000007"); }; - // Branch: slots [children, next]. + // Branch: slots [children, next, id]. private static Emit BranchArm(int key, Emit next, params Emit[] children) => (ref MessagePackWriter w) => { w.WriteArrayHeader(2); w.Write(key); - w.WriteArrayHeader(2); + w.WriteArrayHeader(3); w.WriteArrayHeader(children.Length); foreach (var child in children) child(ref w); next(ref w); + w.Write("000000000008"); }; private static Emit List(params Emit[] items) => (ref MessagePackWriter w) => @@ -121,22 +124,22 @@ namespace GameCult.Caching.Tests [TestCase(7)] [TestCase(10)] public void UnknownArmInFieldRefused(int key) => - AssertRefused(Doc(LeafArm(key, "r", Canary, 7)), NodeName, key, "3, 5, 8, 9"); + AssertRefused(Doc(LeafArm(key, "000000000005", Canary, 7)), NodeName, key, "3, 5, 8, 9"); [TestCase(0)] [TestCase(4)] [TestCase(10)] public void UnknownArmInListRefused(int key) => - AssertRefused(Doc(LeafArm(3, "r", "ok", 1), List(UnitArm(8), LeafArm(key, "i", Canary, 7))), NodeName, key, "3, 5, 8, 9"); + AssertRefused(Doc(LeafArm(3, "000000000005", "ok", 1), List(UnitArm(8), LeafArm(key, "000000000004", Canary, 7))), NodeName, key, "3, 5, 8, 9"); // Inside an arm's own list, and inside an arm's own field: the union nested under a union. [TestCase(4)] [TestCase(10)] public void UnknownArmNestedInArmRefused(int key) { - AssertRefused(Doc(BranchArm(5, Nil, UnitArm(8), LeafArm(key, "c", Canary, 7))), NodeName, key, "3, 5, 8, 9"); - AssertRefused(Doc(BranchArm(5, LeafArm(key, "n", Canary, 7))), NodeName, key, "3, 5, 8, 9"); - AssertRefused(Doc(BranchArm(5, Nil, BranchArm(5, Nil, BranchArm(5, Nil, LeafArm(key, "deep", Canary, 7))))), NodeName, key, "3, 5, 8, 9"); + AssertRefused(Doc(BranchArm(5, Nil, UnitArm(8), LeafArm(key, "000000000006", Canary, 7))), NodeName, key, "3, 5, 8, 9"); + AssertRefused(Doc(BranchArm(5, LeafArm(key, "000000000003", Canary, 7))), NodeName, key, "3, 5, 8, 9"); + AssertRefused(Doc(BranchArm(5, Nil, BranchArm(5, Nil, BranchArm(5, Nil, LeafArm(key, "000000000002", Canary, 7))))), NodeName, key, "3, 5, 8, 9"); } // A union whose arms are also a union has its own key set: 7 is Outer's (OuterOnly) and not Inner's. @@ -173,9 +176,9 @@ namespace GameCult.Caching.Tests foreach (var doc in new[] { new GuardDoc { Root = new Rogue() }, - new GuardDoc { Items = { new Unit8(), new Rogue() } }, - new GuardDoc { Root = new Branch { Children = { new Rogue() } } }, - new GuardDoc { Root = new Branch { Next = new Rogue() } }, + new GuardDoc { Items = { new Unit8 { Id = "000000000007" }, new Rogue() } }, + new GuardDoc { Root = new Branch { Id = "000000000008", Children = { new Rogue() } } }, + new GuardDoc { Root = new Branch { Id = "000000000008", Next = new Rogue() } }, new GuardDoc { Root = new DerivedLeaf() }, new GuardDoc { Crafted = new Unlisted() }, }) @@ -204,29 +207,30 @@ namespace GameCult.Caching.Tests Id = "d1", Root = new Branch { + Id = "000000000008", Children = { - new Leaf { Id = "a", Content = "x", Weight = 1 }, - new Unit8(), - new Branch { Children = { new Branch { Children = { new Leaf { Id = "deep", Content = "y", Weight = 2 } } } } } + new Leaf { Id = "000000000001", Content = "x", Weight = 1 }, + new Unit8 { Id = "000000000007" }, + new Branch { Id = "000000000008", Children = { new Branch { Id = "000000000008", Children = { new Leaf { Id = "000000000002", Content = "y", Weight = 2 } } } } } }, - Next = new Leaf { Id = "n", Content = "z", Weight = 3 } + Next = new Leaf { Id = "000000000003", Content = "z", Weight = 3 } }, - Items = { new Unit8(), new Leaf { Id = "i", Content = "w", Weight = 4 }, null! }, + Items = { new Unit8 { Id = "000000000007" }, new Leaf { Id = "000000000004", Content = "w", Weight = 4 }, null! }, Crafted = new InnerB { N = 9 }, Any = new InnerA { N = 8 } }; private static byte[] KnownArmBytes() => Doc( - BranchArm(5, LeafArm(3, "n", "z", 3), - LeafArm(3, "a", "x", 1), + BranchArm(5, LeafArm(3, "000000000003", "z", 3), + LeafArm(3, "000000000001", "x", 1), UnitArm(8), - BranchArm(5, Nil, BranchArm(5, Nil, LeafArm(3, "deep", "y", 2)))), - List(UnitArm(8), LeafArm(3, "i", "w", 4), Nil), + BranchArm(5, Nil, BranchArm(5, Nil, LeafArm(3, "000000000002", "y", 2)))), + List(UnitArm(8), LeafArm(3, "000000000004", "w", 4), Nil), InnerArm(1, 9), InnerArm(1, 8)); - // Non-zero-start keys with retired gaps, a unit arm, a union inside an arm four deep: the reference shape, unchanged. + // Non-zero-start keys with retired gaps, an id-only arm, a union inside an arm four deep: the reference shape, unchanged. [Test] public void KnownArmsRoundTripByteIdentical() { @@ -243,15 +247,543 @@ namespace GameCult.Caching.Tests [Test] public void ConsumerResolverStillEncodesArms() { - var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(new GuardDoc { Id = "d1", Root = new StubArm() }, typeof(GuardDoc), Registry); - Assert.That(Convert.ToHexString(bytes), Is.EqualTo(Convert.ToHexString(Doc((ref MessagePackWriter w) => + var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(new StubDoc { Id = "d1", Root = new StubArm() }, typeof(StubDoc), Registry); + Assert.That(Convert.ToHexString(bytes), Is.EqualTo(Convert.ToHexString(Raw((ref MessagePackWriter w) => { + w.WriteArrayHeader(2); w.Write("d1"); w.WriteArrayHeader(2); w.Write(9); w.WriteArrayHeader(1); w.Write("via-resolver"); })))); - var back = (GuardDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(GuardDoc), bytes, Registry); + var back = (StubDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(StubDoc), bytes, Registry); Assert.That(back.Root, Is.TypeOf()); } + // A list element is written by its [Key] layout or its id never reaches disk: a consumer resolver that claims the arm of + // a list's union is refused on every path that encodes or decodes the document, naming the arm and the resolver, and + // writes nothing. + [Test] + public void ListElementsClaimedByAResolverAreRefused() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(ClaimDoc) }); + var doc = new ClaimDoc { Id = "c1", Items = { new ClaimArm { N = 4 } } }; + void Refused(TestDelegate attempt) + { + var thrown = Assert.Throws(attempt)!; + Assert.That(thrown.Message, Does.Contain("ClaimArm").And.Contain(nameof(ClaimArmResolver)).And.Contain(nameof(ClaimDoc))); + } + + Refused(() => CultDocumentMessagePackSerialization.SerializeUntyped(doc, typeof(ClaimDoc), registry)); + var held = Raw((ref MessagePackWriter w) => { w.WriteArrayHeader(2); w.Write("c1"); w.WriteArrayHeader(0); }); + Refused(() => CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(ClaimDoc), held, registry)); + Refused(() => CultCacheMessagePack.CreateCodec(registry).Serialize(doc.Items, typeof(List), typeof(ClaimDoc))); + + var scratch = Directory.CreateTempSubdirectory().FullName; + try + { + var path = Path.Combine(scratch, "claim.cc"); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry }); + Assert.That(Assert.Catch(() => cache.Commit(batch => batch.Upsert(typeof(ClaimDoc), doc, new CultRecordKey("c1"))))!.ToString(), + Does.Contain(nameof(ClaimArmResolver))); + Assert.That(File.Exists(path), Is.False, "nothing reached disk"); + } + finally + { + Directory.Delete(scratch, true); + } + } + + // A resolver's formatter for the list type of an object list drops the elements' ids as surely as one for the element, + // so the collection type is on the id path (Soul s1 P1). A claim on a value list, even beside an object list, is none. + [Test] + public void AResolverClaimingTheListTypeOfAnObjectListIsRefused() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(LcDoc), typeof(LcValueDoc) }); + var doc = new LcDoc { Id = "l1", Items = { new LcArm { Id = "000000000001", N = 4 } } }; + var held = Raw((ref MessagePackWriter w) => { w.WriteArrayHeader(2); w.Write("l1"); w.WriteArrayHeader(0); }); + foreach (var attempt in new TestDelegate[] + { + () => CultDocumentMessagePackSerialization.SerializeUntyped(doc, typeof(LcDoc), registry), + () => CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(LcDoc), held, registry) + }) + { + var thrown = Assert.Throws(attempt)!; + Assert.That(thrown.Message, Does.Contain(nameof(ListClaimResolver)).And.Contain("List`1").And.Contain(nameof(LcDoc))); + } + + var values = new LcValueDoc + { + Id = "v1", + Items = { new LcOther { Id = "000000000002" } }, + Values = { new LcValue { N = 3 }, new LcValue { N = 5 } }, + Points = { new Pair { A = 1, B = 2 } } + }; + var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(values, typeof(LcValueDoc), registry); + var back = (LcValueDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(LcValueDoc), bytes, registry); + Assert.That(back.Items.Single().Id, Is.EqualTo("000000000002")); + Assert.That(back.Values.Select(value => value.N), Is.EqualTo(new[] { 3, 5 })); + Assert.That(back.Points.Single(), Is.EqualTo(new Pair { A = 1, B = 2 })); + } + + // A resolver that hands out MessagePack's own formatter for a type writes its [Key] layout: it claims nothing, however it + // got there, and the document encodes with the id present (Soul s1 P2). + [Test] + public void AResolverHandingBackTheStandardFormatterIsNoClaim() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FbDoc) }); + var doc = new FbDoc { Id = "f1", Items = { new FbArm { Id = "000000000009", N = 7 } } }; + + var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(doc, typeof(FbDoc), registry); + + Assert.That(Convert.ToHexString(bytes), Is.EqualTo(Convert.ToHexString(MessagePackSerializer.Serialize(doc, CultDocumentMessagePackSerialization.Options)))); + var back = (FbDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(FbDoc), bytes, registry); + Assert.That(back.Items.Single().Id, Is.EqualTo("000000000009")); + } + + // A consumer whose resolver composite ends in StandardResolverAllowPrivate (some models keep private [Key] members) gets + // that resolver's formatter for every keyed type. It writes the [Key] layout plus the private members, so it is no + // claim: the document encodes, the id is on disk, and the private value survives. + [Test] + public void AStandardResolverAllowPrivateFormatterIsNoClaim() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(ApDoc), typeof(ApPlainDoc) }); + var doc = new ApDoc { Id = "a1", Items = { new ApArm { Id = "00000000000a", N = 4 }.WithSecret(11) } }; + + var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(doc, typeof(ApDoc), registry); + + var back = (ApDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(ApDoc), bytes, registry); + Assert.That(back.Items.Single().Id, Is.EqualTo("00000000000a")); + Assert.That(back.Items.Single().Secret, Is.EqualTo(11), "a private plain-value [Key] member is written"); + + var plain = new ApPlainDoc { Id = "a2", Items = { new ApPlainArm { Id = "00000000000b", N = 5 } } }; + var plainBytes = CultDocumentMessagePackSerialization.SerializeUntyped(plain, typeof(ApPlainDoc), registry); + Assert.That(Convert.ToHexString(plainBytes), Is.EqualTo(Convert.ToHexString(MessagePackSerializer.Serialize(plain, CultDocumentMessagePackSerialization.Options)))); + } + + // The walk reads a [MessagePackObject] type by its keys, but MessagePack writes one that is also an ICollection as a + // collection and drops the keys. The codec compares the two for every type on the id path and refuses the document. + [Test] + public void AKeyedTypeMessagePackWritesAsACollectionIsRefused() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(KCollDoc), typeof(KBagDoc) }); + Assert.That(CultElementIds.Inspect(typeof(KCollDoc)).IdPath, Does.Contain(typeof(KColl))); + + var coll = new KCollDoc { Id = "k1" }; + coll.Items.Add(new FbArm { Id = "00000000000c" }); + var thrown = Assert.Throws(() => CultDocumentMessagePackSerialization.SerializeUntyped(coll, typeof(KCollDoc), registry))!; + Assert.That(thrown.Message, Does.Contain($"writes {nameof(KColl)} as a collection, but the element-id walk reads it by its [Key] layout")); + + // A keyed map is read by key too, so a keyed Dictionary subclass is on the path and refused the same way. + var map = Assert.Throws(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(KDictDoc)))!; + Assert.That(map.Message, Does.Contain($"writes {nameof(KDict)} as a collection")); + + var bag = new KBagDoc { Id = "b1", Bag = new KBagE { Items = { new FbArm { Id = "00000000000d" } } } }; + var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(bag, typeof(KBagDoc), registry); + var back = (KBagDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(KBagDoc), bytes, registry); + Assert.That(back.Bag.Items.Single().Id, Is.EqualTo("00000000000d")); + } + + // Every collection shape the walk reads is one MessagePack writes through a collection formatter, so none is refused. + [Test] + public void EveryCollectionShapeTheWalkReadsIsWrittenByACollectionFormatter() + { + foreach (var holder in new[] + { + typeof(G>), typeof(G), typeof(G), typeof(G>), typeof(G>), + typeof(G>), typeof(G>), typeof(G>), typeof(G>), + typeof(G>), typeof(G>), typeof(G>), + typeof(G>), typeof(G>), + typeof(G>), typeof(G>), typeof(G>>), + typeof(G>), typeof(G>), typeof(G>), + typeof(G>), typeof(G>), + typeof(G>), typeof(G>), + typeof(G>), typeof(G), typeof(G), typeof(G), + }) + { + if (holder != typeof(G)) + Assert.That(CultElementIds.Inspect(holder).IdPath, Is.Not.Empty, holder.ToString()); + Assert.DoesNotThrow(() => CultDocumentMessagePackSerialization.OptionsForDocument(holder), holder.ToString()); + } + } + + // One recogniser decides what the walk owns and the door only verifies it: the same types give no registration problem and a + // passing door. A type formatted by attribute or written by the object resolver is one whole value, off the id path. + [Test] + public void OneRecogniserDecidesAndTheDoorAgrees() + { + foreach (var holder in new[] { typeof(G), typeof(G), typeof(G), typeof(G), typeof(G) }) + { + Assert.That(CultElementIds.Inspect(holder).Problems, Is.Empty, holder.ToString()); + Assert.DoesNotThrow(() => CultDocumentMessagePackSerialization.OptionsForDocument(holder), holder.ToString()); + } + + foreach (var whole in new[] { typeof(G), typeof(G), typeof(G), typeof(G) }) + Assert.That(CultElementIds.Inspect(whole).IdPath, Is.Empty, whole.ToString()); + var ap = CultElementIds.Inspect(typeof(G)).IdPath; + Assert.That(ap, Does.Contain(typeof(ApTrueHold)).And.Contain(typeof(List))); + var seq = new G { Value = new DcSeq { Items = { new FbArm() } } }; + Assert.That(CultElementIds.Plan(seq.GetType(), seq, "r", deterministic: false).Count, Is.EqualTo(0)); + } + + // MessagePack throws for a type, or StandardResolver has no formatter for it (an interface or abstract class that is only + // IEnumerable): such a type cannot be persisted, so the door refuses it with the same typed text as a throwing build. + [Test] + public void ATypeMessagePackCannotBuildIsATypedRefusal() + { + foreach (var (holder, name) in new[] { (typeof(G), nameof(IArms)), (typeof(G), nameof(AbsArms)) }) + { + var thrown = Assert.Throws(() => CultDocumentMessagePackSerialization.OptionsForDocument(holder), holder.ToString())!; + Assert.That(thrown.Message, Does.Contain(name).And.Contain("cannot build a formatter")); + Assert.That(thrown.Message, Does.Contain("StandardResolver has no formatter for it"), "a null formatter says so, without a thrown message"); + } + } + + // A keyed type that enumerates lists is read by key and is on the id path, so the door checks it: MessagePack writes it as a + // collection, which loses the ids the walk would mint. + [Test] + public void AKeyedTypeThatEnumeratesListsIsCheckedByTheDoor() + { + Assert.That(CultElementIds.Inspect(typeof(G)).IdPath, Does.Contain(typeof(KLL))); + var thrown = Assert.Throws(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(G)))!; + Assert.That(thrown.Message, Does.Contain($"writes {nameof(KLL)} as a collection")); + } + + // A resolver that throws while asked about an id-path type is a typed refusal, whether it is the AllowPrivate formatter + // the claim check asks about (the claim stands) or a resolver ahead (it fails to build). + [Test] + public void AResolverThatThrowsIsATypedRefusal() + { + var first = Assert.Throws(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(DkDoc)))!; + Assert.That(first.Message, Does.Contain(nameof(ListClaimResolver)).And.Contain(nameof(DkEl))); + var second = Assert.Throws(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(DkDoc2)))!; + Assert.That(second.Message, Does.Contain(nameof(ListClaimResolver)).And.Contain(nameof(DkEl2)).And.Contain("failed to build").And.Not.Contain("target of an invocation"), + "the resolver's own exception is named, not the reflection wrapper"); + + // A type MessagePack itself cannot build a formatter for (two public members on one key) is refused, naming the type. + var third = Assert.Throws(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(DkDoc3)))!; + Assert.That(third.Message, Does.Contain(nameof(DkEl3)).And.Contain("cannot build a formatter").And.Not.Contain("target of an invocation")); + } + + [MessagePackObject] + public sealed class KColl : List + { + [Key(0)] public int N; + } + + [CultDocument("tests.kcoll_doc", "tests.kcoll_doc.v1")] + [MessagePackObject] + public sealed class KCollDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public KColl Items = new(); + } + + [MessagePackObject] + public sealed class KDict : Dictionary + { + [Key(0)] public int N; + } + + [CultDocument("tests.kdict_doc", "tests.kdict_doc.v1")] + [MessagePackObject] + public sealed class KDictDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public KDict Items = new(); + } + + [MessagePackObject] + public sealed class KBagE : IEnumerable + { + [Key(0)] public List Items = new(); + public IEnumerator GetEnumerator() { yield break; } + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [CultDocument("tests.kbag_doc", "tests.kbag_doc.v1")] + [MessagePackObject] + public sealed class KBagDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public KBagE Bag = new(); + } + + [MessagePackObject] public sealed class G { [Key(0)] public T? Value; } + [Union(0, typeof(GuArm))] public interface IGu { } + [MessagePackObject] public sealed class GuArm : IGu { [Key(0)] [CultElementId] public string Id = string.Empty; [Key(1)] public List Items = new(); } + [MessagePackObject] public sealed class GHold { [Key(0)] public List Items = new(); } + [MessagePackObject] public struct GStruct { [Key(0)] public List Items; } + +#pragma warning disable CS0169, CS0414, CS0649 + [MessagePackObject] public sealed class DkEl { [Key(0)] [CultElementId] public string Id = string.Empty; [Key(1)] public int N; [Key(1)] private int shadow; } + [MessagePackObject] public sealed class DkEl2 { [Key(0)] [CultElementId] public string Id = string.Empty; [Key(1)] public int N; [Key(1)] private int shadow; } +#pragma warning restore CS0169, CS0414, CS0649 + [MessagePackObject] public sealed class DkEl3 { [Key(0)] [CultElementId] public string Id = string.Empty; [Key(1)] public int N; [Key(1)] public int M; } + [CultDocument("tests.dk_doc3", "tests.dk_doc3.v1")] + [MessagePackObject] + public sealed class DkDoc3 { [Key(0)] [CultName] public string Id = string.Empty; [Key(1)] public List Items = new(); } + [CultDocument("tests.dk_doc", "tests.dk_doc.v1")] + [MessagePackObject] + public sealed class DkDoc { [Key(0)] [CultName] public string Id = string.Empty; [Key(1)] public List Items = new(); } + [CultDocument("tests.dk_doc2", "tests.dk_doc2.v1")] + [MessagePackObject] + public sealed class DkDoc2 { [Key(0)] [CultName] public string Id = string.Empty; [Key(1)] public List Items = new(); } + + [MessagePackFormatter(typeof(FormattedRolesFormatter))] public sealed class FormattedRoles : List { } + public sealed class FormattedRolesFormatter : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, FormattedRoles value, MessagePackSerializerOptions options) => writer.WriteNil(); + public FormattedRoles Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { reader.Skip(); return new FormattedRoles(); } + } + + [DataContract] public sealed class DcSeq : IEnumerable + { + [DataMember(Order = 0)] public List Items = new(); + public IEnumerator GetEnumerator() => Items.GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + public interface IArms : IEnumerable { } + public abstract class AbsArms : IEnumerable + { + public abstract IEnumerator GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + [MessagePackObject] public sealed class KLL : List> { [Key(0)] public int N; } +#pragma warning disable CS0169, CS0414, CS0649 + [MessagePackObject(AllowPrivate = true)] public sealed class ApTrueHold { [Key(0)] private List items = new(); } +#pragma warning restore CS0169, CS0414, CS0649 + [DataContract] public sealed class DcColl : List { [DataMember(Order = 0)] public int N; } + [DataContract] public sealed class DcBad : List { [DataMember(Order = 0)] public int A; [DataMember(Order = 0)] public int B; } + + public sealed class DkFormatter : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, DkEl value, MessagePackSerializerOptions options) => writer.Write(value.N); + public DkEl Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) => new DkEl { N = reader.ReadInt32() }; + } + + [MessagePackObject] + public sealed class ApArm + { + [Key(0)] [CultElementId] public string Id = string.Empty; + [Key(1)] public int N; + [Key(2)] private int secret; + [IgnoreMember] public int Secret => secret; + public ApArm WithSecret(int value) + { + secret = value; + return this; + } + } + + [CultDocument("tests.ap_doc", "tests.ap_doc.v1")] + [MessagePackObject] + public sealed class ApDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public List Items = new(); + } + + [MessagePackObject] + public sealed class ApPlainArm + { + [Key(0)] [CultElementId] public string Id = string.Empty; + [Key(1)] public int N; + } + + [CultDocument("tests.ap_plain_doc", "tests.ap_plain_doc.v1")] + [MessagePackObject] + public sealed class ApPlainDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public List Items = new(); + } + + [MessagePackObject] + public sealed class LcArm + { + [Key(0)] [CultElementId] public string Id = string.Empty; + [Key(1)] public int N; + } + + [MessagePackObject] + public sealed class LcOther + { + [Key(0)] [CultElementId] public string Id = string.Empty; + } + + public sealed class LcValue + { + public int N; + } + + [CultDocument("tests.lc_doc", "tests.lc_doc.v1")] + [MessagePackObject] + public sealed class LcDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public List Items = new(); + } + + [CultDocument("tests.lc_value_doc", "tests.lc_value_doc.v1")] + [MessagePackObject] + public sealed class LcValueDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public List Items = new(); + [Key(2)] public List Values = new(); + [Key(3)] public List Points = new(); + } + + [MessagePackObject] + public sealed class FbArm + { + [Key(0)] [CultElementId] public string Id = string.Empty; + [Key(1)] public int N; + } + + [CultDocument("tests.fb_doc", "tests.fb_doc.v1")] + [MessagePackObject] + public sealed class FbDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public List Items = new(); + } + + // Claims List (an object list's type: refused), List and List (value lists: accepted, as a math + // resolver claims List), and answers FbArm with MessagePack's own formatter (no claim). + public sealed class ListClaimResolver : IFormatterResolver + { + public static readonly IFormatterResolver Instance = new ListClaimResolver(); + + public IMessagePackFormatter? GetFormatter() + { + if (typeof(T) == typeof(List)) return (IMessagePackFormatter)(object)new NilFormatter(); + if (typeof(T) == typeof(List)) return (IMessagePackFormatter)(object)new ValuesFormatter(); + if (typeof(T) == typeof(List)) return (IMessagePackFormatter)(object)new PointsFormatter(); + if (typeof(T) == typeof(FbArm)) return global::MessagePack.Resolvers.StandardResolver.Instance.GetFormatter(); + if (typeof(T) == typeof(DkEl)) return (IMessagePackFormatter)(object)new DkFormatter(); + if (typeof(T) == typeof(DkEl2)) return global::MessagePack.Resolvers.StandardResolverAllowPrivate.Instance.GetFormatter(); + if (typeof(T) == typeof(ApArm) || typeof(T) == typeof(ApDoc) || typeof(T) == typeof(List) || + typeof(T) == typeof(ApPlainArm) || typeof(T) == typeof(ApPlainDoc) || typeof(T) == typeof(List)) + return global::MessagePack.Resolvers.StandardResolverAllowPrivate.Instance.GetFormatter(); + return null; + } + + private sealed class NilFormatter : IMessagePackFormatter> + { + public void Serialize(ref MessagePackWriter writer, List value, MessagePackSerializerOptions options) => writer.WriteNil(); + + public List Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + reader.Skip(); + return new List(); + } + } + + private sealed class ValuesFormatter : IMessagePackFormatter> + { + public void Serialize(ref MessagePackWriter writer, List value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(value.Count); + foreach (var item in value) writer.Write(item.N); + } + + public List Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + var count = reader.ReadArrayHeader(); + var list = new List(count); + for (var index = 0; index < count; index++) list.Add(new LcValue { N = reader.ReadInt32() }); + return list; + } + } + + private sealed class PointsFormatter : IMessagePackFormatter> + { + public void Serialize(ref MessagePackWriter writer, List value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(value.Count); + foreach (var item in value) { writer.WriteArrayHeader(2); writer.Write(item.A); writer.Write(item.B); } + } + + public List Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + var count = reader.ReadArrayHeader(); + var list = new List(count); + for (var index = 0; index < count; index++) + { + reader.ReadArrayHeader(); + list.Add(new Pair { A = reader.ReadSingle(), B = reader.ReadSingle() }); + } + + return list; + } + } + } + + // Composed after ListClaimResolver: once that resolver has answered FbArm with the standard formatter the composite never + // asks this one, so its claim on the same type must not refuse the document. + public sealed class FbLaterClaimResolver : IFormatterResolver + { + public static readonly IFormatterResolver Instance = new FbLaterClaimResolver(); + + public IMessagePackFormatter? GetFormatter() => typeof(T) == typeof(FbArm) ? (IMessagePackFormatter)(object)new LaterFormatter() : null; + + private sealed class LaterFormatter : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, FbArm value, MessagePackSerializerOptions options) => writer.WriteNil(); + + public FbArm Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + reader.Skip(); + return new FbArm(); + } + } + } + + [Test] + public void OptionsRefuseANullDocumentTypeOrAssembly() + { + Assert.Throws(() => CultDocumentMessagePackSerialization.OptionsFor(null!)); + Assert.Throws(() => CultDocumentMessagePackSerialization.OptionsForDocument(null!)); + } + + [Union(0, typeof(ClaimArm))] + public abstract class ClaimNode { } + + [MessagePackObject] + public sealed class ClaimArm : ClaimNode + { + [Key(0)] [CultElementId] public string Id = string.Empty; + [Key(1)] public int N; + } + + [CultDocument("tests.claim_doc", "tests.claim_doc.v1")] + [MessagePackObject] + public sealed class ClaimDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public List Items = new(); + } + + public sealed class ClaimArmResolver : IFormatterResolver + { + public static readonly IFormatterResolver Instance = new ClaimArmResolver(); + public IMessagePackFormatter? GetFormatter() => typeof(T) == typeof(ClaimArm) ? (IMessagePackFormatter)(object)new ClaimFormatter() : null; + + private sealed class ClaimFormatter : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, ClaimArm value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(1); + writer.Write(value.N); + } + + public ClaimArm Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + reader.ReadArrayHeader(); + return new ClaimArm { N = reader.ReadInt32() }; + } + } + } + // The guard wraps the composite, so the composite's own order is still the contract: a consumer resolver outranks the // document resolver (CultRecordRef) and MessagePack's own object formatter, here for a type it also knows. [Test] @@ -393,7 +925,11 @@ namespace GameCult.Caching.Tests (shapes ?? List())(ref w); }); - private static Emit CircleArm(int key, int radius) => InnerArm(key, radius); + private static Emit CircleArm(int key, int radius) => (ref MessagePackWriter w) => + { + w.WriteArrayHeader(2); w.Write(key); + w.WriteArrayHeader(2); w.Write(radius); w.Write("0000000000a1"); + }; [TestCase(1)] [TestCase(3)] @@ -426,7 +962,7 @@ namespace GameCult.Caching.Tests [Test] public void KnownArmsOfAnInterfaceUnionByteIdentical() { - var doc = new ShapeDoc { Id = "s1", Shape = new Square { N = 4 }, Shapes = { new Circle { N = 2 }, null! } }; + var doc = new ShapeDoc { Id = "s1", Shape = new Square { N = 4, Id = "0000000000a1" }, Shapes = { new Circle { N = 2, Id = "0000000000a1" }, null! } }; var expected = Shapes(CircleArm(2, 4), List(CircleArm(0, 2), Nil)); Assert.That(Convert.ToHexString(CultDocumentMessagePackSerialization.SerializeUntyped(doc, typeof(ShapeDoc), Registry)), Is.EqualTo(Convert.ToHexString(expected))); Assert.That(Convert.ToHexString(MessagePackSerializer.Serialize(doc, MessagePackSerializerOptions.Standard)), Is.EqualTo(Convert.ToHexString(expected))); @@ -455,13 +991,13 @@ namespace GameCult.Caching.Tests new CultPersistedRecord { Key = "base", SchemaId = descriptor.SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z", - Payload = Doc(LeafArm(3, "r", "ok", 1)) + Payload = Doc(LeafArm(3, "000000000005", "ok", 1)) }, new CultPersistedRecord { Key = "variant", SchemaId = descriptor.SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z", Payload = Array.Empty(), - Variant = new CultVariantDelta("base", new[] { CultVariantOverride.Set(1, Raw(LeafArm(4, "r", Canary, 7))) }) + Variant = new CultVariantDelta("base", new[] { CultVariantOverride.Set(1, Raw(LeafArm(4, "000000000005", Canary, 7))) }) } } })); @@ -495,14 +1031,14 @@ namespace GameCult.Caching.Tests try { var path = Path.Combine(folder, "guard.cc"); - var genuine = Doc(LeafArm(3, "r", Canary, 7)); + var genuine = Doc(LeafArm(3, "000000000005", Canary, 7)); using (var writer = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory, StoreFlushOnDispose = true })) { - await writer.UpsertAsync(typeof(GuardDoc), new GuardDoc { Id = "d1", Root = new Leaf { Id = "r", Content = Canary, Weight = 7 } }, new CultRecordKey("d1")); + await writer.UpsertAsync(typeof(GuardDoc), new GuardDoc { Id = "d1", Root = new Leaf { Id = "000000000005", Content = Canary, Weight = 7 } }, new CultRecordKey("d1")); await writer.FlushAsync(); } - var forged = Doc(LeafArm(key, "r", Canary, 7)); + var forged = Doc(LeafArm(key, "000000000005", Canary, 7)); Assert.That(forged.Length, Is.EqualTo(genuine.Length)); // A directory store names a page by the SHA-256 of its bytes and its manifest commits to that hash, so a forged // page is renamed and the manifest told its new hash: the store then holds a well-formed page the reader must refuse. @@ -561,13 +1097,13 @@ namespace GameCult.Caching.Tests } // An arm is [key, armSlots]; the keys here start at 3, skip 0-2, 4, 6 and 7, and never reuse a retired key. - [Union(3, typeof(Leaf)), Union(5, typeof(Branch)), Union(8, typeof(Unit8)), Union(9, typeof(StubArm))] + [Union(3, typeof(Leaf)), Union(5, typeof(Branch)), Union(8, typeof(Unit8)), Union(9, typeof(Nine))] public abstract class GuardNode { } [MessagePackObject] public class Leaf : GuardNode { - [Key(0)] public string Id = string.Empty; + [Key(0)] [CultElementId] public string Id = string.Empty; [Key(1)] public string Content = string.Empty; [Key(3)] public int Weight; } @@ -580,15 +1116,30 @@ namespace GameCult.Caching.Tests { [Key(0)] public List Children = new(); [Key(1)] public GuardNode? Next; + [Key(2)] [CultElementId] public string Id = string.Empty; } - [MessagePackObject] public sealed class Unit8 : GuardNode { } + [MessagePackObject] public sealed class Unit8 : GuardNode { [Key(0)] [CultElementId] public string Id = string.Empty; } + + [MessagePackObject] public sealed class Nine : GuardNode { [Key(0)] [CultElementId] public string Id = string.Empty; } // A subtype the union does not name. [MessagePackObject] public sealed class Rogue : GuardNode { [Key(0)] public string Note = string.Empty; } - // Written and read by a consumer resolver, not by MessagePack. - public sealed class StubArm : GuardNode { } + // Written and read by a consumer resolver, not by MessagePack. It can only sit outside a list: a list element is written + // by its [Key] layout, or its id never reaches disk (ListElementsClaimedByAResolverAreRefused). + [Union(9, typeof(StubArm))] + public abstract class StubNode { } + + public sealed class StubArm : StubNode { [Key(0)] [CultElementId] public string Id = string.Empty; } + + [CultDocument("tests.stub_doc", "tests.stub_doc.v1")] + [MessagePackObject] + public sealed class StubDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public StubNode? Root; + } public sealed class StubArmResolver : IFormatterResolver { @@ -638,8 +1189,8 @@ namespace GameCult.Caching.Tests [Union(0, typeof(Circle)), Union(2, typeof(Square))] public interface IShape { } - [MessagePackObject] public class Circle : IShape { [Key(0)] public int N; } - [MessagePackObject] public sealed class Square : IShape { [Key(0)] public int N; } + [MessagePackObject] public class Circle : IShape { [Key(0)] public int N; [Key(1)] [CultElementId] public string Id = string.Empty; } + [MessagePackObject] public sealed class Square : IShape { [Key(0)] public int N; [Key(1)] [CultElementId] public string Id = string.Empty; } [MessagePackObject] public sealed class Triangle : IShape { } [MessagePackObject] public sealed class BigCircle : Circle { } diff --git a/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs index 2561b028..9418a680 100644 --- a/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs @@ -65,7 +65,7 @@ namespace GameCult.Caching.Tests return File.ReadAllBytes(path); } - // ---- the fixture: a recursive union, a unit arm, a union inside a record inside a union, a list of unions ---- + // ---- the fixture: a recursive union, id-only arms, a union inside a record inside a union, a list of unions ---- // Keys 0, 1, 2, 5 and 6: 3 and 4 are retired, so the key map is not sequential. Wrap is the +2 nesting step beside // Box's +3, so any container depth from 4 up is reachable exactly. @@ -76,26 +76,27 @@ namespace GameCult.Caching.Tests public sealed class Wrap : Node { [Key(0)] public Node? Inner; + [Key(1)] [CultElementId] public string Id = string.Empty; } [MessagePackObject] public sealed class Box : Node { - [Key(0)] public string Id = string.Empty; + [Key(0)] [CultElementId] public string Id = string.Empty; [Key(1)] public List Children = new(); } [MessagePackObject] public sealed class Text : Node { - [Key(0)] public string Id = string.Empty; + [Key(0)] [CultElementId] public string Id = string.Empty; [Key(1)] public string Content = string.Empty; } [MessagePackObject] public sealed class Use : Node { - [Key(0)] public string Id = string.Empty; + [Key(0)] [CultElementId] public string Id = string.Empty; [Key(1)] public Composition Composition = new(); [Key(2)] public List Slots = new(); } @@ -104,7 +105,7 @@ namespace GameCult.Caching.Tests [MessagePackObject] public sealed class Image : Node { - [Key(0)] public string Id = string.Empty; + [Key(0)] [CultElementId] public string Id = string.Empty; [Key(1)] public string Uri = string.Empty; [Key(3)] public string Alt = string.Empty; } @@ -117,17 +118,17 @@ namespace GameCult.Caching.Tests [Key(1)] public Node? Body; } - // Floor and Ceiling are unit arms at the ends of the Int32 key domain. + // Floor and Ceiling are id-only arms at the ends of the Int32 key domain. [Union(0, typeof(Pending)), Union(1, typeof(Completed)), Union(2, typeof(Denied)), Union(int.MinValue, typeof(Floor)), Union(int.MaxValue, typeof(Ceiling))] public abstract class Outcome { } - [MessagePackObject] public sealed class Pending : Outcome { [Key(0)] public string Message = string.Empty; } - [MessagePackObject] public sealed class Completed : Outcome { [Key(0)] public int SourceVersion; } - // A unit arm: written [2, []]. - [MessagePackObject] public sealed class Denied : Outcome { } - [MessagePackObject] public sealed class Floor : Outcome { } - [MessagePackObject] public sealed class Ceiling : Outcome { } + [MessagePackObject] public sealed class Pending : Outcome { [Key(0)] public string Message = string.Empty; [Key(1)] [CultElementId] public string Id = string.Empty; } + [MessagePackObject] public sealed class Completed : Outcome { [Key(0)] public int SourceVersion; [Key(1)] [CultElementId] public string Id = string.Empty; } + // The arms of a list carry an id, so Denied, Floor and Ceiling are written [key, [id]], not as unit arms. + [MessagePackObject] public sealed class Denied : Outcome { [Key(0)] [CultElementId] public string Id = string.Empty; } + [MessagePackObject] public sealed class Floor : Outcome { [Key(0)] [CultElementId] public string Id = string.Empty; } + [MessagePackObject] public sealed class Ceiling : Outcome { [Key(0)] [CultElementId] public string Id = string.Empty; } // Item names Tool and Part under keys 1 and 2; CraftedItem, the inner union both derive from, names them under 0 and 1. [Union(0, typeof(Gem)), Union(1, typeof(Tool)), Union(2, typeof(Part))] @@ -161,24 +162,24 @@ namespace GameCult.Caching.Tests Version = 3, Root = root ?? new Box { - Id = "root", + Id = "0000000000c0", Children = { - new Text { Id = "t1", Content = "hello" }, + new Text { Id = "0000000000c1", Content = "hello" }, new Use { - Id = "u1", + Id = "0000000000c2", Composition = new Composition { Name = "comp", - Body = new Box { Id = "inner", Children = { new Text { Id = "deep", Content = "x" }, new Image { Id = "img", Uri = "uri://a", Alt = "alt" } } } + Body = new Box { Id = "0000000000d0", Children = { new Text { Id = "0000000000d1", Content = "x" }, new Image { Id = "0000000000d2", Uri = "uri://a", Alt = "alt" } } } }, - Slots = { new Text { Id = "s1", Content = "slot" }, null!, new Box { Id = "b2" } } + Slots = { new Text { Id = "0000000000e1", Content = "slot" }, null!, new Box { Id = "0000000000e2" } } }, - new Image { Id = "i2", Uri = "uri://b", Alt = "" } + new Image { Id = "0000000000c3", Uri = "uri://b", Alt = "" } } }, - Outcomes = { new Pending { Message = "wait" }, new Completed { SourceVersion = 7 }, new Denied() }, + Outcomes = { new Pending { Message = "wait", Id = "0000000000a1" }, new Completed { SourceVersion = 7, Id = "0000000000a2" }, new Denied { Id = "0000000000a3" } }, Part = new Tool { Name = "hammer", Uses = 12 }, Crafted = new Part { Name = "gear", Count = 4 }, Missing = null @@ -254,13 +255,13 @@ namespace GameCult.Caching.Tests private static byte[] Surface(Emit? root = null, Emit? outcomes = null, Emit? crafted = null, Extra? extra = null, Emit? trailing = null) { extra ??= new Extra(); - root ??= BoxArm("root", Slots( - TextArm("t1", "hello"), - ArmOf(2, Str("u1"), - Plus(extra.Composition, Str("comp"), BoxArm("inner", Slots(TextArm("deep", "x"), ImageArm("img", "uri://a", "alt")))), - Slots(TextArm("s1", "slot", extra.ListElement), Nil, BoxArm("b2", Slots()))), - ImageArm("i2", "uri://b", "")), extra.RootArm); - outcomes ??= Slots(ArmOf(0, Str("wait")), ArmOf(1, Num(7)), ArmOf(2)); + root ??= BoxArm("0000000000c0", Slots( + TextArm("0000000000c1", "hello"), + ArmOf(2, Str("0000000000c2"), + Plus(extra.Composition, Str("comp"), BoxArm("0000000000d0", Slots(TextArm("0000000000d1", "x"), ImageArm("0000000000d2", "uri://a", "alt")))), + Slots(TextArm("0000000000e1", "slot", extra.ListElement), Nil, BoxArm("0000000000e2", Slots()))), + ImageArm("0000000000c3", "uri://b", "")), extra.RootArm); + outcomes ??= Slots(ArmOf(0, Str("wait"), Str("0000000000a1")), ArmOf(1, Num(7), Str("0000000000a2")), ArmOf(2, Str("0000000000a3"))); crafted ??= ArmOf(1, Str("gear"), Num(4)); var slots = new[] { Str("surface"), Num(3), root, outcomes, ArmOf(1, Str("hammer"), Num(12)), crafted, Nil }; return Raw(Slots(trailing != null ? slots.Append(trailing).ToArray() : extra.Record ? slots.Append(ExtraSlot).ToArray() : slots)); @@ -281,9 +282,9 @@ namespace GameCult.Caching.Tests new Case("unknown-arm-field.msgpack", "refused", "Node", 3, "unknown-arm", Surface(root: ArmOf(3, Str("r"), Str(Canary)))), new Case("unknown-arm-list.msgpack", "refused", "Outcome", 7, "unknown-arm", - Surface(outcomes: Slots(ArmOf(0, Str("wait")), ArmOf(7, Str(Canary))))), + Surface(outcomes: Slots(ArmOf(0, Str("wait"), Str("0000000000a1")), ArmOf(7, Str(Canary))))), new Case("unknown-arm-nested.msgpack", "refused", "Node", 9, "unknown-arm", - Surface(root: BoxArm("root", Slots(BoxArm("l1", Slots(BoxArm("l2", Slots(ArmOf(9, Str("deep"), Str(Canary)))))))))), + Surface(root: BoxArm("0000000000c0", Slots(BoxArm("0000000000d3", Slots(BoxArm("0000000000d4", Slots(ArmOf(9, Str("deep"), Str(Canary)))))))))), new Case("inner-union-outer-key.msgpack", "refused", "CraftedItem", 2, "unknown-arm", Surface(crafted: ArmOf(2, Str("gear"), Num(4)))), new Case("arm-arity.msgpack", "refused", "Node", 1, "arity", @@ -327,6 +328,41 @@ namespace GameCult.Caching.Tests return string.Join(" <- ", parts); } + // ---- unit arms: a union no list reaches, so its arms carry no element id and a member-less arm is a unit arm ---- + + [Union(0, typeof(Waiting)), Union(2, typeof(Denied2)), Union(int.MinValue, typeof(Floor2)), Union(int.MaxValue, typeof(Ceiling2))] + public abstract class UnitOutcome { } + + [MessagePackObject] public sealed class Waiting : UnitOutcome { [Key(0)] public string Message = string.Empty; } + [MessagePackObject] public sealed class Denied2 : UnitOutcome { } + [MessagePackObject] public sealed class Floor2 : UnitOutcome { } + [MessagePackObject] public sealed class Ceiling2 : UnitOutcome { } + + private static MessagePackSerializerOptions UnitOptions => CultDocumentMessagePackSerialization.OptionsFor(typeof(UnionSurface).Assembly); + + // The reference writes a member-less arm as [key, []]; the other runtimes read these files and write the same bytes. + [Test] + public void UnitArmVectorsAreWhatTheReferenceWrites() + { + var arms = new (string File, UnitOutcome Value, int Key, string Name)[] + { + ("unit-arm-denied.msgpack", new Denied2(), 2, nameof(Denied2)), + ("unit-arm-floor.msgpack", new Floor2(), int.MinValue, nameof(Floor2)), + ("unit-arm-ceiling.msgpack", new Ceiling2(), int.MaxValue, nameof(Ceiling2)), + }; + foreach (var (file, value, key, name) in arms) + { + var written = MessagePackSerializer.Serialize(value, UnitOptions); + Assert.That(Convert.ToHexString(written), Is.EqualTo(Convert.ToHexString(Raw(ArmOf(key)))), $"{name}: a unit arm is [key, []]"); + Check(file, written); + + var committed = Committed(file); + var decoded = MessagePackSerializer.Deserialize(committed, UnitOptions); + Assert.That(decoded.GetType().Name, Is.EqualTo(name), file); + Assert.That(Convert.ToHexString(MessagePackSerializer.Serialize(decoded, UnitOptions)), Is.EqualTo(Convert.ToHexString(committed)), $"{file} re-encodes byte-identical"); + } + } + // ---- tests ---- [Test] @@ -387,7 +423,7 @@ namespace GameCult.Caching.Tests var inner = (Box)use.Composition.Body!; Assert.Multiple(() => { - Assert.That(value.Outcomes.OfType().Count(), Is.EqualTo(1), "a unit arm"); + Assert.That(value.Outcomes.OfType().Count(), Is.EqualTo(1), "an id-only arm"); Assert.That(root.Children, Has.Count.GreaterThan(1), "a list of unions"); Assert.That(use.Slots.Any(s => s is Box) && use.Slots.Any(s => s is null), Is.True, "a list of unions holding a nested union arm and nil"); Assert.That(inner.Children.OfType().Count(), Is.EqualTo(1), "a union inside a record inside a union, four arms deep"); @@ -460,30 +496,30 @@ namespace GameCult.Caching.Tests Slots(slots)(ref w); }; - private static Emit WithOutcome(Emit element) => Slots(ArmOf(0, Str("wait")), ArmOf(1, Num(7)), ArmOf(2), element); + private static Emit WithOutcome(Emit element) => Slots(ArmOf(0, Str("wait"), Str("0000000000a1")), ArmOf(1, Num(7), Str("0000000000a2")), ArmOf(2, Str("0000000000a3")), element); - private static Emit WrapChain(int wraps, Emit leaf) => wraps == 0 ? leaf : ArmOf(6, WrapChain(wraps - 1, leaf)); + private static Emit WrapChain(int wraps, Emit leaf) => wraps == 0 ? leaf : ArmOf(6, WrapChain(wraps - 1, leaf), Str("0000000000b1")); private static Node WrapChainValue(int wraps, Node leaf) { - for (var i = 0; i < wraps; i++) leaf = new Wrap { Inner = leaf }; + for (var i = 0; i < wraps; i++) leaf = new Wrap { Inner = leaf, Id = "0000000000b1" }; return leaf; } // n arrays nested inside one another around a single string. private static Emit Nested(int n) => n == 1 ? Slots(Str(Canary)) : Slots(Nested(n - 1)); - private static Emit AtLimitLeaf => BoxArm("leaf", Slots()); - private static Emit PastLimitLeaf => TextArm("leaf", Canary); + private static Emit AtLimitLeaf => BoxArm("0000000000f0", Slots()); + private static Emit PastLimitLeaf => TextArm("0000000000f0", Canary); private static IReadOnlyList BoundsCases() => new[] { new Case("arm-key-int32-min.msgpack", "accepted", "Outcome", int.MinValue, "arm-key-int32-min.msgpack", - Surface(outcomes: WithOutcome(ArmOf(int.MinValue)))), + Surface(outcomes: WithOutcome(ArmOf(int.MinValue, Str("0000000000a4"))))), new Case("arm-key-int32-max.msgpack", "accepted", "Outcome", int.MaxValue, "arm-key-int32-max.msgpack", - Surface(outcomes: WithOutcome(ArmOf(int.MaxValue)))), + Surface(outcomes: WithOutcome(ArmOf(int.MaxValue, Str("0000000000a5"))))), new Case("arm-key-wide-coding.msgpack", "accepted", "Outcome", int.MaxValue, "arm-key-int32-max.msgpack", - Surface(outcomes: WithOutcome(RawKey((ref MessagePackWriter w) => w.WriteUInt64(int.MaxValue))))), + Surface(outcomes: WithOutcome(RawKey((ref MessagePackWriter w) => w.WriteUInt64(int.MaxValue), Str("0000000000a5"))))), new Case("arm-key-above-int32.msgpack", "refused", "Outcome", null, "key-out-of-range", Surface(outcomes: WithOutcome(RawKey((ref MessagePackWriter w) => w.Write(0x80000000u))))), new Case("arm-key-below-int32.msgpack", "refused", "Outcome", null, "key-out-of-range", @@ -649,7 +685,7 @@ namespace GameCult.Caching.Tests var payload = Committed("depth-at-limit.msgpack"); Assert.That(DepthOf(payload), Is.EqualTo(Limit), "the vector nests exactly the limit, counted independently"); Assert.That(Convert.ToHexString(SerializeValue(Decode(payload))), Is.EqualTo(Convert.ToHexString(payload)), "read, then written again"); - var built = Constructed(WrapChainValue(AtLimitWraps, new Box { Id = "leaf" })); + var built = Constructed(WrapChainValue(AtLimitWraps, new Box { Id = "0000000000f0" })); Assert.That(Convert.ToHexString(SerializeValue(built)), Is.EqualTo(Convert.ToHexString(payload)), "the constructed value writes the vector"); } @@ -662,7 +698,7 @@ namespace GameCult.Caching.Tests Assert.That(read, Does.Contain(typeof(UnionSurface).FullName).And.Contain($"the limit is {Limit}").And.Contain("refused on read").And.Not.Contain(Canary)); byte[]? written = null; - var write = Chain(Refusal(() => written = SerializeValue(Constructed(WrapChainValue(PastLimitWraps, new Text { Id = "leaf", Content = Canary }))))); + var write = Chain(Refusal(() => written = SerializeValue(Constructed(WrapChainValue(PastLimitWraps, new Text { Id = "0000000000f0", Content = Canary }))))); Assert.That(written, Is.Null, "nothing is returned"); Assert.That(write, Does.Contain(typeof(UnionSurface).FullName).And.Contain($"the limit is {Limit}").And.Contain("refused on write").And.Not.Contain(Canary)); diff --git a/tests/GameCult.Caching.Tests/VariantTests.cs b/tests/GameCult.Caching.Tests/VariantTests.cs index 7d3a80f3..8f574cd1 100644 --- a/tests/GameCult.Caching.Tests/VariantTests.cs +++ b/tests/GameCult.Caching.Tests/VariantTests.cs @@ -181,6 +181,14 @@ namespace GameCult.Caching.Tests var seen = a.Get(BigKey)!; EditBase(b, gear => gear.Tags.Add("pierce")); + // Expect tests the variant's stored delta only, and it did not change, so the condition holds and the write goes on: the + // store holds a variant and moved, so it is refused and reloaded as a write conflict. It is not a mismatch (false). + Assert.Throws(() => a.Commit(batch => + { + batch.Expect(BigKey, seen); + batch.Upsert(typeof(VariantOther), new VariantOther { Name = "marker" }, new CultRecordKey("marker")); + })); + seen = a.Get(BigKey)!; Assert.That(a.Commit(batch => { batch.Expect(BigKey, seen); @@ -189,6 +197,7 @@ namespace GameCult.Caching.Tests a.PullAllBackingStoresAsync(); seen = a.Get(BigKey)!; + b.PullAllBackingStoresAsync(); SeedBig(b, power: 26); Assert.That(a.Commit(batch => { @@ -241,8 +250,10 @@ namespace GameCult.Caching.Tests SeedBig(cache); cache.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "other" }, new CultRecordKey("other"))); - var rebase = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, new CultRecordKey("other")))); - Assert.That(rebase.Message, Does.Contain(BigKey.Value).And.Contain("VariantOther")); + var rebase = Assert.Throws(() => cache.Commit(batch => batch.UpsertVariant(BigKey, new CultRecordKey("other"))))!; + // Held in a store, the key's record is a VariantGear and the write is a VariantOther: the ownership rule refuses it first + // (variants:ruling:cache-refuses-retype), so this refusal no longer names the types. + Assert.That(rebase.Message, Does.Contain(BigKey.Value)); } // A store whose variant record names another type than its base's: the loader refuses it naming both keys. @@ -271,6 +282,24 @@ namespace GameCult.Caching.Tests Assert.That(load.Message, Does.Contain("other-variant").And.Contain("gear-base")); } + // Rebasing a variant onto a base of another type would retype its key: refused with or without a store, the record untouched. + [Test] + public void ARebaseOntoABaseOfAnotherTypeIsRefusedWithoutAStore() + { + using var cache = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry)); + SeedBase(cache); + SeedBig(cache); + var other = new CultRecordKey("other"); + cache.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "other" }, other)); + + var rebase = Assert.Throws(() => cache.Commit(batch => batch.UpsertVariant(BigKey, other)))!; + + Assert.That(rebase.RecordKey, Is.EqualTo(BigKey.Value)); + Assert.That(cache.GetStored(BigKey)!.Variant!.BaseKey, Is.EqualTo(BaseKey.Value)); + Assert.That(cache.GetStored(BigKey)!.Descriptor.DocumentType, Is.EqualTo(typeof(VariantGear))); + Assert.Throws(() => cache.UpsertVariantAsync(BigKey, other).GetAwaiter().GetResult()); + } + [Test] public void AVariantOfAGlobalIsRefused() { @@ -665,34 +694,301 @@ namespace GameCult.Caching.Tests Assert.That(reopened.Get(other), Is.Not.Null); } - // ---- a variant sheds overrides its type lacks when it is flushed, as a plain record sheds the member ---- - + // A store holding a variant is written only from what its writer last read. Another writer adds a variant whose indexed value + // this cache's staged edit of its base now takes: the write is refused whole, the cache reloads what it refused and what moved, + // and the retry is judged by the cache's own rules. [Test] - public void AFlushShedsAnOverrideOfADroppedSlotSoALaterTypeReusingTheSlotDoesNotResurrectIt() + public void AWriteIntoAVariantStoreAnotherWriterChangedIsRefusedReloadedAndJudgedOnRetry([Values] bool viaCommit) { - var path = PathOf("drift.cc"); - var wide = CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) }); - using (var writer = Open(path, registry: wide)) + var path = PathOf(viaCommit ? "judged-commit.cc" : "judged-flush.cc"); + using var cache = Open(path); + SeedBase(cache); + using (var other = Open(path)) + SeedBig(other); + var edited = Laser(); + edited.Code = "laser big"; + cache.UpsertAsync(typeof(VariantGear), edited, BaseKey).GetAwaiter().GetResult(); + var before = File.ReadAllBytes(path); + + var conflict = Assert.Throws(() => { - writer.Commit(batch => + if (viaCommit) + cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("n"), new CultRecordKey("n"))); + else + cache.FlushAllBackingStores(); + })!; + + Assert.That(conflict.RecordKeys, Does.Contain(BaseKey.Value)); + Assert.That(conflict.ChangedKeys, Does.Contain(BigKey.Value)); + Assert.That(cache.Get(BaseKey)!.Code, Is.EqualTo("l1"), "the refused edit is not served"); + Assert.That(cache.Get(BigKey), Is.Not.Null, "the moved record is loaded"); + Assert.That(cache.IsDirty, Is.False); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "nothing was written"); + Assert.That(Refused(() => cache.UpsertAsync(typeof(VariantGear), edited, BaseKey).GetAwaiter().GetResult()).Message, + Does.Contain(BigKey.Value), "the retry is judged by the cache's own index rule"); + } + + // A refused base removal leaves the cache serving the store, writable, and judging the next attempt itself. + [Test] + public void ARefusedBaseRemovalReloadsAndLeavesTheCacheWritable() + { + var path = PathOf("gear.cc"); + var n = new CultRecordKey("n"); + using var a = Open(path); + SeedBase(a); + using (var b = Open(path)) + SeedBig(b); + a.Remove(BaseKey); + + var conflict = Assert.Throws(() => a.FlushAllBackingStores())!; + + Assert.That(conflict.RecordKeys, Is.EqualTo(new[] { BaseKey.Value })); + Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { BigKey.Value })); + Assert.That(a.Get(BaseKey), Is.Not.Null); + Assert.That(a.Get(BigKey), Is.Not.Null); + Assert.That(a.IsDirty, Is.False); + Assert.DoesNotThrow(() => a.FlushAllBackingStores()); + Assert.DoesNotThrow(() => a.Commit(batch => batch.Upsert(typeof(VariantGear), Named("n", "nn"), n))); + Assert.That(Refused(() => a.Remove(BaseKey)).Message, Does.Contain(BigKey.Value), "a base with variants is refused by this cache's own rule"); + + using var reopened = Open(path); + Assert.That(reopened.Get(n), Is.Not.Null); + Assert.That(reopened.Get(BigKey), Is.Not.Null); + Assert.That(reopened.Get(BaseKey), Is.Not.Null); + } + + // A held record another writer changed is not trusted: the write is refused although this cache never staged it. + [Test] + public void AWriteIsRefusedWhenAnotherWriterChangedAVariantThisCacheHolds() + { + var path = PathOf("gear.cc"); + using var a = Open(path); + SeedBase(a); + SeedBig(a); + using (var b = Open(path)) + b.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] { - batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base")); - batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[] - { - writer.Override(nameof(DriftWide.Name), "drift variant"), - writer.Override(nameof(DriftWide.Wing), "stale wing") - }); + b.Override(nameof(VariantGear.Name), "laser big"), + b.Override(nameof(VariantGear.Power), 99), + b.Override(nameof(VariantGear.Code), "laser big") + })); + Assert.That(a.Get(BigKey)!.Power, Is.EqualTo(25)); + + var conflict = Assert.Throws(() => + a.Commit(batch => batch.Upsert(typeof(VariantGear), Named("y", "yy"), new CultRecordKey("y"))))!; + + Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { BigKey.Value })); + Assert.That(a.Get(BigKey)!.Power, Is.EqualTo(99), "the cache serves the other writer's override after the refusal"); + } + + // A write that would add a variant is judged like one into a variant store: a plain store another writer changed refuses it. + [Test] + public void AVariantWrittenIntoAPlainStoreAnotherWriterChangedIsRefusedThenLandsOnRetry() + { + var path = PathOf("plain-then-variant.cc"); + var z = new CultRecordKey("z"); + using (var seed = Open(path)) + SeedBase(seed); + using (var a = Open(path)) + { + using (var b = Open(path)) + b.Commit(batch => batch.Upsert(typeof(VariantGear), Other("zc"), z)); + + var conflict = Assert.Throws(() => SeedBig(a))!; + + Assert.That(conflict.RecordKeys, Does.Contain(BigKey.Value)); + Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { z.Value })); + Assert.That(a.Get(z), Is.Not.Null, "the refusal reloaded the record that moved"); + Assert.DoesNotThrow(() => SeedBig(a)); + } + + using var reopened = Open(path); + Assert.That(reopened.Get(BigKey), Is.Not.Null); + Assert.That(reopened.Get(z), Is.Not.Null); + } + + // A plain write copies another writer's record forward without reading it: this cache has still not read it, so a variant + // written next is refused. + [Test] + public void ARecordACachePlainWriteCopiedForwardIsStillUnreadWhenAVariantIsWrittenNext() + { + var path = PathOf("copied-unread.cc"); + var z = new CultRecordKey("z"); + using (var seed = Open(path)) + SeedBase(seed); + using var a = Open(path); + using (var b = Open(path)) + b.Commit(batch => batch.Upsert(typeof(VariantGear), Other("zc"), z)); + a.Commit(batch => batch.Upsert(typeof(VariantGear), Other("yc"), new CultRecordKey("y"))); + + var conflict = Assert.Throws(() => SeedBig(a))!; + + Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { z.Value })); + } + + // ---- a write copies what it did not stage, and re-encodes what it stages under the type's own id ---- + + private static readonly string DriftWideId = CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) }).GetRequired(typeof(DriftWide)).SchemaId; + + // The same schema name as Wide, a later version that lists Wide's id as compatible and has no Wing. + private static readonly Type DriftDeclared = EmittedDocumentTypes.Emit( + "DriftDeclared", + "tests.variant_drift", + "tests.variant_drift.v4", + new[] { new EmittedDocumentTypes.Field("Name", typeof(string), 0, IsName: true), new EmittedDocumentTypes.Field("Power", typeof(int), 1) }, + new[] { DriftWideId }); + + private static readonly CultRecordKey DriftBase = new("drift-base"); + private static readonly CultRecordKey DriftVariant = new("drift-variant"); + + private static CultPersistedStoreSnapshot ReadStore(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + + private void SeedDrift(string path) + { + using var writer = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) })); + writer.Commit(batch => + { + batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, DriftBase); + batch.UpsertVariant(DriftVariant, DriftBase, new[] + { + writer.Override(nameof(DriftWide.Name), "drift variant"), + writer.Override(nameof(DriftWide.Wing), "stale wing") + }); + }); + } + + // Narrow reads Wide's records through the schema name and neither owns nor declares Wide's id. A write of another record + // copies them as Wide stored them: bytes, id, storedAt, the override of the slot Narrow lacks, and the entry that publishes + // Wide's id. + [Test] + public void AWriteOfAnotherRecordLeavesAVariantItsTypeHasNotDeclaredAsStored([Values] bool viaFlush) + { + var path = PathOf(viaFlush ? "drift-untouched-flush.cc" : "drift-untouched-commit.cc"); + SeedDrift(path); + var before = ReadStore(path); + var wideEntryBefore = StoreSlices.CatalogEntry(path, DriftWideId); + + using (var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }))) + { + Assert.That(narrow.Get(DriftVariant)!.Name, Is.EqualTo("drift variant"), "Narrow reads the variant"); + var other = new CultRecordKey("other"); + if (viaFlush) + { + narrow.UpsertAsync(typeof(DriftNarrow), new DriftNarrow { Name = "other" }, other).GetAwaiter().GetResult(); + narrow.FlushAllBackingStores(); + } + else + narrow.Commit(batch => batch.Upsert(typeof(DriftNarrow), new DriftNarrow { Name = "other" }, other)); + } + + var after = ReadStore(path); + foreach (var key in new[] { DriftBase.Value, DriftVariant.Value }) + { + var was = before.Records.Single(record => record.Key == key); + var is_ = after.Records.Single(record => record.Key == key); + Assert.That((is_.SchemaId, is_.StoredAt, is_.Payload), Is.EqualTo((was.SchemaId, was.StoredAt, was.Payload)), key); + } + + Assert.That(OverriddenSlotsOn(path, DriftVariant.Value).OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 }), "the dropped slot's override is still stored"); + Assert.That(StoreSlices.CatalogEntry(path, DriftWideId), + Is.EqualTo(wideEntryBefore)); + } + + // A type that lists Wide's id as compatible owns Wide's records: staging one writes it under the type's own id, at a later + // storedAt, without the member the type dropped, so a later type reusing that slot does not resurrect it. + [Test] + public void AStagedWriteOfADeclaredCompatibleRecordIsReEncodedUnderTheTypesOwnIdAndShedsTheDroppedSlot() + { + var path = PathOf("drift-declared.cc"); + SeedDrift(path); + var wideBase = ReadStore(path).Records.Single(record => record.Key == DriftBase.Value); + var ownId = CultDocumentRegistry.ForTypes(new[] { DriftDeclared }).GetRequired(DriftDeclared).SchemaId; + + using (var declared = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { DriftDeclared }))) + { + declared.Commit(batch => + { + batch.Upsert(DriftDeclared, declared.Get(DriftBase)!, DriftBase); + batch.UpsertVariant(DriftVariant, DriftBase, new[] { declared.Override(DriftDeclared, "Name", "drift variant") }); }); } - using (var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }))) - narrow.BackingStores[0].PushAll(); - - Assert.That(OverriddenSlotsOn(path, "drift-variant"), Is.EqualTo(new[] { 0 }), "the dropped slot's override is gone from the flushed record"); + var written = ReadStore(path); + Assert.That(written.Records.Select(record => record.SchemaId), Is.All.EqualTo(ownId)); + Assert.That(string.CompareOrdinal(written.Records.Single(record => record.Key == DriftBase.Value).StoredAt, wideBase.StoredAt), Is.GreaterThan(0)); + Assert.That(OverriddenSlotsOn(path, DriftVariant.Value), Is.EqualTo(new[] { 0 }), "the dropped slot's override is gone from the written record"); using var reused = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftReused) })); - Assert.That(reused.Get(new CultRecordKey("drift-base"))!.Colour, Is.Empty); - Assert.That(reused.Get(new CultRecordKey("drift-variant"))!.Colour, Is.Empty, "the variant inherits its base's colour, not the old wing"); + Assert.That(reused.Get(DriftBase)!.Colour, Is.Empty); + Assert.That(reused.Get(DriftVariant)!.Colour, Is.Empty, "the variant inherits its base's colour, not the old wing"); + } + + public enum UndeclaredWrite + { + UpsertBase, + UpsertVariant, + FlattenVariant, + RemoveBase, + RemoveVariant + } + + // Narrow reads Wide's records through the schema name and does not list Wide's id, so they are read-only to it: a staged write, + // flatten or removal of either is refused typed, and the store keeps them as Wide stored them, the dropped slot's override too. + [Test] + public void AnUndeclaredDroppedSlotRecordThatIsWrittenIsRefused([Values] UndeclaredWrite write) + { + var path = PathOf("drift-undeclared-" + write + ".cc"); + SeedDrift(path); + var before = File.ReadAllBytes(path); + + using (var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }))) + { + var key = write is UndeclaredWrite.UpsertBase or UndeclaredWrite.RemoveBase ? DriftBase : DriftVariant; + var refusal = Assert.Throws(() => + { + switch (write) + { + case UndeclaredWrite.UpsertBase: + narrow.Commit(batch => batch.Upsert(typeof(DriftNarrow), new DriftNarrow { Name = "changed" }, DriftBase)); + break; + case UndeclaredWrite.UpsertVariant: + narrow.Commit(batch => batch.UpsertVariant(DriftVariant, DriftBase, new[] { narrow.Override(nameof(DriftNarrow.Name), "changed") })); + break; + case UndeclaredWrite.FlattenVariant: + narrow.FlattenAsync(DriftVariant).GetAwaiter().GetResult(); + break; + case UndeclaredWrite.RemoveBase: + narrow.Commit(batch => batch.Remove(DriftBase)); + break; + case UndeclaredWrite.RemoveVariant: + narrow.Commit(batch => batch.Remove(DriftVariant)); + break; + } + })!; + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((key.Value, DriftWideId))); + narrow.FlushAllBackingStores(); + } + + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + Assert.That(OverriddenSlotsOn(path, DriftVariant.Value).OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 })); + } + + // Only the read-only key is read-only: a variant of a read-only base, written at a new key, is a write of the variant alone and lands. + [Test] + public void AVariantOfAReadOnlyBaseLands() + { + var path = PathOf("drift-read-only-base.cc"); + SeedDrift(path); + var fresh = new CultRecordKey("fresh-variant"); + var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }); + + using (var narrow = Open(path, registry: narrowRegistry)) + narrow.Commit(batch => batch.UpsertVariant(fresh, DriftBase, new[] { narrow.Override(nameof(DriftNarrow.Name), "fresh") })); + + using var reopened = Open(path, registry: narrowRegistry); + Assert.That(reopened.Get(fresh)!.Name, Is.EqualTo("fresh")); + Assert.That(ReadStore(path).Records.Single(record => record.Key == DriftBase.Value).SchemaId, Is.EqualTo(DriftWideId)); } [Test] @@ -742,7 +1038,7 @@ namespace GameCult.Caching.Tests } [Test] - public void AnOverrideOfASlotTheTypeNeverHadIsReportedOnLoadAndShedOnTheNextFlush() + public void AnOverrideOfASlotTheTypeNeverHadIsReportedOnEveryLoadAndStaysStoredUntilTheVariantIsWritten() { var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base", CultVariantOverride.Set(99, new byte[] { 0x01 }), @@ -757,10 +1053,19 @@ namespace GameCult.Caching.Tests cache.BackingStores[0].PushAll(); } - Assert.That(OverriddenSlotsOn(path, "hand-variant").OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 })); - using var reopened = Open(path); - Assert.That(reopened.BackingStores[0].LastSchemaMigrationReports.SelectMany(report => report.IgnoredExtraSlots), Is.Empty); - Assert.That(reopened.Get(new CultRecordKey("hand-variant"))!.Name, Is.EqualTo("hand variant")); + Assert.That(OverriddenSlotsOn(path, "hand-variant").OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2, 99 }), "a flush that staged nothing copies the variant as stored"); + using (var reopened = Open(path)) + { + Assert.That(reopened.BackingStores[0].LastSchemaMigrationReports.SelectMany(report => report.IgnoredExtraSlots), Is.EqualTo(new[] { 99 })); + Assert.That(reopened.Get(new CultRecordKey("hand-variant"))!.Name, Is.EqualTo("hand variant")); + reopened.Commit(batch => batch.UpsertVariant(new CultRecordKey("hand-variant"), new CultRecordKey("hand-base"), new[] + { + reopened.Override(nameof(VariantGear.Name), "hand variant"), + reopened.Override(nameof(VariantGear.Code), "hand-code") + })); + } + + Assert.That(OverriddenSlotsOn(path, "hand-variant").OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 }), "a write of the variant stores only what it was given"); } private static int[] OverriddenSlotsOn(string path, string key) => @@ -904,12 +1209,16 @@ namespace GameCult.Caching.Tests a.Override(nameof(VariantGear.Code), "shared") })); - var refused = Refused(() => b.Commit(batch => + void Write() => b.Commit(batch => { batch.Expect(other, null); batch.Upsert(typeof(VariantGear), Named("other", "shared"), other); - })); - Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("shared")); + }); + + var conflict = Assert.Throws(Write)!; + Assert.That(conflict.ChangedKeys, Does.Contain(BigKey.Value)); + var refused = Refused(Write); + Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("shared"), "the retry is judged by Resolve"); Assert.That(b.Get(other), Is.Null, "nothing landed in memory"); } @@ -931,12 +1240,16 @@ namespace GameCult.Caching.Tests var seen = b.Get(BaseKey)!; SeedBig(a); - var refused = Refused(() => b.Commit(batch => + void Write() => b.Commit(batch => { batch.Expect(BaseKey, seen); batch.Remove(BaseKey); - })); - Assert.That(refused.Message, Does.Contain(BaseKey.Value).And.Contain(BigKey.Value)); + }); + + var conflict = Assert.Throws(Write)!; + Assert.That(conflict.ChangedKeys, Does.Contain(BigKey.Value)); + var refused = Refused(Write); + Assert.That(refused.Message, Does.Contain(BaseKey.Value).And.Contain(BigKey.Value), "the retry is judged by Resolve"); Assert.That(b.Get(BaseKey), Is.Not.Null, "nothing landed in memory"); } @@ -963,7 +1276,7 @@ namespace GameCult.Caching.Tests using (var w = Open(path)) Assert.That(w.Commit(batch => batch.Upsert(typeof(VariantGear), Named("x", "l1"), BaseKey)), Is.True); - var refused = Refused(() => a.Commit(batch => + var refused = Assert.Throws(() => a.Commit(batch => { batch.Expect(other, a.Get(other)); batch.UpsertVariant(BigKey, BaseKey, new[] @@ -971,8 +1284,9 @@ namespace GameCult.Caching.Tests a.Override(nameof(VariantGear.Name), "x"), a.Override(nameof(VariantGear.Code), "big") }); - })); - Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("x")); + }))!; + Assert.That(refused.Message, Does.Contain(BigKey.Value)); + Assert.That(refused.ChangedKeys, Is.EqualTo(new[] { BaseKey.Value }), "the refusal names the base another writer renamed"); } using var reopened = Open(path, readOnly: true); @@ -980,7 +1294,7 @@ namespace GameCult.Caching.Tests } [Test] - public void AConditionalCommitThatIsSoundOnTheMergedSetStillLands() + public void AConditionalCommitThatIsSoundOntoAVariantStoreAnotherWriterChangedIsRefusedThenLandsOnRetry() { var path = PathOf("gear.cc"); var other = new CultRecordKey("other"); @@ -991,11 +1305,15 @@ namespace GameCult.Caching.Tests using (var b = Open(path)) { SeedBig(a); - Assert.That(b.Commit(batch => + bool Write() => b.Commit(batch => { batch.Expect(other, null); batch.Upsert(typeof(VariantGear), Named("other", "fine"), other); - }), Is.True); + }); + + Assert.Throws(() => Write()); + Assert.That(b.Get(BigKey), Is.Not.Null, "the refusal reloaded the other writer's variant"); + Assert.That(Write(), Is.True); } using var reopened = Open(path); @@ -1111,7 +1429,7 @@ namespace GameCult.Caching.Tests } [Test] - public void AVariantAnotherWriterLandedIsStillJudgedWhenThisCacheHoldsNoVariant() + public void AVariantAnotherWriterLandedIsRefusedAsChangedWhenThisCacheHoldsNoVariant() { var path = PathOf("gear.cc"); var other = new CultRecordKey("other"); @@ -1122,7 +1440,7 @@ namespace GameCult.Caching.Tests using (var b = Open(path)) { VariantOnCode(a, "shared"); - Refused(() => b.Commit(batch => + Assert.Throws(() => b.Commit(batch => { batch.Expect(other, null); batch.Upsert(typeof(VariantGear), Named("other", "shared"), other); @@ -1374,7 +1692,7 @@ namespace GameCult.Caching.Tests public void AVariantRecordUnderAV1HeaderIsRefusedNamingTheRecord() { var path = WriteStore(CultPersistedStoreSnapshot.FormatV1, PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base")); - var error = Assert.Throws(() => Open(path).Dispose())!; + var error = Assert.Throws(() => Open(path).Dispose())!; Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("cultcache.store.v2")); } @@ -1382,7 +1700,7 @@ namespace GameCult.Caching.Tests public void AVariantRecordWithAPayloadIsRefusedNamingTheRecord() { var path = WriteStore(PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base", payload: new byte[] { 0x90 })); - var error = Assert.Throws(() => Open(path).Dispose())!; + var error = Assert.Throws(() => Open(path).Dispose())!; Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("payload")); } @@ -1390,7 +1708,7 @@ namespace GameCult.Caching.Tests public void AnOverrideWithAnUnknownOpIsRefusedNamingTheRecord() { var path = WriteStore(PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base", op: (CultOverrideOp)7)); - var error = Assert.Throws(() => Open(path).Dispose())!; + var error = Assert.Throws(() => Open(path).Dispose())!; Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("op 7")); } diff --git a/tests/GameCult.Caching.Tests/WriteOwnershipTests.cs b/tests/GameCult.Caching.Tests/WriteOwnershipTests.cs new file mode 100644 index 00000000..d78c2a67 --- /dev/null +++ b/tests/GameCult.Caching.Tests/WriteOwnershipTests.cs @@ -0,0 +1,417 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // variants:ruling:write-ownership-by-written-type and variants:ruling:cache-refuses-retype: an upsert replaces the record stored at + // its key only when the type it writes there owns that record. Which type owns the id, and which other types a registry holds, + // change nothing; retyping a key is a removal and an add. The rule is one per-key predicate (CultDocumentRegistry.WrittenOwns) + // for a cache and a cacheless writer, in a single-file store and a directory store. + public class WriteOwnershipTests + { + // Two schemas of one shape that are never the same type. + [CultDocument("tests.wo_a", "tests.wo_a.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class WoA + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + [CultDocument("tests.wo_b", "tests.wo_b.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class WoB + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + private static readonly CultDocumentRegistry ARegistry = CultDocumentRegistry.ForTypes(new[] { typeof(WoA) }); + private static readonly CultDocumentRegistry ABRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(WoA), typeof(WoB) }); + private static readonly CultRecordKey K1 = new("k1"); + private static readonly CultRecordKey K2 = new("k2"); + + private string _directory = string.Empty; + + [SetUp] + public void CreateDirectory() + { + _directory = Path.Combine(Path.GetTempPath(), $"cultlib-writeownership-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + + public static readonly object[] StoreKinds = { false, true }; + + private string PathFor(bool directory) => Path.Combine(_directory, directory ? "store" : "store.cc"); + + private static CultCache Open(string path, bool directory, CultDocumentRegistry registry) + { + var cache = new CultCache(registry); + cache.AddBackingStore(directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path)); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + return cache; + } + + private static string Fingerprint(string path) + { + using var sha = SHA256.Create(); + if (File.Exists(path)) + return Convert.ToHexString(sha.ComputeHash(File.ReadAllBytes(path))); + return string.Join("|", Directory.GetFiles(path, "*", SearchOption.AllDirectories) + .OrderBy(file => file, StringComparer.Ordinal) + .Select(file => Path.GetRelativePath(path, file) + ":" + Convert.ToHexString(sha.ComputeHash(File.ReadAllBytes(file))))); + } + + private static Type TypeAt(string path, bool directory, CultDocumentRegistry registry, CultRecordKey key) + { + using var cache = Open(path, directory, registry); + return cache.GetStored(key)!.Descriptor.DocumentType; + } + + // A store that a cache holding both types wrote: A at k1 and B at k2. + private string SeedAAtK1BAtK2(bool directory) + { + var path = PathFor(directory); + using var cache = Open(path, directory, ABRegistry); + Assert.That(cache.Commit(batch => + { + batch.Upsert(new WoA { Name = "k1", Value = 1 }, new CultRecordHandle(K1)); + batch.Upsert(new WoB { Name = "k2", Value = 2 }, new CultRecordHandle(K2)); + }), Is.True); + return path; + } + + // P1: a cache holding A and B may not replace B's record with A's, whoever else the registry declares. + [TestCaseSource(nameof(StoreKinds))] + public void ACacheRefusesToWriteOneRegisteredTypeOverAnothersRecord(bool directory) + { + var path = SeedAAtK1BAtK2(directory); + var before = Fingerprint(path); + + using (var cache = Open(path, directory, ABRegistry)) + { + Assert.Throws(() => cache.Commit(batch => batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle(K2)))); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoB)), "the cache still holds B's record"); + Assert.Throws(() => cache.UpsertAsync(typeof(WoA), new WoA { Name = "k2", Value = 9 }, K2)); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoB)), "a staged write is refused at the call, not at the flush"); + } + + Assert.That(Fingerprint(path), Is.EqualTo(before)); + Assert.That(TypeAt(path, directory, ABRegistry, K2), Is.EqualTo(typeof(WoB))); + + // The pair that differs only in the type written: B over B's own record. + using (var cache = Open(path, directory, ABRegistry)) + Assert.That(cache.Commit(batch => batch.Upsert(new WoB { Name = "k2", Value = 3 }, new CultRecordHandle(K2))), Is.True); + } + + // Retype is a removal and an add, in separate commits. + [TestCaseSource(nameof(StoreKinds))] + public void ARetypeIsARemovalAndAnAddInSeparateCommits(bool directory) + { + var path = SeedAAtK1BAtK2(directory); + + using (var cache = Open(path, directory, ABRegistry)) + { + Assert.That(cache.Commit(batch => batch.Remove(K2)), Is.True); + Assert.That(cache.Commit(batch => batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle(K2))), Is.True); + } + + Assert.That(TypeAt(path, directory, ABRegistry, K2), Is.EqualTo(typeof(WoA))); + Assert.That(TypeAt(path, directory, ABRegistry, K1), Is.EqualTo(typeof(WoA)), "the bystander is untouched"); + } + + // The store is judged again where the write lands, under its lock: another writer retyped the key after this cache loaded it, + // so this cache's own-type write of the old record is now a write over a record that is not its type's. + [TestCaseSource(nameof(StoreKinds))] + public void AnotherWritersRetypeRefusesThisCachesStaleWriteUnderTheLock(bool directory) + { + var path = SeedAAtK1BAtK2(directory); + using var stale = Open(path, directory, ABRegistry); + Assert.That(stale.GetStored(K1)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoA))); + using (var other = Open(path, directory, ABRegistry)) + { + Assert.That(other.Commit(batch => batch.Remove(K1)), Is.True); + Assert.That(other.Commit(batch => batch.Upsert(new WoB { Name = "k1", Value = 5 }, new CultRecordHandle(K1))), Is.True); + } + + var before = Fingerprint(path); + + Assert.Throws(() => stale.Commit(batch => batch.Upsert(new WoA { Name = "k1", Value = 9 }, new CultRecordHandle(K1)))); + + Assert.That(Fingerprint(path), Is.EqualTo(before)); + Assert.That(TypeAt(path, directory, ABRegistry, K1), Is.EqualTo(typeof(WoB))); + } + + public static readonly object[] Registries = { "none", "A", "AB", "Shared" }; + + private static CultDocumentRegistry? Lent(object kind) => kind switch + { + "A" => ARegistry, + "AB" => ABRegistry, + "Shared" => CultDocumentRegistry.Shared, + _ => null, + }; + + // P3: a cacheless write of A over B's record is refused whatever registry is lent, and the same lent or not. + [TestCaseSource(nameof(Registries))] + public void ACachelessWriteOfOneTypeOverAnothersRecordIsRefusedWhateverRegistryIsLent(object lent) + { + var path = SeedAAtK1BAtK2(directory: false); + var before = Fingerprint(path); + var store = new SingleFileMessagePackBackingStore(path); + + Assert.Throws(() => store.WriteDocument(K2, new WoA { Name = "k2", Value = 9 }, registry: Lent(lent))); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + + // WriteRecord names its entry and nothing else. + var entry = ARegistry.GetRequired().ToCatalogEntry(); + var record = new CultPersistedRecord + { + Key = K2.Value, + SchemaId = entry.SchemaId, + StoredAt = "2026-10-28T00:00:00.0000000+00:00", + Payload = MessagePackSerializer.Serialize(new WoA { Name = "k2", Value = 9 }), + }; + Assert.Throws(() => store.WriteRecord(record, entry, null, Lent(lent))); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + + // The pair that differs only in whose record it is: A over A's own, which commits for every registry. + store.WriteDocument(K1, new WoA { Name = "k1", Value = 9 }, registry: Lent(lent) ?? ARegistry); + Assert.That(TypeAt(path, false, ABRegistry, K1), Is.EqualTo(typeof(WoA))); + } + + // The judge is per key, and an answer for one written type is never reused for another. Another writer left A's record at two + // keys; this stale cache holds A at one and B at the other and writes both. The key A owns lands, the key where B meets A's + // record is the one refused, whichever is judged first. + [TestCase("a-own", "b-bad", true, false)] + [TestCase("a-own", "b-bad", false, false)] + [TestCase("b-own", "a-bad", true, true)] + [TestCase("b-own", "a-bad", false, true)] + public void AStaleBatchIsJudgedPerKeyByEachKeysWrittenType(string ownKey, string badKey, bool ownFirst, bool directory) + { + var path = PathFor(directory); + var own = new CultRecordKey(ownKey); + var bad = new CultRecordKey(badKey); + using (var seed = Open(path, directory, ABRegistry)) + Assert.That(seed.Commit(batch => + { + batch.Upsert(new WoA { Name = ownKey, Value = 1 }, new CultRecordHandle(own)); + batch.Upsert(new WoB { Name = badKey, Value = 1 }, new CultRecordHandle(bad)); + }), Is.True); + using var stale = Open(path, directory, ABRegistry); + using (var other = Open(path, directory, ABRegistry)) + { + Assert.That(other.Commit(batch => batch.Remove(bad)), Is.True); + Assert.That(other.Commit(batch => batch.Upsert(new WoA { Name = badKey, Value = 2 }, new CultRecordHandle(bad))), Is.True); + } + + var before = Fingerprint(path); + void StageOwn(CultCacheBatch batch) => batch.Upsert(new WoA { Name = ownKey, Value = 3 }, new CultRecordHandle(own)); + void StageBad(CultCacheBatch batch) => batch.Upsert(new WoB { Name = badKey, Value = 3 }, new CultRecordHandle(bad)); + + var refusal = Assert.Throws(() => stale.Commit(batch => + { + if (ownFirst) { StageOwn(batch); StageBad(batch); } else { StageBad(batch); StageOwn(batch); } + }))!; + + Assert.That(refusal.RecordKey, Is.EqualTo(badKey)); + Assert.That(refusal.Message, Does.Not.Contain($"'{ownKey}'"), "the key A owns is not among the refused"); + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + // A batch that removes a key is a removal of it, whatever this cache has staged there: the removal rule judges it, so a key + // another writer retyped can still be removed by the cache that staged a write of the old type. + [TestCaseSource(nameof(StoreKinds))] + public void ABatchRemovalOfAStagedKeyIsJudgedAsARemoval(bool directory) + { + var path = SeedAAtK1BAtK2(directory); + using var stale = Open(path, directory, ABRegistry); + stale.UpsertAsync(typeof(WoA), new WoA { Name = "k1", Value = 9 }, K1).GetAwaiter().GetResult(); + using (var other = Open(path, directory, ABRegistry)) + { + Assert.That(other.Commit(batch => batch.Remove(K1)), Is.True); + Assert.That(other.Commit(batch => batch.Upsert(new WoB { Name = "k1", Value = 5 }, new CultRecordHandle(K1))), Is.True); + } + + Assert.That(stale.Commit(batch => batch.Remove(K1)), Is.True); + + using var reopened = Open(path, directory, ABRegistry); + Assert.That(reopened.GetStored(K1), Is.Null); + Assert.That(reopened.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoB)), "the bystander is untouched"); + } + + // The ruling's retype: one batch holds the removal and the add, in the order they were called. The removal half is judged as a + // removal (a registered type's record is removable), and the record lands at the key. + [TestCaseSource(nameof(StoreKinds))] + public void ARetypeIsOneBatchHoldingTheRemovalAndTheAdd(bool directory) + { + var path = SeedAAtK1BAtK2(directory); + + using (var cache = Open(path, directory, ABRegistry)) + { + Assert.That(cache.Commit(batch => + { + batch.Remove(K2); + batch.Upsert(new WoB { Name = "k2", Value = 8 }, new CultRecordHandle(K2)); + batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle(K2)); + }), Is.True, "the key stays a replacement through a later add of the same batch"); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoA))); + } + + Assert.That(TypeAt(path, directory, ABRegistry, K2), Is.EqualTo(typeof(WoA))); + Assert.That(TypeAt(path, directory, ABRegistry, K1), Is.EqualTo(typeof(WoA)), "the bystander is untouched"); + } + + // The order is the meaning: a removal called after the add leaves the key removed. + [TestCaseSource(nameof(StoreKinds))] + public void ARemovalCalledAfterTheAddLeavesTheKeyRemoved(bool directory) + { + var path = SeedAAtK1BAtK2(directory); + + using (var cache = Open(path, directory, ABRegistry)) + { + Assert.That(cache.Commit(batch => + { + batch.Remove(K2); + batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle(K2)); + batch.Remove(K2); + }), Is.True); + Assert.That(cache.GetStored(K2), Is.Null); + } + + using var reopened = Open(path, directory, ABRegistry); + Assert.That(reopened.GetStored(K2), Is.Null); + } + + // The removal half is a removal and nothing more: a record no registered type owns is not replaced by adding after its removal. + [TestCaseSource(nameof(StoreKinds))] + public void ARetypeOfARecordNoRegisteredTypeOwnsIsStillRefused(bool directory) + { + var path = SeedAAtK1BAtK2(directory); + var before = Fingerprint(path); + + using (var cache = Open(path, directory, ARegistry)) + { + Assert.Throws(() => cache.Commit(batch => + { + batch.Remove(K2); + batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle(K2)); + })); + } + + Assert.That(Fingerprint(path), Is.EqualTo(before)); + } + + // A removal of a key the cache does not hold is judged under the store's lock, and so is the add after it: another writer put + // B there, this cache never loaded it, and removing then adding A is a retype of whatever is stored. + [TestCaseSource(nameof(StoreKinds))] + public void ARetypeOfAKeyTheCacheDoesNotHoldCommitsInOneBatch(bool directory) + { + var path = PathFor(directory); + using var early = Open(path, directory, ABRegistry); + using (var other = Open(path, directory, ABRegistry)) + Assert.That(other.Commit(batch => batch.Upsert(new WoB { Name = "k1", Value = 1 }, new CultRecordHandle(K1))), Is.True); + + Assert.That(early.Commit(batch => + { + batch.Remove(K1); + batch.Upsert(new WoA { Name = "k1", Value = 2 }, new CultRecordHandle(K1)); + }), Is.True); + + Assert.That(TypeAt(path, directory, ABRegistry, K1), Is.EqualTo(typeof(WoA))); + } + + // A removal and an add called separately on the cache are one retype too: the add, staged after the removal, is not lost to the + // flush that judges it, and the type changes in the file. + [TestCaseSource(nameof(StoreKinds))] + public void AStagedRemovalAndAddOfAnotherTypeIsNotLostAtTheFlush(bool directory) + { + var path = SeedAAtK1BAtK2(directory); + + using (var cache = Open(path, directory, ABRegistry)) + { + Assert.That(cache.Remove(K2), Is.True); + cache.UpsertAsync(typeof(WoA), new WoA { Name = "k2", Value = 9 }, K2).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoA))); + } + + Assert.That(TypeAt(path, directory, ABRegistry, K2), Is.EqualTo(typeof(WoA))); + } + + [TestCaseSource(nameof(StoreKinds))] + public void AStagedRemovalOfAKeyTheCacheDoesNotHoldAndAnAddOfAnotherTypeIsNotLostAtTheFlush(bool directory) + { + var path = PathFor(directory); + using var early = Open(path, directory, ABRegistry); + using (var other = Open(path, directory, ABRegistry)) + Assert.That(other.Commit(batch => batch.Upsert(new WoB { Name = "k1", Value = 1 }, new CultRecordHandle(K1))), Is.True); + + Assert.That(early.Remove(K1), Is.False, "the cache holds nothing there"); + early.UpsertAsync(typeof(WoA), new WoA { Name = "k1", Value = 2 }, K1).GetAwaiter().GetResult(); + early.FlushAllBackingStores(); + + Assert.That(TypeAt(path, directory, ABRegistry, K1), Is.EqualTo(typeof(WoA))); + } + + // A removal is spent by the flush that judged it: a later add of another type, with no removal in front of it, is judged as an upsert again. + [TestCaseSource(nameof(StoreKinds))] + public void ARemovalIsSpentByTheFlushThatJudgedIt(bool directory) + { + var path = SeedAAtK1BAtK2(directory); + + using var cache = Open(path, directory, ABRegistry); + Assert.That(cache.Remove(K2), Is.True); + cache.FlushAllBackingStores(); + + // Another writer puts B at the key this cache removed; this cache never loaded it, and stages an A there with no removal. + using (var other = Open(path, directory, ABRegistry)) + Assert.That(other.Commit(batch => batch.Upsert(new WoB { Name = "k2", Value = 4 }, new CultRecordHandle(K2))), Is.True); + cache.UpsertAsync(typeof(WoA), new WoA { Name = "k2", Value = 5 }, K2).GetAwaiter().GetResult(); + + Assert.Throws(() => cache.FlushAllBackingStores()); + Assert.That(TypeAt(path, directory, ABRegistry, K2), Is.EqualTo(typeof(WoB))); + } + + // No store, same rule: a cache holding A and B refuses one's write over the other's record, on Commit and on UpsertAsync, and + // a retype is a removal and an add, in one batch or staged apart. + [Test] + public void AStorelessCacheRefusesToWriteOneRegisteredTypeOverAnothersRecord() + { + using var cache = new CultCache(ABRegistry); + Assert.That(cache.Commit(batch => batch.Upsert(new WoB { Name = "k2", Value = 2 }, new CultRecordHandle(K2))), Is.True); + + var commit = Assert.Throws(() => cache.Commit(batch => batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle(K2))))!; + Assert.That(commit.RecordKey, Is.EqualTo(K2.Value)); + Assert.Throws(() => cache.UpsertAsync(typeof(WoA), new WoA { Name = "k2", Value = 9 }, K2)); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoB))); + + // The pair that differs only in the type written: B over B's own record. + Assert.That(cache.Commit(batch => batch.Upsert(new WoB { Name = "k2", Value = 3 }, new CultRecordHandle(K2))), Is.True); + + Assert.That(cache.Commit(batch => + { + batch.Remove(K2); + batch.Upsert(new WoA { Name = "k2", Value = 9 }, new CultRecordHandle(K2)); + }), Is.True); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoA))); + + Assert.That(cache.Remove(K2), Is.True); + cache.UpsertAsync(typeof(WoB), new WoB { Name = "k2", Value = 4 }, K2).GetAwaiter().GetResult(); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(WoB))); + } + } +} diff --git a/tests/GameCult.Caching.Tests/WriteSetTests.cs b/tests/GameCult.Caching.Tests/WriteSetTests.cs new file mode 100644 index 00000000..37217aef --- /dev/null +++ b/tests/GameCult.Caching.Tests/WriteSetTests.cs @@ -0,0 +1,1283 @@ +#nullable enable +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // Rule 1 of variants:ruling:write-set-content-version-declared-ownership: a flush, a commit and a single-document write apply + // only what the writer staged to what the store durably holds now, under its lock. Every other record and catalog entry is + // copied forward as stored. Every input goes through the cache's own write API and the real stores. + public class WriteSetTests + { + [CultDocument("tests.write_set_item", "tests.write_set_item.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class WsItem + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public string Note = string.Empty; + } + + [CultDocument("tests.write_set_other", "tests.write_set_other.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class WsOther + { + [Key(0)] + [CultName] + public string Name = string.Empty; + } + + [CultDocument("tests.write_set_nested", "tests.write_set_nested.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class WsNested + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public List Tags = new(); + + [Key(2)] + public WsInner Inner = new(); + } + + [MessagePackObject(AllowPrivate = true)] + internal sealed class WsInner + { + [Key(0)] + public int X; + } + + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(WsItem), typeof(WsOther), typeof(WsNested) }); + private static readonly string ItemId = Registry.GetRequired(typeof(WsItem)).SchemaId; + private static readonly CultRecordKey A = new("a"); + private static readonly CultRecordKey X = new("x"); + + private string _directory = string.Empty; + + [SetUp] + public void SetUp() + { + _directory = Path.Combine(Path.GetTempPath(), "cultlib-write-set-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, true); + } + + private string PathOf(string name) => Path.Combine(_directory, name); + + private static CultCache Open(string path, bool directory) => + CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory }); + + private static CultPersistedStoreSnapshot Read(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + + private static void Write(string path, CultPersistedStoreSnapshot snapshot) => + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + + // What a record is on disk: its id, its storedAt and, in a single file, its payload (a directory manifest indexes pages). + private static string Stored(string path, string key, bool directory) + { + var record = Read(path).Records.Single(candidate => candidate.Key == key); + return $"{record.SchemaId}|{record.StoredAt}|{(directory ? string.Empty : BitConverter.ToString(record.Payload))}"; + } + + private static byte[] EntryBytes(string path, string schemaId) => + StoreSlices.CatalogEntry(path, schemaId); + + private static string[] Keys(string path) => Read(path).Records.Select(record => record.Key).OrderBy(key => key, StringComparer.Ordinal).ToArray(); + + private static void Seed(string path, bool directory, params string[] keys) + { + using var cache = Open(path, directory); + cache.Commit(batch => + { + foreach (var key in keys) + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-" + key, Note = "seeded" }, new CultRecordKey(key)); + }); + } + + // One staged write landed by a plain flush or by an unconditional commit. + private static void Land(CultCache cache, bool commit, Type type, object document, CultRecordKey key) + { + if (commit) + { + cache.Commit(batch => batch.Upsert(type, document, key)); + return; + } + + cache.UpsertAsync(type, document, key).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + } + + // A loads, B writes x, A stages y and flushes or commits: x keeps B's bytes, id and storedAt. A record B removed stays removed. + [Test] + public void ALoadThenAWriteAfterAnotherWritersWriteKeepsThatWrite([Values] bool directory, [Values] bool commit) + { + var path = PathOf("keeps.cc"); + Seed(path, directory, "a"); + using var a = Open(path, directory); + using (var b = Open(path, directory)) + b.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x", Note = "by-b" }, X)); + var written = Stored(path, "x", directory); + + Land(a, commit, typeof(WsItem), new WsItem { Name = "name-y", Note = "by-a" }, new CultRecordKey("y")); + + Assert.That(Keys(path), Is.EqualTo(new[] { "a", "x", "y" })); + Assert.That(Stored(path, "x", directory), Is.EqualTo(written)); + } + + [Test] + public void ARecordAnotherWriterRemovedStaysRemovedAfterALaterWrite([Values] bool directory, [Values] bool commit) + { + var path = PathOf("removed.cc"); + Seed(path, directory, "a", "x"); + using var a = Open(path, directory); + Assert.That(a.Get(X), Is.Not.Null, "A loaded x"); + using (var b = Open(path, directory)) + b.Commit(batch => batch.Remove(X)); + + Land(a, commit, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); + + Assert.That(Keys(path), Is.EqualTo(new[] { "a", "y" }), "A's copy of x is not written back"); + } + + // A path with nothing at it when the cache opened, and a store there by the time it writes: the write lands on that store. + [Test] + public void AWriteOverAStoreThatAppearedAfterTheOpenKeepsItsRecords([Values] bool commit) + { + var path = PathOf("appeared.cc"); + using var a = Open(path, false); + Assert.That(File.Exists(path), Is.False); + using (var b = Open(path, false)) + b.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X)); + + Land(a, commit, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); + + Assert.That(Keys(path), Is.EqualTo(new[] { "x", "y" })); + } + + // An in-place change to a loaded document is not a write: it is written when the document is staged. + [Test] + public void AnInPlaceChangeThatWasNeverStagedIsNotWrittenAndAStagedOneIs([Values] bool directory, [Values] bool commit) + { + var path = PathOf("in-place.cc"); + Seed(path, directory, "a"); + using var cache = Open(path, directory); + var item = cache.Get(A)!; + item.Note = "edited"; + + cache.FlushAllBackingStores(); + using (var check = Open(path, directory)) + Assert.That(check.Get(A)!.Note, Is.EqualTo("seeded"), "an unstaged change is not written"); + + Land(cache, commit, typeof(WsItem), item, A); + using (var check = Open(path, directory)) + Assert.That(check.Get(A)!.Note, Is.EqualTo("edited")); + } + + // B changes the durable entry for the id of a record A carries. A writes a record under another id: the entry for the id it + // did not write is B's, byte for byte, not the one A registered or loaded. + [Test] + public void ARecordCopiedForwardKeepsTheCatalogEntryTheStoreHoldsNow([Values] bool directory, [Values] bool commit) + { + var path = PathOf("entry.cc"); + Seed(path, directory, "a"); + using var cache = Open(path, directory); + var snapshot = Read(path); + snapshot.SchemaCatalog.Single(entry => entry.SchemaId == ItemId).ContentHash = "changed-by-b"; + Write(path, snapshot); + var theirs = EntryBytes(path, ItemId); + + Land(cache, commit, typeof(WsOther), new WsOther { Name = "name-z" }, new CultRecordKey("z")); + + Assert.That(EntryBytes(path, ItemId), Is.EqualTo(theirs)); + Assert.That(Read(path).SchemaCatalog.Single(entry => entry.SchemaId == ItemId).ContentHash, Is.EqualTo("changed-by-b")); + } + + // The runtime decodes seven fields of an entry. Fields after them, and the width a value was written in, are not its to drop. + [Test] + public void ACarriedCatalogEntryKeepsItsExactBytesIncludingWhatThisRuntimeDoesNotDecode([Values] bool directory, [Values] bool commit) + { + var path = PathOf("raw.cc"); + Seed(path, directory, "a"); + var original = EntryBytes(path, ItemId); + Assert.That(original[0], Is.EqualTo(0x97), "a fixarray of the seven fields the runtime knows"); + var crafted = new byte[] { 0x99 } + .Concat(original.Skip(1)) + .Concat(new byte[] { 0xd3, 0, 0, 0, 0, 0, 0, 0, 1 }) + .Concat(new byte[] { 0xc0 }) + .ToArray(); + var bytes = File.ReadAllBytes(path); + var at = IndexOf(bytes, original); + Assert.That(at, Is.GreaterThanOrEqualTo(0)); + File.WriteAllBytes(path, bytes.Take(at).Concat(crafted).Concat(bytes.Skip(at + original.Length)).ToArray()); + + using var cache = Open(path, directory); + Assert.That(cache.Get(A), Is.Not.Null, "the entry with two unknown trailing fields reads"); + Land(cache, commit, typeof(WsOther), new WsOther { Name = "name-z" }, new CultRecordKey("z")); + + Assert.That(IndexOf(File.ReadAllBytes(path), crafted), Is.GreaterThanOrEqualTo(0), "the entry is in the written store byte for byte"); + } + + private static int IndexOf(byte[] haystack, byte[] needle) + { + for (var start = 0; start + needle.Length <= haystack.Length; start++) + { + var same = true; + for (var index = 0; same && index < needle.Length; index++) + same = haystack[start + index] == needle[index]; + if (same) + return start; + } + + return -1; + } + + // The condition is the only difference between a conditional and an unconditional commit: one batch, one apply. + [Test] + public void AConditionalAndAnUnconditionalCommitOfOneBatchOntoOneStoreWriteTheSameBytes() + { + var first = PathOf("unconditional.cc"); + var second = PathOf("conditional.cc"); + Seed(first, false, "a", "x"); + File.Copy(first, second); + + foreach (var (path, conditional) in new[] { (first, false), (second, true) }) + { + using var cache = Open(path, false); + var current = cache.Get(A)!; + Assert.That(cache.Commit(batch => + { + if (conditional) + batch.Expect(A, current); + batch.Upsert(typeof(WsOther), new WsOther { Name = "name-z" }, new CultRecordKey("z")); + batch.Remove(X); + }), Is.True); + } + + byte[] Normalized(string path) + { + var snapshot = Read(path); + foreach (var record in snapshot.Records.Where(record => record.Key == "z")) + record.StoredAt = string.Empty; + return CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot); + } + + Assert.That(Keys(first), Is.EqualTo(new[] { "a", "z" })); + Assert.That(Normalized(second), Is.EqualTo(Normalized(first))); + } + + // An unconditional commit also lands what was staged before it, on the store as it is now. + [Test] + public void AnUnconditionalCommitLandsEarlierStagedWritesWithItsBatchOntoWhatAnotherWriterWrote([Values] bool directory) + { + var path = PathOf("staged.cc"); + Seed(path, directory, "a"); + using var cache = Open(path, directory); + using (var other = Open(path, directory)) + other.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X)); + cache.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-s" }, new CultRecordKey("s")).GetAwaiter().GetResult(); + + Assert.That(cache.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y"))), Is.True); + + Assert.That(Keys(path), Is.EqualTo(new[] { "a", "s", "x", "y" })); + Assert.That(cache.IsDirty, Is.False); + } + + // The operator's check on a real store: CULTLIB_REAL_STORE names a copy of one. A cache that registers none of its types + // stages one record and flushes or commits: every other record and catalog entry, and the header, are byte for byte as they were. + [Test] + public void ARealStoreSurvivesOneStagedWriteByteForByte([Values] bool commit) + { + var source = Environment.GetEnvironmentVariable("CULTLIB_REAL_STORE"); + if (string.IsNullOrEmpty(source) || !File.Exists(source)) + Assert.Ignore("CULTLIB_REAL_STORE does not name a copy of a real store."); + var path = PathOf("real.cc"); + File.Copy(source!, path); + var beforePath = PathOf("real-before.cc"); + File.Copy(source!, beforePath); + var before = Read(path); + + using (var cache = Open(path, false)) + Land(cache, commit, typeof(WsItem), new WsItem { Name = "name-real-write" }, new CultRecordKey("real-write")); + + var after = Read(path); + TestContext.Out.WriteLine($"real store: {before.Records.Length} records, {before.SchemaCatalog.Length} catalog entries, header {before.FormatVersion}"); + Assert.That(after.Records.Length, Is.EqualTo(before.Records.Length + 1)); + Assert.That(after.FormatVersion, Is.EqualTo(before.FormatVersion)); + + var changed = before.Records.Where(record => !StoreSlices.Record(beforePath, record.Key).SequenceEqual(StoreSlices.Record(path, record.Key))).Select(record => record.Key).ToArray(); + Assert.That(changed, Is.Empty, "records that are not byte-identical"); + var afterEntries = StoreSlices.CatalogEntries(path).ToList(); + var missing = StoreSlices.CatalogEntries(beforePath).Where(raw => !afterEntries.Any(other => other.SequenceEqual(raw))).Select(raw => BitConverter.ToString(raw)).ToArray(); + Assert.That(missing, Is.Empty, "catalog entries that are not byte-identical"); + } + + private static void MarkAs(string path, string header) + { + var snapshot = Read(path); + snapshot.FormatVersion = header; + Write(path, snapshot); + } + + // A marked header says the store holds an element id, and a record's bytes cannot say whether it does. A write that copies + // any record forward therefore keeps the mark, whatever this cache wrote or read of that record. + [Test] + public void AMarkedHeaderIsKeptWhileTheWriteCopiesAnyRecord([Values] bool anotherWriterAddedARecord) + { + var path = PathOf("mark.cc"); + Seed(path, false, "a"); + MarkAs(path, CultPersistedStoreSnapshot.FormatV3); + using var cache = Open(path, false); + if (anotherWriterAddedARecord) + { + using (var other = Open(path, false)) + other.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X)); + MarkAs(path, CultPersistedStoreSnapshot.FormatV3); + } + + Land(cache, commit: false, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); + + Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); + } + + // Only a write that encodes every record the store will hold can say none holds an id. + [Test] + public void AMarkedHeaderIsDroppedOnlyByAWriteThatEncodesEveryRecord([Values] bool commit) + { + var path = PathOf("mark-all.cc"); + Seed(path, false, "a"); + MarkAs(path, CultPersistedStoreSnapshot.FormatV3); + using var cache = Open(path, false); + + Land(cache, commit, typeof(WsItem), new WsItem { Name = "name-a", Note = "again" }, A); + + Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV1)); + } + + // Another writer changed a copied record after this cache read it, and the mark stays: a copied record is not read to decide it. + [Test] + public void AMarkedHeaderIsKeptWhenAnotherWriterChangedACopiedRecordAfterTheLoad() + { + var path = PathOf("mark-changed.cc"); + Seed(path, false, "a"); + MarkAs(path, CultPersistedStoreSnapshot.FormatV3); + using var cache = Open(path, false); + using (var other = Open(path, false)) + other.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-a", Note = "changed" }, A)); + MarkAs(path, CultPersistedStoreSnapshot.FormatV3); + + Land(cache, commit: false, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); + + Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); + } + + // A copied record stays copied whoever wrote it: what this cache wrote earlier does not make the next write one that decides by content. + [Test] + public void AMarkedHeaderIsKeptWhenEveryCopiedRecordWasWrittenByThisCache() + { + var path = PathOf("mark-wrote.cc"); + Seed(path, false, "a"); + using var cache = Open(path, false); + Land(cache, commit: false, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); + MarkAs(path, CultPersistedStoreSnapshot.FormatV3); + + Land(cache, commit: false, typeof(WsItem), new WsItem { Name = "name-z" }, new CultRecordKey("z")); + + Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); + } + + // A batch is the write that lands last: it replaces a staged write of the same key. + [Test] + public void ABatchWinsOverAStagedWriteOfTheSameKey() + { + var path = PathOf("batch-wins.cc"); + Seed(path, false, "a"); + using (var cache = Open(path, false)) + { + cache.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-a", Note = "staged" }, A).GetAwaiter().GetResult(); + Assert.That(cache.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-a", Note = "batch" }, A)), Is.True); + } + + using var check = Open(path, false); + Assert.That(check.Get(A)!.Note, Is.EqualTo("batch")); + } + + // v2 exactly when the written store holds a variant: a copied variant keeps it, and the last one removed drops it. + [Test] + public void TheV2HeaderIsWrittenExactlyWhileTheStoreHoldsAVariant() + { + var path = PathOf("variant.cc"); + Seed(path, false, "a"); + var variant = new CultRecordKey("v"); + using (var cache = Open(path, false)) + { + cache.Commit(batch => batch.UpsertVariant(variant, A, new[] + { + cache.Override(nameof(WsItem.Name), "name-v"), + cache.Override(nameof(WsItem.Note), "variant") + })); + } + + Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV2)); + + using (var cache = Open(path, false)) + { + Land(cache, commit: true, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); + Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV2), "the variant was copied"); + cache.Commit(batch => batch.Remove(variant)); + } + + Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV1)); + } + + // Another writer adds a record under this cache's schema id whose payload this cache's type cannot decode. + private static void AddUndecodableRecord(string path, string key) + { + var snapshot = Read(path); + snapshot.Records = snapshot.Records.Concat(new[] + { + new CultPersistedRecord { Key = key, SchemaId = ItemId, StoredAt = DateTimeOffset.UtcNow.ToString("O"), Payload = new byte[] { 0xC1 } } + }).OrderBy(record => record.Key, StringComparer.Ordinal).ToArray(); + Write(path, snapshot); + } + + // A flush decodes nothing it did not stage: a copied record this cache's type cannot decode is carried byte for byte. + [Test] + public void AFlushCopiesARecordThisCacheCannotDecode([Values] bool commit) + { + var path = PathOf("undecodable.cc"); + Seed(path, false, "a"); + using var a = Open(path, false); + AddUndecodableRecord(path, "r"); + var carried = Stored(path, "r", false); + + Land(a, commit, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); + + Assert.That(Keys(path), Is.EqualTo(new[] { "a", "r", "y" })); + Assert.That(Stored(path, "r", false), Is.EqualTo(carried)); + } + + // The same record in a store holding a variant cannot be judged without decoding it: the write is refused with the decode + // failure as its cause, and the disk, the cache and its staged change are as they were. + [Test] + public void AWriteIntoAVariantStoreWithARecordThisCacheCannotLoadChangesNothing([Values] bool commit) + { + var path = PathOf("undecodable-variant.cc"); + Seed(path, false, "a"); + using (var seed = Open(path, false)) + seed.Commit(batch => batch.UpsertVariant(new CultRecordKey("v"), A, new[] { seed.Override(nameof(WsItem.Name), "name-v"), seed.Override(nameof(WsItem.Note), "variant") })); + var a = Open(path, false); + AddUndecodableRecord(path, "r"); + var before = File.ReadAllBytes(path); + var y = new CultRecordKey("y"); + + var conflict = Assert.Throws(() => + { + if (commit) + a.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-y" }, y)); + else + Land(a, false, typeof(WsItem), new WsItem { Name = "name-y" }, y); + })!; + + Assert.That(conflict.InnerException, Is.Not.Null, "the decode failure is the cause"); + Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { "r" })); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + Assert.That(a.Get(new CultRecordKey("r")), Is.Null); + if (!commit) + { + Assert.That(a.IsDirty, Is.True, "a reload that fails changes nothing, the staged write included"); + Assert.That(a.Get(y), Is.Not.Null); + } + } + + // A stages k, B writes k later, A flushes: A's bytes land at a storedAt later than B's in either store kind, so a writer holding B's k is told it moved. + [Test] + public void AWriteOverAnotherWritersLaterRecordStoresALaterStoredAt([Values] bool directory) + { + var path = PathOf("later.cc"); + Seed(path, directory, "a"); + var k = new CultRecordKey("k"); + using var a = Open(path, directory); + a.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-a" }, k).GetAwaiter().GetResult(); + System.Threading.Thread.Sleep(30); + using var b = Open(path, directory); + b.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-b" }, k)); + var seenByB = b.Get(k)!; + var bAt = Read(path).Records.Single(record => record.Key == "k").StoredAt; + + a.FlushAllBackingStores(); + + var onDisk = Read(path).Records.Single(record => record.Key == "k"); + Assert.That(DateTimeOffset.Parse(onDisk.StoredAt, null, System.Globalization.DateTimeStyles.RoundtripKind), + Is.GreaterThan(DateTimeOffset.Parse(bAt, null, System.Globalization.DateTimeStyles.RoundtripKind))); + Assert.That(a.Commit(batch => { batch.Expect(k, a.Get(k)!); batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "again" }, k); }), + Is.True, "A's own condition holds without a pull"); + Assert.That(b.Commit(batch => { batch.Expect(k, seenByB); batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "stale" }, k); }), + Is.False, "B's copy of k is no longer what the store holds"); + } + + // Rule 2: a record's version is the SHA-256 of its stored bytes. Another writer rewrites the record to other bytes yet leaves + // its storedAt as it was (a peer whose clock equals, or a tool): the bytes are all that tells the two records apart. + private void RewriteAtItsOwnStoredAt(string path, bool directory, string key, string note) + { + var storedAt = StoredAtOf(path, key); + using (var writer = Open(path, directory)) + writer.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-" + key, Note = note }, new CultRecordKey(key))); + var snapshot = Read(path); + var record = snapshot.Records.Single(candidate => candidate.Key == key); + if (!directory) + { + record.StoredAt = storedAt; + Write(path, snapshot); + return; + } + + var pages = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path); + var page = CultDocumentMessagePackSerialization.DeserializePersistedRecord( + File.ReadAllBytes(Path.Combine(pages, Convert.ToHexString(record.Payload).ToLowerInvariant() + ".msgpack"))); + page.StoredAt = storedAt; + var bytes = CultDocumentMessagePackSerialization.SerializePersistedRecord(page); + var hash = System.Security.Cryptography.SHA256.HashData(bytes); + File.WriteAllBytes(Path.Combine(pages, Convert.ToHexString(hash).ToLowerInvariant() + ".msgpack"), bytes); + record.StoredAt = storedAt; + record.Payload = hash; + Write(path, snapshot); + } + + private static string NoteOnDisk(string path, bool directory) + { + using var fresh = Open(path, directory); + return fresh.Get(K)!.Note; + } + + private static readonly CultRecordKey K = new("k"); + + // A reads k; another writer rewrites it at its own storedAt; A's Expect on what it read fails and the disk keeps the rewrite. + [Test] + public void AStaleExpectFailsWhenAnotherWriterRewroteTheRecordAtItsOwnStoredAt([Values] bool directory) + { + var path = PathOf("t1.cc"); + Seed(path, directory, "k"); + using var a = Open(path, directory); + var observed = a.Get(K)!; + RewriteAtItsOwnStoredAt(path, directory, "k", "by-c"); + + var committed = a.Commit(batch => + { + batch.Expect(K, observed); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "stale" }, K); + }); + + Assert.That(committed, Is.False); + Assert.That(NoteOnDisk(path, directory), Is.EqualTo("by-c")); + } + + // A pull serves the rewritten record although nothing about it but its bytes changed. + [Test] + public void APullServesARecordRewrittenAtItsOwnStoredAt([Values] bool directory) + { + var path = PathOf("t2.cc"); + Seed(path, directory, "k"); + using var a = Open(path, directory); + RewriteAtItsOwnStoredAt(path, directory, "k", "by-c"); + + a.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + + Assert.That(a.Get(K)!.Note, Is.EqualTo("by-c")); + } + + // ExpectUnchanged compares every record's version: a rewrite at the same storedAt fails it for an unrelated key. + [Test] + public void ExpectUnchangedFailsWhenARecordWasRewrittenAtItsOwnStoredAt([Values] bool directory) + { + var path = PathOf("t3.cc"); + Seed(path, directory, "k"); + using var a = Open(path, directory); + RewriteAtItsOwnStoredAt(path, directory, "k", "by-c"); + + var committed = a.Commit(batch => + { + batch.ExpectUnchanged(); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X); + }); + + Assert.That(committed, Is.False); + Assert.That(Keys(path), Is.EqualTo(new[] { "k" })); + } + + // The staleness check of a store holding a variant compares versions: a rewrite at the record's own storedAt refuses the + // write, names the record, leaves the file as it was, and the cache then holds the rewrite so a retry lands. + [Test] + public void AWriteIntoAVariantStoreIsRefusedWhenARecordWasRewrittenAtItsOwnStoredAt() + { + var path = PathOf("t4.cc"); + Seed(path, false, "a"); + using var a = Open(path, false); + AddVariant(a, "v", A); + using (var peer = Open(path, false)) + Assert.That(peer.Get(A), Is.Not.Null); + RewriteAtItsOwnStoredAt(path, false, "a", "by-c"); + var before = File.ReadAllBytes(path); + var y = new CultRecordKey("y"); + + var conflict = Assert.Throws(() => + Land(a, true, typeof(WsItem), new WsItem { Name = "name-y" }, y))!; + + Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { "a" })); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + Assert.That(a.Get(A)!.Note, Is.EqualTo("by-c"), "the refusal reloaded the record by version"); + Land(a, true, typeof(WsItem), new WsItem { Name = "name-y" }, y); + Assert.That(Keys(path), Is.EqualTo(new[] { "a", "v", "y" })); + } + + // A write teaches the writer's cache the version of the bytes it wrote, by flush or by commit, in either store kind: the + // writer's own Expect holds without a pull, and fails once anyone else writes the record. + [Test] + public void AnOwnWriteTeachesTheCacheItsVersion([Values] bool directory, [Values] bool commit) + { + var path = PathOf("t5.cc"); + Seed(path, directory, "a"); + using var a = Open(path, directory); + Land(a, commit, typeof(WsItem), new WsItem { Name = "name-k", Note = "by-a" }, K); + + Assert.That(a.Commit(batch => + { + batch.Expect(K, a.Get(K)!); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "again" }, K); + }), Is.True, "A's own condition holds without a pull"); + + var seen = a.Get(K)!; + using (var b = Open(path, directory)) + b.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-b" }, K)); + Assert.That(a.Commit(batch => + { + batch.Expect(K, seen); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "stale" }, K); + }), Is.False); + } + + // A version is the SHA-256 of the bytes as the file holds them, not of a re-encoding. A writer may legally encode a record + // wider than needed (a str16 for a short string, a bin32, an array16); the file below holds every record that way. Each + // held record's version is the hash of its slice of the file; a pull over the unchanged file replaces nothing; and a write + // copies the records it did not stage byte for byte, so they keep their version and the next write is not refused. + [Test] + public void AVersionIsTheHashOfTheBytesTheFileHoldsWhateverTheirEncoding() + { + var path = PathOf("wide.cc"); + Seed(path, false, "a", "k"); + using (var seed = Open(path, false)) + AddVariant(seed, "v", A); + File.WriteAllBytes(path, WidenEveryRecord(File.ReadAllBytes(path), out var slices)); + var wide = Read(path).Records.Single(record => record.Key == "k"); + Assert.That(slices["k"], Is.Not.EqualTo(CultDocumentMessagePackSerialization.SerializePersistedRecord(new CultPersistedRecord + { + Key = wide.Key, + SchemaId = wide.SchemaId, + StoredAt = wide.StoredAt, + Payload = wide.Payload + })), "the stored bytes are not the canonical encoding of the record"); + + using var cache = Open(path, false); + foreach (var (key, slice) in slices) + Assert.That(cache.GetStored(new CultRecordKey(key))!.StoredVersion, Is.EqualTo(HexOfSha256(slice)), key); + + var before = cache.GetStored(K); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + Assert.That(ReferenceEquals(cache.GetStored(K), before), Is.True, "an unchanged file is not read as changed"); + + Assert.That(cache.Commit(batch => + { + batch.Expect(K, cache.Get(K)!); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X); + }), Is.True); + var file = File.ReadAllBytes(path); + foreach (var key in new[] { "a", "v" }) + Assert.That(Contains(file, slices[key]), Is.True, $"{key} was copied as the file held it"); + Land(cache, false, typeof(WsItem), new WsItem { Name = "name-x", Note = "again" }, X); + Assert.That(Keys(path), Is.EqualTo(new[] { "a", "k", "v", "x" }), "a second write into the variant store is not refused"); + } + + private static string HexOfSha256(byte[] bytes) => + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes)).ToLowerInvariant(); + + private static bool Contains(byte[] haystack, byte[] needle) => IndexOf(haystack, needle) >= 0; + + // The same store with every array, string and byte string written in its widest MessagePack form, and each record's slice. + private static byte[] WidenEveryRecord(byte[] bytes, out Dictionary slices) + { + slices = new Dictionary(StringComparer.Ordinal); + var output = new List(); + var reader = new MessagePackReader(bytes); + var top = reader.ReadArrayHeader(); + output.AddRange(new byte[] { 0xdc, (byte)(top >> 8), (byte)top }); + for (var slot = 0; slot < top; slot++) + { + if (slot != 2) + { + var from = (int)reader.Consumed; + reader.Skip(); + output.AddRange(bytes.Skip(from).Take((int)reader.Consumed - from)); + continue; + } + + var count = reader.ReadArrayHeader(); + output.AddRange(new byte[] { 0xdc, (byte)(count >> 8), (byte)count }); + for (var index = 0; index < count; index++) + { + var start = output.Count; + var fields = reader.ReadArrayHeader(); + output.AddRange(new byte[] { 0xdc, (byte)(fields >> 8), (byte)fields }); + string? key = null; + for (var field = 0; field < fields; field++) + { + if (reader.NextMessagePackType == MessagePackType.String) + { + var text = reader.ReadString()!; + key ??= text; + var utf8 = System.Text.Encoding.UTF8.GetBytes(text); + output.AddRange(new byte[] { 0xda, (byte)(utf8.Length >> 8), (byte)utf8.Length }); + output.AddRange(utf8); + } + else if (reader.NextMessagePackType == MessagePackType.Binary) + { + var blob = reader.ReadBytes()?.ToArray() ?? Array.Empty(); + output.AddRange(new byte[] { 0xc6, (byte)(blob.Length >> 24), (byte)(blob.Length >> 16), (byte)(blob.Length >> 8), (byte)blob.Length }); + output.AddRange(blob); + } + else + { + var from = (int)reader.Consumed; + reader.Skip(); + output.AddRange(bytes.Skip(from).Take((int)reader.Consumed - from)); + } + } + + slices[key!] = output.Skip(start).ToArray(); + } + } + + return output.ToArray(); + } + + // A condition on a record fails when another writer removed it: the removal stands and the writer does not bring it back. + [Test] + public void AnExpectFailsWhenAnotherWriterRemovedTheRecord([Values] bool directory) + { + var path = PathOf("gone.cc"); + Seed(path, directory, "k"); + using var a = Open(path, directory); + var observed = a.Get(K)!; + using (var b = Open(path, directory)) + Assert.That(b.Commit(batch => batch.Remove(K)), Is.True); + + var committed = a.Commit(batch => + { + batch.Expect(K, observed); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "resurrected" }, K); + }); + + Assert.That(committed, Is.False); + Assert.That(Keys(path), Is.Empty); + } + + // The cache's own held record, not only the store's, learns the version of the bytes written. A variant staged and then + // re-resolved (its base edited before the flush) is held by the cache as a new object the store never saw, so only the + // cache's own line gives it the version of the bytes it was written as. + [Test] + public void AVariantReResolvedBeforeItsFlushIsKnownByItsWrittenVersion() + { + var path = PathOf("reresolved.cc"); + Seed(path, false, "a"); + using var cache = Open(path, false); + var v = new CultRecordKey("v"); + cache.UpsertVariantAsync(v, A, new[] { cache.Override(nameof(WsItem.Name), "name-v") }).GetAwaiter().GetResult(); + var staged = cache.GetStored(v); + cache.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-a", Note = "edited" }, A).GetAwaiter().GetResult(); + Assert.That(ReferenceEquals(cache.GetStored(v), staged), Is.False, "the base edit re-resolved the variant into a new held copy"); + cache.FlushAllBackingStores(); + + var held = cache.GetStored(v)!; + Assert.That(held.StoredVersion, Is.EqualTo(HexOfSha256(CultDocumentMessagePackSerialization.SerializePersistedRecord( + Read(path).Records.Single(record => record.Key == "v"))))); + Assert.That(cache.Commit(batch => + { + batch.Expect(v, cache.Get(v)!); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X); + }), Is.True, "the writer's own condition on the variant holds"); + } + + + // A commit with no condition decides nothing about the other records, so it hashes none of them: only the record it writes + // is hashed to be named. A commit with a condition compares every durable record's version. + [Test] + public void AnUnconditionalCommitHashesOnlyTheRecordsItWrites() + { + var path = PathOf("cost.cc"); + Seed(path, false, "a", "b", "c", "d", "e"); + var store = new SingleFileMessagePackBackingStore(path); + using var cache = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry)); + cache.AddBackingStore(store); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + + store.RecordsHashed = 0; + Assert.That(cache.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X)), Is.True); + Assert.That(store.RecordsHashed, Is.EqualTo(1)); + + store.RecordsHashed = 0; + Assert.That(cache.Commit(batch => + { + batch.Expect(X, cache.Get(X)!); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x", Note = "again" }, X); + }), Is.True); + Assert.That(store.RecordsHashed, Is.GreaterThanOrEqualTo(6), "a condition hashes every durable record, which is how the count proves itself"); + } + + // Records no store has read or written are told apart however many are made in one clock tick. + [Test] + public void UnstoredRecordsMadeBackToBackHaveDistinctVersions() + { + using var cache = new CultCache(Registry); + var keys = Enumerable.Range(0, 2000).Select(index => new CultRecordKey("r" + index)).ToArray(); + foreach (var key in keys) + cache.UpsertAsync(typeof(WsItem), new WsItem { Name = "same" }, key).GetAwaiter().GetResult(); + + Assert.That(keys.Select(key => cache.GetStored(key)!.StoredVersion).Distinct().Count(), Is.EqualTo(keys.Length)); + } + + // A write into a store holding a variant is refused when any record it read has moved, including one the writer is itself + // writing: it must not overwrite another writer's edit with a copy it read before that edit. + [Test] + public void AWriteIntoAVariantStoreIsRefusedWhenARecordItWritesMovedAfterItRead() + { + var path = PathOf("own-upsert.cc"); + Seed(path, false, "a"); + using var a = Open(path, false); + AddVariant(a, "v", A); + using (var peer = Open(path, false)) + Assert.That(peer.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-a", Note = "by-peer" }, A)), Is.True); + var before = File.ReadAllBytes(path); + + var conflict = Assert.Throws(() => + Land(a, true, typeof(WsItem), new WsItem { Name = "name-a", Note = "stale" }, A))!; + + Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { "a" })); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + } + + // A commit that removes a key this cache staged removes it everywhere: the staged write does not survive the batch. + [Test] + public void ACommitThatRemovesAStagedKeyRemovesIt() + { + var path = PathOf("staged-removed.cc"); + Seed(path, false, "a"); + var k = new CultRecordKey("k"); + using (var cache = Open(path, false)) + { + cache.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-k" }, k).GetAwaiter().GetResult(); + Assert.That(cache.Commit(batch => batch.Remove(k)), Is.True); + Assert.That(Keys(path), Is.EqualTo(new[] { "a" })); + Assert.That(cache.Get(k), Is.Null); + } + + using var reopened = Open(path, false); + Assert.That(reopened.Get(k), Is.Null); + } + + private static DateTimeOffset At(string storedAt) => DateTimeOffset.Parse(storedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + + private static string StoredAtOf(string path, string key) => Read(path).Records.Single(record => record.Key == key).StoredAt; + + // The stored bytes belong to the store that read them, never to a record object: a record read through the public snapshot + // API is only a decoded copy, so an edit made to its payload in place is what a write of it stores. + [Test] + public void ARecordReadThroughTheSnapshotApiAndEditedInPlaceIsWrittenAsEdited() + { + var path = PathOf("edit-payload.cc"); + Seed(path, false, "k"); + var snapshot = Read(path); + OverwriteInPlace(snapshot.Records.Single().Payload, "seeded", "SEEDED"); + + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + + Assert.That(NoteOnDisk(path, false), Is.EqualTo("SEEDED")); + var record = CultDocumentMessagePackSerialization.DeserializePersistedRecord(StoreSlices.Record(path, "k")); + OverwriteInPlace(record.Payload, "SEEDED", "seeded"); + Assert.That( + CultDocumentMessagePackSerialization.DeserializePersistedRecord(CultDocumentMessagePackSerialization.SerializePersistedRecord(record)).Payload, + Is.EqualTo(record.Payload), "a single record is re-encoded from its fields too"); + } + + [Test] + public void AnOverrideReadThroughTheSnapshotApiAndEditedInPlaceIsWrittenAsEdited() + { + var path = PathOf("edit-override.cc"); + Seed(path, false, "a"); + using (var seed = Open(path, false)) + AddVariant(seed, "v", A); + var snapshot = Read(path); + var variant = snapshot.Records.Single(record => record.Key == "v").Variant!; + OverwriteInPlace(variant.Overrides.Single(entry => entry.Path[0].Slot == 1).Value, "variant-v", "VARIANT-v"); + + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + + using var fresh = Open(path, false); + Assert.That(fresh.Get(new CultRecordKey("v"))!.Note, Is.EqualTo("VARIANT-v")); + } + + // A held document edited in place (a list, a nested object) and put back as the same instance is written as edited, and a + // later write of a neighbour copies it as the file then holds it. + [Test] + public void AHeldDocumentEditedInPlaceAndPutBackIsWrittenAsEdited([Values] bool commit) + { + var path = PathOf("edit-held.cc"); + var n = new CultRecordKey("n"); + using (var seed = Open(path, false)) + seed.Commit(batch => batch.Upsert(typeof(WsNested), new WsNested { Name = "name-n", Tags = { "t1" }, Inner = { X = 1 } }, n)); + + using (var cache = Open(path, false)) + { + var held = cache.Get(n)!; + held.Tags.Add("t2"); + held.Inner.X = 9; + Land(cache, commit, typeof(WsNested), held, n); + } + + using (var fresh = Open(path, false)) + { + var read = fresh.Get(n)!; + Assert.That(read.Tags, Is.EqualTo(new[] { "t1", "t2" })); + Assert.That(read.Inner.X, Is.EqualTo(9)); + } + + var written = StoreSlices.Record(path, "n"); + using (var other = Open(path, false)) + Land(other, commit, typeof(WsItem), new WsItem { Name = "name-x" }, X); + Assert.That(StoreSlices.Record(path, "n"), Is.EqualTo(written), "the edit is copied as the file holds it"); + } + + // A record the writer did not stage keeps the bytes and the version its file holds, whatever encoding its writer chose, + // across any number of writes of its neighbours. + [Test] + public void AnUntouchedNonCanonicalRecordKeepsItsBytesAndVersionAcrossAWriteOfANeighbour([Values] bool commit) + { + var path = PathOf("untouched-wide.cc"); + Seed(path, false, "k"); + File.WriteAllBytes(path, WidenEveryRecord(File.ReadAllBytes(path), out var slices)); + using var cache = Open(path, false); + var version = cache.GetStored(K)!.StoredVersion; + Assert.That(version, Is.EqualTo(HexOfSha256(slices["k"]))); + + Land(cache, commit, typeof(WsItem), new WsItem { Name = "name-x" }, X); + Land(cache, commit, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); + + Assert.That(StoreSlices.Record(path, "k"), Is.EqualTo(slices["k"])); + Assert.That(cache.GetStored(K)!.StoredVersion, Is.EqualTo(version)); + using var fresh = Open(path, false); + Assert.That(fresh.GetStored(K)!.StoredVersion, Is.EqualTo(version)); + } + + // A record the writer stages is encoded from its object, so a non-canonical record put back unchanged is written canonically + // and, being other bytes, has another version. + [Test] + public void AStagedNonCanonicalRecordPutBackUnchangedIsReEncodedAndGetsANewVersion([Values] bool commit) + { + var path = PathOf("staged-wide.cc"); + Seed(path, false, "k"); + File.WriteAllBytes(path, WidenEveryRecord(File.ReadAllBytes(path), out var slices)); + using var cache = Open(path, false); + var held = cache.Get(K)!; + + Land(cache, commit, typeof(WsItem), held, K); + + var written = StoreSlices.Record(path, "k"); + Assert.That(written, Is.Not.EqualTo(slices["k"])); + Assert.That(written, Is.EqualTo(CultDocumentMessagePackSerialization.SerializePersistedRecord(Read(path).Records.Single())), + "the record is in the canonical encoding"); + Assert.That(cache.GetStored(K)!.StoredVersion, Is.EqualTo(HexOfSha256(written))); + Assert.That(cache.GetStored(K)!.StoredVersion, Is.Not.EqualTo(HexOfSha256(slices["k"]))); + } + + // A catalog entry the writer did not choose to replace is carried as the bytes the file holds, a slot this runtime does not + // read included, in either store kind. The ItemId entry is carried: a WsItem stays in the store and only a WsOther is staged. + [Test] + public void ACatalogEntryWithASlotThisRuntimeDoesNotReadSurvivesAWriteOfANeighbour([Values] bool directory, [Values] bool commit) + { + var path = PathOf("catalog-unknown-slot.cc"); + Seed(path, directory, "a"); + File.WriteAllBytes(path, AddAnUnreadSlotToEveryCatalogEntry(File.ReadAllBytes(path))); + var entry = StoreSlices.CatalogEntry(path, ItemId); + using var cache = Open(path, directory); + + Land(cache, commit, typeof(WsOther), new WsOther { Name = "name-x" }, X); + + Assert.That(StoreSlices.CatalogEntry(path, ItemId), Is.EqualTo(entry)); + } + + // The catalog entry for a staged record's id is the writer's, encoded from its object, even where the file publishes another + // entry under that id: a type that adds a compatible id keeps its id, and only the writer's entry carries the change. Here the + // file's ItemId entry differs from the writer's by a slot this runtime does not read, and a WsItem is staged. + [Test] + public void ACatalogEntryForAStagedRecordsIdIsTheWritersEncodingNotTheFilesSlice([Values] bool directory, [Values] bool commit) + { + var path = PathOf("catalog-writer-entry.cc"); + Seed(path, directory, "a"); + var writers = StoreSlices.CatalogEntry(path, ItemId); + File.WriteAllBytes(path, AddAnUnreadSlotToEveryCatalogEntry(File.ReadAllBytes(path))); + Assert.That(StoreSlices.CatalogEntry(path, ItemId), Is.Not.EqualTo(writers)); + using var cache = Open(path, directory); + + Land(cache, commit, typeof(WsItem), new WsItem { Name = "name-x" }, X); + + Assert.That(StoreSlices.CatalogEntry(path, ItemId), Is.EqualTo(writers)); + } + + // The same store with every catalog entry one slot longer than this runtime reads, as a newer writer's would be. + private static byte[] AddAnUnreadSlotToEveryCatalogEntry(byte[] bytes) + { + var output = new List(); + var reader = new MessagePackReader(bytes); + var top = reader.ReadArrayHeader(); + output.Add((byte)(0x90 | top)); + for (var slot = 0; slot < top; slot++) + { + if (slot != 1) + { + var from = (int)reader.Consumed; + reader.Skip(); + output.AddRange(bytes.Skip(from).Take((int)reader.Consumed - from)); + continue; + } + + var count = reader.ReadArrayHeader(); + output.AddRange(new byte[] { 0xdc, (byte)(count >> 8), (byte)count }); + for (var index = 0; index < count; index++) + { + var from = (int)reader.Consumed; + reader.Skip(); + var entry = bytes.Skip(from).Take((int)reader.Consumed - from).ToArray(); + Assert.That(entry[0] & 0xf0, Is.EqualTo(0x90), "a catalog entry is a fixarray"); + output.Add((byte)(entry[0] + 1)); + output.AddRange(entry.Skip(1)); + output.AddRange(new byte[] { 0xa3, (byte)'x', (byte)'y', (byte)'z' }); + } + } + + return output.ToArray(); + } + + private static void OverwriteInPlace(byte[] bytes, string from, string to) + { + var was = System.Text.Encoding.UTF8.GetBytes(from); + var now = System.Text.Encoding.UTF8.GetBytes(to); + Assert.That(now.Length, Is.EqualTo(was.Length)); + var at = IndexOf(bytes, was); + Assert.That(at, Is.GreaterThanOrEqualTo(0), $"'{from}' is in the bytes"); + Array.Copy(now, 0, bytes, at, now.Length); + } + + private static void AddVariant(CultCache cache, string key, CultRecordKey baseKey) => + cache.Commit(batch => batch.UpsertVariant(new CultRecordKey(key), baseKey, new[] + { + cache.Override(nameof(WsItem.Name), "name-" + key), + cache.Override(nameof(WsItem.Note), "variant-" + key) + })); + + // The record under a key now belongs to a schema no registered type claims, as if a build that knows more types wrote it. + private static void MakeForeign(string path, string key) + { + const string foreignId = "sha256:00000000000000000000000000000000000000000000000000000000000050de"; + var snapshot = Read(path); + snapshot.Records.Single(record => record.Key == key).SchemaId = foreignId; + snapshot.SchemaCatalog = snapshot.SchemaCatalog.Append(new CultSchemaCatalogEntry + { + SchemaId = foreignId, + SchemaName = "tests.write_set_foreign", + SchemaVersion = "tests.write_set_foreign.v1", + ContentHash = "foreign", + CanonicalSchemaJson = "{}" + }).ToArray(); + Write(path, snapshot); + } + + // Two writers whose clocks put a record's storedAt ahead of both mint the same storedAt over it. The second to write + // replaces the first's different bytes, so it is stored later than the first's, and the first's copy is told it moved. + // The equal storedAt is the case, and the mint is from the stored one, not from the writer's clock (which is behind it). Either store kind. + [Test] + public void AWriteOverARecordAtItsOwnStoredAtIsStoredLaterWhateverTheClockSays([Values] bool directory, [Values] bool commit) + { + var path = PathOf("skew.cc"); + Seed(path, directory, "k"); + var k = new CultRecordKey("k"); + var ahead = DateTimeOffset.UtcNow.AddHours(1).ToString("O", CultureInfo.InvariantCulture); + // A single file holds the record at that storedAt. A directory store loads a record from its page, which the manifest + // does not own, so there both writers are made to hold it ahead: a stage then mints from it, as a skewed clock does. + if (!directory) + { + var snapshot = Read(path); + snapshot.Records.Single(record => record.Key == "k").StoredAt = ahead; + Write(path, snapshot); + } + + using var a = Open(path, directory); + using var b = Open(path, directory); + if (directory) + { + a.GetStored(k)!.StoredAt = ahead; + b.GetStored(k)!.StoredAt = ahead; + } + + if (commit) + b.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-b" }, k)); + else + { + a.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-a" }, k).GetAwaiter().GetResult(); + b.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-b" }, k).GetAwaiter().GetResult(); + b.FlushAllBackingStores(); + } + + var bAt = StoredAtOf(path, "k"); + var seenByB = b.Get(k)!; + if (!commit) + Assert.That(a.GetStored(k)!.StoredAt, Is.EqualTo(bAt), "both writers minted the same storedAt over the record"); + + if (commit) + a.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-a" }, k)); + else + a.FlushAllBackingStores(); + + Assert.That(At(StoredAtOf(path, "k")), Is.GreaterThan(At(bAt)), "A's different bytes are stored later than B's"); + Assert.That(b.Commit(batch => + { + batch.Expect(k, seenByB); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "stale" }, k); + }), Is.False, "B's copy of k is not what the store holds"); + Assert.That(a.Commit(batch => + { + batch.Expect(k, a.Get(k)!); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "again" }, k); + }), Is.True, "A's own condition holds without a pull: the cache took the storedAt the write gave it"); + } + + // Another writer removes a base and its variant together; this cache, still holding both, adds a variant of that base. + // The base and the variant disappeared from the store, which moved it: the write is refused and the cache reloads. + [Test] + public void AVariantWrittenOverABaseAnotherWriterRemovedIsRefusedAndReloaded() + { + var path = PathOf("gone-base.cc"); + Seed(path, false, "x"); + using var a = Open(path, false); + AddVariant(a, "v", X); + using (var b = Open(path, false)) + Assert.That(b.Commit(batch => { batch.Remove(new CultRecordKey("v")); batch.Remove(X); }), Is.True); + + var conflict = Assert.Throws(() => AddVariant(a, "w", X))!; + + Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { "v", "x" })); + Assert.That(Keys(path), Is.Empty, "nothing was written"); + Assert.That(a.Get(X), Is.Null, "the cache reloaded what the other writer removed"); + using var fresh = Open(path, false); + Assert.That(fresh.Get(X), Is.Null); + } + + // A foreign refusal whose own reload is refused (dropping x, which a held variant is based on) carries that refusal as its cause. + [Test] + public void AForeignRefusalWhoseReloadFailsCarriesThatCause([Values] bool commit) + { + var path = PathOf("foreign-reload.cc"); + Seed(path, false, "x"); + var a = Open(path, false); + AddVariant(a, "v", X); + MakeForeign(path, "x"); + var before = File.ReadAllBytes(path); + + var refusal = Assert.Throws(() => + Land(a, commit, typeof(WsItem), new WsItem { Name = "name-x", Note = "edited" }, X))!; + + Assert.That(refusal.RecordKey, Is.EqualTo("x")); + Assert.That(refusal.InnerException, Is.TypeOf().And.Message.Contains("variant v is based on it"), "the reload that failed is the cause"); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + Assert.That(a.Get(X), Is.Not.Null, "the reload failed, so the cache is unchanged"); + } + + // A write that finds a record it holds gone foreign is refused as a conflict, and when the reload that refusal owes fails + // (x cannot go while variant v is based on it) the conflict carries that failure, and nothing changes. + [Test] + public void AWriteConflictWhoseReloadFailsCarriesThatCause([Values] bool commit) + { + var path = PathOf("conflict-reload.cc"); + Seed(path, false, "x"); + var a = Open(path, false); + AddVariant(a, "v", X); + MakeForeign(path, "x"); + var before = File.ReadAllBytes(path); + + var conflict = Assert.Throws(() => + Land(a, commit, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")))!; + + Assert.That(conflict.InnerException, Is.TypeOf().And.Message.Contains("variant v is based on it"), "the reload that failed is the cause"); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + Assert.That(a.Get(X), Is.Not.Null, "the reload failed, so the cache is unchanged"); + } + + // A refused foreign record is what the store holds, so the next write into a variant store is not told that it moved. + [Test] + public void AForeignRecordARefusalReloadedIsNotSeenAsMovedByTheNextWrite([Values] bool commit) + { + var path = PathOf("foreign-read.cc"); + Seed(path, false, "a", "x"); + using var a = Open(path, false); + AddVariant(a, "v", A); + MakeForeign(path, "x"); + Assert.Throws(() => + Land(a, commit, typeof(WsItem), new WsItem { Name = "name-x", Note = "edited" }, X)); + + Assert.DoesNotThrow(() => Land(a, commit, typeof(WsItem), new WsItem { Name = "name-n" }, new CultRecordKey("n"))); + + Assert.That(Keys(path), Is.EqualTo(new[] { "a", "n", "v", "x" })); + } + + // A writer that finds the store file gone has read nothing there: a write into the store it makes anew is not refused as + // moved because of what the writer read from the file that was. + [Test] + public void AWriterThatFindsTheStoreFileGoneWritesItAnewWithoutBeingToldItMoved() + { + var path = PathOf("gone-store.cc"); + Seed(path, false, "a"); + using var a = Open(path, false); + AddVariant(a, "v", A); + File.Delete(path); + a.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + + Assert.That(a.Commit(batch => + { + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-a", Note = "again" }, A); + batch.UpsertVariant(new CultRecordKey("v"), A, new[] + { + a.Override(nameof(WsItem.Name), "name-v"), + a.Override(nameof(WsItem.Note), "variant-again") + }); + }), Is.True); + + Assert.That(Keys(path), Is.EqualTo(new[] { "a", "v" })); + } + } +} diff --git a/tests/GameCult.Caching.Tests/WrittenOwnsTests.cs b/tests/GameCult.Caching.Tests/WrittenOwnsTests.cs new file mode 100644 index 00000000..c1a1c148 --- /dev/null +++ b/tests/GameCult.Caching.Tests/WrittenOwnsTests.cs @@ -0,0 +1,97 @@ +#nullable enable +using System; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // The one per-key predicate of write ownership (variants:ruling:write-ownership-by-written-type, own-entry-reads-whole), over + // catalogs the production types publish. Each refusal is paired with the accepted case that differs in only the one thing the + // rule looks at. Where the predicate is wired in is WriteOwnershipTests. + public class WrittenOwnsTests + { + // One schema name at three versions, nothing declared: N2 reads N1's records whole (it adds a slot) and Wide's only by + // dropping a slot; Renamed has N2's exact shape under another schema name. + [CultDocument("tests.wo_n", "tests.wo_n.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class WoN1 + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + [CultDocument("tests.wo_n", "tests.wo_n.v2")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class WoN2 + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + [Key(2)] public string Colour = string.Empty; + } + + [CultDocument("tests.wo_n", "tests.wo_n.v3")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class WoNWide + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + [Key(2)] public string Colour = string.Empty; + [Key(3)] public string Extra = string.Empty; + } + + [CultDocument("tests.wo_nx", "tests.wo_nx.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class WoNRenamed + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + [Key(2)] public string Colour = string.Empty; + } + + private static CultSchemaCatalogEntry EntryOf() where T : class => + CultDocumentRegistry.ForTypes(new[] { typeof(T) }).GetRequired().ToCatalogEntry(); + + private static CultSchemaCatalogEntry Listing(CultSchemaCatalogEntry entry, params string[] ids) => + new() + { + SchemaId = entry.SchemaId, + ContentHash = entry.ContentHash, + SchemaName = entry.SchemaName, + SchemaVersion = entry.SchemaVersion, + CanonicalSchemaJson = entry.CanonicalSchemaJson, + Members = entry.Members, + CompatibleSchemaIds = ids, + }; + + [Test] + public void WrittenOwnsDeclaredIdsAreTheEntrysOwnOrAnyItListsNotOnlyTheFirst() + { + var a = EntryOf(); + var b = EntryOf(); + var catalog = new[] { a }; + + Assert.That(CultDocumentRegistry.WrittenOwns(a, a.SchemaId, catalog), Is.True, "its own id"); + Assert.That(CultDocumentRegistry.WrittenOwns(b, a.SchemaId, catalog), Is.False, "same shape, another schema: not its record"); + Assert.That(CultDocumentRegistry.WrittenOwns(Listing(b, b.SchemaId, a.SchemaId), a.SchemaId, catalog), Is.True, "listed last"); + Assert.That(CultDocumentRegistry.WrittenOwns(Listing(b, a.SchemaId, b.SchemaId), a.SchemaId, catalog), Is.True, "listed first"); + Assert.That(CultDocumentRegistry.WrittenOwns(Listing(b, b.SchemaId, "tests.wo.other"), a.SchemaId, catalog), Is.False, "lists another id"); + Assert.That(CultDocumentRegistry.WrittenOwns(a, a.SchemaId, Array.Empty()), Is.True, "declared needs no catalog"); + } + + [Test] + public void WrittenOwnsAnOlderIdOfItsOwnNameOnlyWhenItReadsTheRecordWhole() + { + var n1 = EntryOf(); + var n2 = EntryOf(); + var wide = EntryOf(); + var renamed = EntryOf(); + + Assert.That(CultDocumentRegistry.WrittenOwns(n2, n1.SchemaId, new[] { n1 }), Is.True, "same name, reads whole"); + Assert.That(CultDocumentRegistry.WrittenOwns(renamed, n1.SchemaId, new[] { n1 }), Is.False, "the same shape under another name"); + Assert.That(CultDocumentRegistry.WrittenOwns(n2, wide.SchemaId, new[] { wide }), Is.False, "same name, drops a persisted slot"); + Assert.That(CultDocumentRegistry.WrittenOwns(n2, "tests.wo.unpublished", new[] { n1 }), Is.False, "an id no catalog entry publishes"); + Assert.That(CultDocumentRegistry.WrittenOwns(n2, n1.SchemaId, new[] { n1, wide }), Is.True, "another entry in the catalog changes nothing"); + } + } +} diff --git a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs index b5f63775..a58bc29d 100644 --- a/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs +++ b/tests/GameCult.Geometry.Tests/GeometryDocumentTests.cs @@ -5,6 +5,7 @@ using GameCult.Caching.MessagePack; using GameCult.Mesh; using GameCult.Networking; using MessagePack; +using MessagePack.Resolvers; using NUnit.Framework; using R3; using System; @@ -193,6 +194,19 @@ namespace GameCult.Geometry.Tests .Should().Be("geometry:domain:02c9b5810977406b0c206f3a3494327a423abb9448192be1b9a1863cd0f2ed95"); } + // A source-generated formatter is of no known family and passes the element-id door (ruling c2a-source-generated-formatters-defaulted). + [Test] + public void AGeneratedFormatterPassesTheElementIdDoor() + { + StandardResolver.Instance.GetFormatter().Should() + .NotBeSameAs(DynamicObjectResolver.Instance.GetFormatter(), "the fixture must stay source-generated"); + var payload = File.ReadAllBytes(FixturePath("ragnarok-domain.msgpack")); + var domain = (CultGeometryDomainDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(CultGeometryDomainDocument), payload); + var again = (CultGeometryDomainDocument)CultDocumentMessagePackSerialization.DeserializeUntyped( + typeof(CultGeometryDomainDocument), CultDocumentMessagePackSerialization.SerializeUntyped(domain, typeof(CultGeometryDomainDocument))); + CultGeometryDomainDocument.CreateRecordKey(again).Value.Should().Be(CultGeometryDomainDocument.CreateRecordKey(domain).Value); + } + [Test] public void RustExportedChunkArtifact_DecodesWithStableCultCacheKey() { diff --git a/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs index ce9bea2b..da764cc5 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshContentVectorTests.cs @@ -264,13 +264,18 @@ public sealed class CultMeshContentVectorTests foreach (var depth in new[] { 250, 253, 254, 255, 256, 257, 300 }) { Add($"deep_extra_{depth}", Doc(fields.Append(Deep(depth)))); - AddFields($"deep_chunkextra_{depth}", (7, Arr(Arr(Str(EmptyHash), Raw(0), Raw(0), Str("k"), Deep(depth))))); + AddFields($"deep_chunkextra_{depth}", (7, Arr(Arr(Str(EmptyHash), Raw(0), Raw(0), Str("k"), Str("0"), Deep(depth))))); } AddFields("chunk_3", (7, Arr(Arr(Str(EmptyHash), Raw(0), Raw(0))))); + // Slot 4 is the element id: a string, or nil (the reference reads null); anything else is refused. AddFields("chunk_5", (7, Arr(Arr(Str(EmptyHash), Raw(0), Raw(0), Str("k"), Nil)))); AddFields("chunk_5_nested", (7, Arr(Arr(Str(EmptyHash), Raw(0), Raw(0), Str("k"), Arr(Raw(1), Raw(2)))))); - // An extra slot inside a chunk reference is followed by more bytes, so a skip that stops short shows. + AddFields("chunk_id_str", (7, Arr(Arr(Str(EmptyHash), Raw(0), Raw(0), Str("k"), Str("0"))))); + AddFields("chunk_id_int", (7, Arr(Arr(Str(EmptyHash), Raw(0), Raw(0), Str("k"), Raw(0))))); + AddFields("chunk_id_str8", (7, Arr(Arr(Str(EmptyHash), Raw(0), Raw(0), Str("k"), Raw(0xd9, 1, 0x30))))); + AddFields("chunk_6", (7, Arr(Arr(Str(EmptyHash), Raw(0), Raw(0), Str("k"), Str("0"), Nil)))); + // An extra slot (past the id) inside a chunk reference is followed by more bytes, so a skip that stops short shows. var extras = new (string Name, byte[] Value)[] { ("map", MapOf(Str("a"), Str("b"), Str("c"), Str("d"))), ("map16", Cat(Raw(0xde, 0, 2), Raw(1), Raw(2), Raw(3), Raw(4))), @@ -289,7 +294,7 @@ public sealed class CultMeshContentVectorTests ("nested", Arr(MapOf(Str("a"), Arr(Raw(1), Raw(2))), Arr())) }; foreach (var (name, value) in extras) - AddFields($"chunk_extra_{name}", (7, Arr(Arr(Str(EmptyHash), Raw(0), Raw(0), Str("k"), value)))); + AddFields($"chunk_extra_{name}", (7, Arr(Arr(Str(EmptyHash), Raw(0), Raw(0), Str("k"), Str("0"), value)))); AddFields("chunk_1", (7, Arr(Arr(Str(EmptyHash))))); AddFields("chunk_0", (7, Arr(Arr()))); AddFields("chunk_is_map", (7, Arr(MapOf(Str("a"), Str("b"))))); diff --git a/tests/GameCult.Mesh.Tests/CultMeshSingleFileElementIdTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSingleFileElementIdTests.cs new file mode 100644 index 00000000..a8c501de --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshSingleFileElementIdTests.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +#nullable enable + +namespace GameCult.Mesh.Tests; + +// C2a: a single-file document written or read through CultMesh is judged as a cache judges it: ids minted, a list that +// cannot hold ids refused, the header decided by what the file holds. +public sealed class CultMeshSingleFileElementIdTests +{ + private const string HexA = "aaaaaaaaaaaa"; + private static readonly CultRecordKey Key = new("publication"); + // A raw write lands beside the typed record, never over it: neither declares the other's schema id. + private static readonly CultRecordKey RawKey = new("raw"); + + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "cultmesh-single-file-ids", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) Directory.Delete(_root, true); + } + + private static CultMeshBodyPublicationDocument Publication(params CultMeshBodyDescriptor[] representations) => new() + { + BodyId = "aetheria:entities", + ProducerId = "aetheria", + SchemaId = "eve.entity_soa.v1", + Representations = representations + }; + + private static string HeaderOf(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion; + + private static CultMeshBodyDescriptor Descriptor(string id = "") => new() { Id = id, BodyId = "body" }; + + private string Path_(string name) => Path.Combine(_root, name); + + [Test] + public void TypedWriteMintsTheElementIdsAndMarksTheFileThatHoldsThem() + { + var path = Path_("ids.cc"); + var publication = Publication(Descriptor(), Descriptor()); + + CultMesh.WriteSingleFileDocument(path, Key, publication); + + publication.Representations.Select(entry => entry.Id).Should().OnlyContain(id => id.Length == 12).And.OnlyHaveUniqueItems(); + HeaderOf(path).Should().Be("cultcache.store.v3"); + CultMesh.ReadSingleFileDocument(path, Key).Representations.Select(entry => entry.Id) + .Should().Equal(publication.Representations.Select(entry => entry.Id)); + } + + // A typed write replaces the whole file with a document it can see, so the ids that document holds decide the header. + [Test] + public void TypedWriteDecidesTheHeaderByTheDocumentItWritesEvenOverAMarkedFile() + { + var path = Path_("none.cc"); + CultMesh.WriteSingleFileDocument(path, Key, Publication()); + HeaderOf(path).Should().Be("cultcache.store.v1"); + + CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor())); + HeaderOf(path).Should().Be("cultcache.store.v3"); + CultMesh.WriteSingleFileDocument(path, Key, Publication()); + HeaderOf(path).Should().Be("cultcache.store.v1", "the whole file is replaced by a document that holds no id"); + } + + // A raw payload is opaque: the writer cannot see what it holds, so a marked file stays marked. + [Test] + public void RawPayloadWriteOverAMarkedFileKeepsItMarkedAndOverAnUnmarkedOneStaysV1() + { + var path = Path_("raw.cc"); + var schema = new CultMeshSingleFileDocumentSchema("raw:schema", "RawSchema", "1"); + var payload = new byte[] { 0x90 }; + + CultMesh.WriteSingleFileDocumentPayload(path, RawKey, schema, null, payload); + HeaderOf(path).Should().Be("cultcache.store.v1"); + + CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor())); + HeaderOf(path).Should().Be("cultcache.store.v3"); + CultMesh.WriteSingleFileDocumentPayload(path, RawKey, schema, null, payload); + HeaderOf(path).Should().Be("cultcache.store.v3", "the payload's ids are not the writer's to see"); + } + + // A raw write at the typed record's own key, by an entry that lists the typed record's id compatible and so may replace it: + // the file then holds only the opaque payload, whose ids the writer cannot see, so the marked file stays marked. (Which files + // a raw write may replace is CultMeshSingleFileReadabilityTests, over the shared vectors.) + [Test] + public void RawPayloadReplacingTheOnlyMarkedRecordKeepsTheFileMarked() + { + var path = Path_("replaced.cc"); + CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor())); + HeaderOf(path).Should().Be("cultcache.store.v3"); + var typed = CultDocumentRegistry.Shared.GetRequired().SchemaId; + var schema = new CultMeshSingleFileDocumentSchema("raw:schema", "RawSchema", "1") { CompatibleSchemaIds = new[] { typed } }; + + CultMesh.WriteSingleFileDocumentPayload(path, Key, schema, null, new byte[] { 0x90 }); + + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Should().ContainSingle() + .Which.SchemaId.Should().Be("raw:schema"); + HeaderOf(path).Should().Be("cultcache.store.v3", "the payload's ids are not the writer's to see"); + } + + [Test] + public void TypedWriteRefusesADuplicateIdAndAnElementObjectTwiceAndLeavesTheFileAlone() + { + var path = Path_("refused.cc"); + CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor())); + var before = File.ReadAllBytes(path); + + var duplicate = Publication(Descriptor(HexA), Descriptor(HexA)); + var refusal = Assert.Throws(() => CultMesh.WriteSingleFileDocument(path, Key, duplicate))!; + refusal.ElementId.Should().Be(HexA); + + var once = Descriptor(); + var twice = Publication(once, once); + Assert.Throws(() => CultMesh.WriteSingleFileDocument(path, Key, twice))!.Message.Should().Contain("twice"); + once.Id.Should().BeEmpty("a refusal mints nothing"); + + File.ReadAllBytes(path).Should().Equal(before); + } + + [Test] + public void AFailedTypedWriteGivesTheMintedIdsBack() + { + var blocked = Path_("blocked.cc"); + Directory.CreateDirectory(blocked); + var element = Descriptor(); + + Assert.That(() => CultMesh.WriteSingleFileDocument(blocked, Key, Publication(element)), Throws.Exception); + + element.Id.Should().BeEmpty("a write that does not land gives its ids back"); + } + + [Test] + public void TypedReadRefusesWhatALoadRefusesAndMintsWhatALoadMints() + { + var path = Path_("disk.cc"); + CultMesh.WriteSingleFileDocument(path, Key, Publication(Descriptor())); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + + var duplicate = Publication(Descriptor(HexA), Descriptor(HexA)); + snapshot.Records[0].Payload = MessagePackSerializer.Serialize(duplicate); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var refusal = Assert.Throws(() => CultMesh.ReadSingleFileDocument(path, Key))!; + refusal.ElementId.Should().Be(HexA); + + // A payload written before ids: two reads, and a cache load, agree on the ids minted. + snapshot.Records[0].Payload = MessagePackSerializer.Serialize(Publication(Descriptor(), Descriptor())); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var first = CultMesh.ReadSingleFileDocument(path, Key).Representations.Select(entry => entry.Id).ToArray(); + first.Should().OnlyContain(id => id.Length == 12); + CultMesh.ReadSingleFileDocument(path, Key).Representations.Select(entry => entry.Id).Should().Equal(first); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions + { + Registry = CultDocumentRegistry.ForTypes(new[] { typeof(CultMeshBodyPublicationDocument) }), + ReadOnly = true + }); + cache.Get(Key)!.Representations.Select(entry => entry.Id).Should().Equal(first); + } + + // A list document defined in this assembly: the record another writer leaves holding ids under an unmarked header. + [CultDocument("mesh.test.id_list", "mesh.test.id_list.v1")] + [MessagePackObject] + public sealed class MeshIdList + { + [Key(0)] [CultName] public string Name { get; set; } = ""; + [Key(1)] public List Items { get; set; } = new(); + } + + [MessagePackObject] + public sealed class MeshIdItem + { + [Key(0)] public string N { get; set; } = ""; + [Key(1)] [CultElementId] public string Id { get; set; } = ""; + } + + private static readonly CultRecordKey IdsKey = new("ids"); + private const string HexB = "bbbbbbbbbbbb"; + + // A v1 file whose record holds 12-hex element ids: the document is written with the type (v3), then the header is put back + // to the v1 a writer that does not decide would have left. + private string UnmarkedStoreHoldingIds() + { + var path = Path_("unmarked.cc"); + CultMesh.WriteSingleFileDocument( + path, + IdsKey, + new MeshIdList { Name = "ids", Items = { new MeshIdItem { Id = HexA }, new MeshIdItem { Id = HexB } } }, + registry: Registry(typeof(MeshIdList))); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + snapshot.FormatVersion = CultPersistedStoreSnapshot.FormatV1; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + HeaderOf(path).Should().Be("cultcache.store.v1"); + return path; + } + + private string PythonStore() + { + string? vectors = null; + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null && vectors == null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "tests", "vectors", "numeric-leaves", "py-store", "leaf-record.cc"); + if (File.Exists(candidate)) vectors = candidate; + } + + vectors.Should().NotBeNull("the shared vectors sit above the test directory"); + var path = Path_("leaf-record.cc"); + File.Copy(vectors!, path); + HeaderOf(path).Should().Be("cultcache.store.v1", "cultcache-py leaves the header unmarked"); + return path; + } + + private static CultDocumentRegistry Registry(params Type[] types) => CultDocumentRegistry.ForTypes(types); + + // A CultMesh write is a writer with a type: it decides for the copied record from the bytes the file holds, whatever any + // earlier read saw (a fresh store per call has read nothing). + [Test] + public void ACultMeshWriteThatCopiesARecordHoldingIdsMarksTheStore() + { + var path = UnmarkedStoreHoldingIds(); + + CultMesh.WriteSingleFileDocument(path, Key, Publication(), registry: Registry(typeof(MeshIdList), typeof(CultMeshBodyPublicationDocument))); + + HeaderOf(path).Should().Be("cultcache.store.v3"); + CultMesh.ReadSingleFileDocument(path, IdsKey, Registry(typeof(MeshIdList))).Items.Select(item => item.Id).Should().Equal(HexA, HexB); + } + + // A raw payload is opaque by design: it attaches no registry and decides nothing, even beside a record it could have read. + [Test] + public void ARawCultMeshWriteBesideARecordHoldingIdsLeavesTheHeaderAlone() + { + var path = UnmarkedStoreHoldingIds(); + + CultMesh.WriteSingleFileDocumentPayload(path, RawKey, new CultMeshSingleFileDocumentSchema("raw:schema", "RawSchema", "1"), null, new byte[] { 0x90 }); + + HeaderOf(path).Should().Be("cultcache.store.v1"); + } + + // Soul s1 P8, committed: the writer's registry has only the type it writes, so the python record is foreign to it and it + // decides nothing for it (variants:question:non-csharp-writers-mark-ids-2). + [Test] + public void ACultMeshWriteWhoseRegistryLacksTheRecordsTypeDecidesNothingForIt() + { + var path = PythonStore(); + + CultMesh.WriteSingleFileDocument(path, Key, Publication(), registry: Registry(typeof(CultMeshBodyPublicationDocument))); + + HeaderOf(path).Should().Be("cultcache.store.v1"); + } + + // The copy check never refuses a write: a record the registry claims by schema name but cannot resolve (the catalog entry + // names another type for a slot) is copied as its bytes and decides nothing. Soul s2 P5, incompatible-catalog-slot12. + [Test] + public void AnUnresolvableCopiedRecordIsCopiedAndDecidesNothing() + { + var path = UnmarkedStoreHoldingIds(); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + const string changedId = "mesh.test.id_list.v0"; + var entry = snapshot.SchemaCatalog.Single(candidate => candidate.SchemaName == "mesh.test.id_list"); + entry.SchemaId = changedId; + entry.ContentHash = "persisted-by-another-runtime"; + entry.Members.Single(member => member.Slot == 1).TypeName = "System.Collections.Generic.List`1[[Other.Inner]]"; + snapshot.Records.Single(record => record.Key == IdsKey.Value).SchemaId = changedId; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var before = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Single(record => record.Key == IdsKey.Value); + + Assert.DoesNotThrow(() => CultMesh.WriteSingleFileDocument(path, Key, Publication(), registry: Registry(typeof(MeshIdList), typeof(CultMeshBodyPublicationDocument)))); + + var after = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Single(record => record.Key == IdsKey.Value); + after.SchemaId.Should().Be(changedId); + after.Payload.Should().Equal(before.Payload); + HeaderOf(path).Should().Be("cultcache.store.v1"); + } + + // Typed writes beside records that hold no id leave the store unmarked: the check marks only on an id it found. + [Test] + public void ATypedWriteBesideARecordThatHoldsNoIdKeepsTheStoreUnmarked() + { + var path = Path_("plain.cc"); + CultMesh.WriteSingleFileDocument(path, Key, Publication(), registry: Registry(typeof(CultMeshBodyPublicationDocument))); + CultMesh.WriteSingleFileDocument(path, new CultRecordKey("second"), Publication(), registry: Registry(typeof(CultMeshBodyPublicationDocument))); + + HeaderOf(path).Should().Be("cultcache.store.v1"); + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs new file mode 100644 index 00000000..556d785d --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +#nullable enable + +namespace GameCult.Mesh.Tests; + +// A single-file write replaces a file exactly when the runtime's own reader opens it, as a cache's flush does. The bytes and +// the verdict are shared with every runtime: tests/vectors/document-variants-c2a/readability. +public sealed class CultMeshSingleFileReadabilityTests +{ + private const int CSharp = 1; + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "cultmesh-single-file-readability", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) Directory.Delete(_root, true); + } + + private static string VectorRoot() + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c2a", "readability"); + if (Directory.Exists(candidate)) return candidate; + } + + throw new DirectoryNotFoundException($"Shared readability vectors not found above {TestContext.CurrentContext.TestDirectory}."); + } + + public static IEnumerable Vectors() => + File.ReadAllLines(Path.Combine(VectorRoot(), "manifest.txt")) + .Where(line => line.Length > 0 && line[0] != '#') + .Select(line => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + .Select(cells => new TestCaseData(cells[0], cells[CSharp] == "reads").SetName($"{cells[0]} {cells[CSharp]}")); + + private static CultMeshBodyPublicationDocument Publication() => new() + { + BodyId = "aetheria:entities", + ProducerId = "aetheria", + SchemaId = "eve.entity_soa.v1" + }; + + [TestCaseSource(nameof(Vectors))] + public void AWriteReplacesAFileExactlyWhenItReads(string vector, bool reads) + { + var raw = new CultMeshSingleFileDocumentSchema("raw:schema", "RawSchema", "1"); + var writes = new (string Name, Action Write)[] + { + ("typed", path => CultMesh.WriteSingleFileDocument(path, new CultRecordKey("publication"), Publication())), + ("raw payload", path => CultMesh.WriteSingleFileDocumentPayload(path, new CultRecordKey("publication"), raw, null, new byte[] { 0x90 })) + }; + foreach (var (name, write) in writes) + { + var path = Path.Combine(_root, name.Replace(' ', '-') + ".cc"); + var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); + File.WriteAllBytes(path, bytes); + + if (reads) + { + var before = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); + if (before.Records.Any(record => record.Variant != null)) + { + Assert.That(() => write(path), Throws.TypeOf(), name); + File.ReadAllBytes(path).Should().Equal(bytes, name + " wrote into a store holding a variant it cannot resolve"); + continue; + } + + write(path); + var after = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + after.Records.Where(record => record.Key == "publication").Should().ContainSingle(name); + var others = before.Records.Where(record => record.Key != "publication").ToArray(); + after.Records.Should().HaveCount(others.Length + 1, name); + foreach (var record in others) + { + var kept = after.Records.Single(candidate => candidate.Key == record.Key); + kept.SchemaId.Should().Be(record.SchemaId, name); + kept.StoredAt.Should().Be(record.StoredAt, name); + kept.Payload.Should().Equal(record.Payload, name + " copies the file's other records as they are"); + } + + continue; + } + + Assert.That(() => write(path), Throws.TypeOf(), name); + File.ReadAllBytes(path).Should().Equal(bytes, name + " rewrote a file this runtime cannot read"); + } + } + + // A read asks the same reader as a write: a file this runtime cannot read, a zero-byte one included, is refused as unreadable + // and never reported as a store that lacks the record. + [Test] + public void AReadOfAZeroByteFileIsRefusedAsUnreadableNotAsAMissingRecord() + { + var path = Path.Combine(_root, "zero.cc"); + File.WriteAllBytes(path, Array.Empty()); + + Assert.That(() => CultMesh.ReadSingleFileDocumentPayload(path, new CultRecordKey("publication"), "raw:schema"), Throws.TypeOf()); + } + + [Test] + public void EveryVectorInTheFolderHasAManifestRow() + { + var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal); + var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal); + rows.Should().Equal(files); + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs new file mode 100644 index 00000000..7ce94ed2 --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshSingleFileStoreTests.cs @@ -0,0 +1,465 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +#nullable enable + +namespace GameCult.Mesh.Tests; + +// CultMesh's single-file helpers reach the store file through the one CultCache single-file store: what is at the path, the lock +// and the atomic replace are decided there, once, for a Mesh read or write and a cache open, flush or commit alike. +public sealed class CultMeshSingleFileStoreTests +{ + private static readonly CultRecordKey Key = new("publication"); + private static readonly CultMeshSingleFileDocumentSchema Raw = new("raw:schema", "RawSchema", "1"); + private static readonly byte[] EmptyArray = { 0x90 }; + private static readonly CultCacheOpenOptions ReadOnlyOpen = new() { FlushOnDispose = false, StoreFlushOnDispose = false }; + private string _root = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "cultmesh-single-file-store", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + [TearDown] + public void TearDown() + { + if (!Directory.Exists(_root)) return; + // A recursive delete cannot remove a link whose target is gone; take the links out by themselves first. + foreach (var entry in Directory.EnumerateFileSystemEntries(_root).Where(IsALink)) + { + if ((new FileInfo(entry).Attributes & FileAttributes.Directory) != 0) Directory.Delete(entry, false); + else File.Delete(entry); + } + + Directory.Delete(_root, true); + } + + private static CultMeshBodyPublicationDocument Publication(string producer = "aetheria") => new() + { + BodyId = "aetheria:entities", + ProducerId = producer, + SchemaId = "eve.entity_soa.v1" + }; + + private static CultMeshBodyPublicationDocument PublicationOf(string id) => new() + { + BodyId = "aetheria:" + id, + ProducerId = "aetheria", + SchemaId = "eve.entity_soa.v1" + }; + + private static string SchemaId() => CultDocumentRegistry.Shared.GetRequired().SchemaId; + + private static bool IsALink(string path) + { + var attributes = new FileInfo(path).Attributes; + return (int)attributes != -1 && (attributes & FileAttributes.ReparsePoint) != 0; + } + + private static int RecordsSeenByACache(string path) + { + using var cache = CultCacheMessagePack.Create(path, ReadOnlyOpen); + return cache.GetAll().Count(); + } + + private IEnumerable TempFiles() => + Directory.GetFileSystemEntries(_root).Where(entry => entry.EndsWith(".tmp", StringComparison.Ordinal)); + + // A link whose target is gone: a symlink where one can be made, a directory junction on Windows, which needs no privilege. + private static void CreateDanglingLink(string path, string target) + { + if (!OperatingSystem.IsWindows()) + { + File.CreateSymbolicLink(path, target); + return; + } + + Directory.CreateDirectory(target); + var mklink = Process.Start(new ProcessStartInfo("cmd.exe", $"/c mklink /J \"{path}\" \"{target}\"") + { + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + })!; + mklink.WaitForExit(); + Assert.That(mklink.ExitCode, Is.Zero, "mklink /J failed"); + Directory.Delete(target); + } + + private static void CreateLiveLinkOrIgnore(string path, string target) + { + try + { + File.CreateSymbolicLink(path, target); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Assert.Ignore("A file symlink needs a privilege this account lacks: " + ex.Message); + } + } + + private static byte[] EntryBytes(CultPersistedStoreSnapshot snapshot, string schemaId) => + CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot + { + SchemaCatalog = snapshot.SchemaCatalog.Where(entry => entry.SchemaId == schemaId).ToArray() + }); + + private static CultPersistedStoreSnapshot Snapshot(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + + // A Mesh write is a write set of one key: every other record in the file and the entry that publishes it are copied forward as + // the file holds them, and a second write of the key replaces only that key. + [Test] + public void AMeshWriteIntoAStoreHoldingOtherRecordsCopiesThemForwardAndReplacesOnlyItsKey() + { + var path = Path.Combine(_root, "shared.cc"); + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions())) + { + cache.Commit(batch => + { + batch.Upsert(typeof(CultMeshBodyPublicationDocument), PublicationOf("a"), new CultRecordKey("a")); + batch.Upsert(typeof(CultMeshBodyPublicationDocument), PublicationOf("b"), new CultRecordKey("b")); + }); + } + + var before = Snapshot(path); + CultMesh.WriteSingleFileDocumentPayload(path, Key, Raw, null, new byte[] { 0x90 }); + var after = Snapshot(path); + + after.Records.Select(record => record.Key).Should().Equal("a", "b", "publication"); + foreach (var key in new[] { "a", "b" }) + { + var was = before.Records.Single(record => record.Key == key); + var kept = after.Records.Single(record => record.Key == key); + (kept.SchemaId, kept.StoredAt).Should().Be((was.SchemaId, was.StoredAt)); + kept.Payload.Should().Equal(was.Payload); + } + + EntryBytes(after, SchemaId()).Should().Equal(EntryBytes(before, SchemaId())); + after.SchemaCatalog.Should().Contain(entry => entry.SchemaId == "raw:schema"); + + CultMesh.WriteSingleFileDocumentPayload(path, Key, Raw, null, new byte[] { 0x91, 0x01 }); + var again = Snapshot(path); + again.Records.Select(record => record.Key).Should().Equal("a", "b", "publication"); + again.Records.Single(record => record.Key == "publication").Payload.Should().Equal(new byte[] { 0x91, 0x01 }); + foreach (var key in new[] { "a", "b" }) + again.Records.Single(record => record.Key == key).Payload.Should().Equal(before.Records.Single(record => record.Key == key).Payload); + } + + // A Mesh write has read nothing and has no cache to resolve a variant against its base, so it never writes into a store that holds + // one: typed or raw, at the variant's key, its base's key or an unrelated key, it is refused and the file is left byte for byte. + [Test] + public void AMeshWriteIntoAStoreHoldingAVariantIsRefusedAndLeavesItByteIdentical( + [Values("variant", "base", "unrelated")] string target, [Values] bool typed) + { + var path = Path.Combine(_root, $"variant-{target}-{typed}.cc"); + var baseKey = new CultRecordKey("a"); + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions())) + { + cache.Commit(batch => + { + batch.Upsert(typeof(CultMeshBodyPublicationDocument), PublicationOf("a"), baseKey); + batch.UpsertVariant(new CultRecordKey("v"), baseKey, new[] + { + cache.Override(nameof(CultMeshBodyPublicationDocument.BodyId), "aetheria:v"), + cache.Override(nameof(CultMeshBodyPublicationDocument.ProducerId), "variant-producer") + }); + }); + } + + var bytes = File.ReadAllBytes(path); + Snapshot(path).Records.Should().Contain(record => record.Variant != null); + var key = new CultRecordKey(target switch { "variant" => "v", "base" => "a", _ => "z" }); + + void Write() + { + if (typed) + CultMesh.WriteSingleFileDocument(path, key, PublicationOf("z")); + else + CultMesh.WriteSingleFileDocumentPayload(path, key, Raw, null, new byte[] { 0x90 }); + } + + // A raw write at the variant's or the base's key names a record under an id its entry does not declare, and that refusal is + // checked first. + if (!typed && target != "unrelated") + Assert.Throws(Write)!.RecordKey.Should().Be(key.Value); + else + Assert.Throws(Write)!.Message.Should().Contain("CultCache"); + File.ReadAllBytes(path).Should().Equal(bytes); + } + + // A Mesh write declares the ids of the catalog entry it writes: its own and those it lists compatible. Over a record under any + // other id, typed or raw, it is refused with the typed schema conflict naming the record, and the file is left byte for byte. + [Test] + public void AMeshWriteOverARecordUnderAnIdItsEntryDoesNotDeclareIsRefused([Values] bool typed) + { + var path = Path.Combine(_root, $"undeclared-{typed}.cc"); + if (typed) + CultMesh.WriteSingleFileDocumentPayload(path, Key, Raw, null, EmptyArray); + else + CultMesh.WriteSingleFileDocument(path, Key, Publication()); + var bytes = File.ReadAllBytes(path); + + var refusal = Assert.Throws(() => + { + if (typed) + CultMesh.WriteSingleFileDocument(path, Key, Publication("other")); + else + CultMesh.WriteSingleFileDocumentPayload(path, Key, Raw, null, new byte[] { 0x91, 0x01 }); + })!; + + (refusal.RecordKey, refusal.SchemaId).Should().Be((Key.Value, typed ? Raw.SchemaId : SchemaId())); + refusal.Message.Should().Contain("nothing was reloaded"); + File.ReadAllBytes(path).Should().Equal(bytes); + } + + // A write whose entry lists the held id compatible owns the record and replaces it under its own id. + [Test] + public void AMeshWriteWhoseEntryListsTheHeldIdLandsUnderItsOwnId() + { + var path = Path.Combine(_root, "listed.cc"); + CultMesh.WriteSingleFileDocumentPayload(path, Key, Raw, null, EmptyArray); + var next = new CultMeshSingleFileDocumentSchema("raw:schema.v2", "RawSchema", "2") { CompatibleSchemaIds = new[] { Raw.SchemaId } }; + + CultMesh.WriteSingleFileDocumentPayload(path, Key, next, null, new byte[] { 0x91, 0x01 }); + + var record = Snapshot(path).Records.Single(); + (record.Key, record.SchemaId).Should().Be((Key.Value, "raw:schema.v2")); + record.Payload.Should().Equal(new byte[] { 0x91, 0x01 }); + } + + // The writer cannot see whether the raw payload or the records it copies hold element ids, so a marked file stays marked. + [Test] + public void AMeshRawWriteIntoAMarkedStoreKeepsItMarked() + { + var path = Path.Combine(_root, "marked.cc"); + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions())) + cache.Commit(batch => batch.Upsert(typeof(CultMeshBodyPublicationDocument), PublicationOf("a"), new CultRecordKey("a"))); + var marked = Snapshot(path); + marked.FormatVersion = CultPersistedStoreSnapshot.FormatV3; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(marked)); + + CultMesh.WriteSingleFileDocumentPayload(path, Key, Raw, null, new byte[] { 0x90 }); + + Snapshot(path).FormatVersion.Should().Be(CultPersistedStoreSnapshot.FormatV3); + } + + [Test] + public void ADanglingLinkIsAnIoErrorOnTypedWriteRawWriteAndReadNeverAMissingFile() + { + var path = Path.Combine(_root, "dangling.cc"); + var target = Path.Combine(_root, "unmounted"); + CreateDanglingLink(path, target); + + var operations = new (string Name, Action Run)[] + { + ("typed write", () => CultMesh.WriteSingleFileDocument(path, Key, Publication())), + ("raw write", () => CultMesh.WriteSingleFileDocumentPayload(path, Key, Raw, null, EmptyArray)), + ("payload read", () => CultMesh.ReadSingleFileDocumentPayload(path, Key, "raw:schema")), + ("keyless payload read", () => CultMesh.ReadSingleFileDocumentPayload(path, "raw:schema")), + ("typed read", () => CultMesh.ReadSingleFileDocument(path, Key)), + }; + foreach (var (name, run) in operations) + { + var error = Assert.Catch(() => run(), name)!; + error.Should().NotBeOfType(name + " reported a dangling link as a missing file"); + } + + IsALink(path).Should().BeTrue("the link was replaced"); + Directory.Exists(target).Should().BeFalse("a write went through the link"); + File.Exists(target).Should().BeFalse("a write went through the link"); + TempFiles().Should().BeEmpty(); + } + + [Test] + public void OnlyNothingAtThePathIsAMissingFile() + { + var path = Path.Combine(_root, "missing.cc"); + var missing = Assert.Throws(() => CultMesh.ReadSingleFileDocumentPayload(path, Key, "raw:schema"))!; + missing.FileName.Should().Be(path); + Assert.Throws(() => CultMesh.ReadSingleFileDocumentPayload(path, "raw:schema")); + Assert.Throws(() => CultMesh.ReadSingleFileDocument(path, Key)); + File.Exists(path).Should().BeFalse("a read created the file"); + + // A write to nothing at the path creates the store, and the reader reads it back. + CultMesh.WriteSingleFileDocumentPayload(path, Key, Raw, null, EmptyArray); + CultMesh.ReadSingleFileDocumentPayload(path, Key, "raw:schema").Should().Equal(EmptyArray); + } + + [Test] + public void ADirectoryAtThePathIsAnIoErrorOnReadAndWriteAndIsLeftAlone() + { + var path = Path.Combine(_root, "directory.cc"); + Directory.CreateDirectory(path); + + var operations = new (string Name, Action Run)[] + { + ("typed write", () => CultMesh.WriteSingleFileDocument(path, Key, Publication())), + ("raw write", () => CultMesh.WriteSingleFileDocumentPayload(path, Key, Raw, null, EmptyArray)), + ("payload read", () => CultMesh.ReadSingleFileDocumentPayload(path, Key, "raw:schema")), + ("keyless payload read", () => CultMesh.ReadSingleFileDocumentPayload(path, "raw:schema")), + }; + foreach (var (name, run) in operations) + { + var error = Assert.Catch(() => run(), name)!; + error.Should().NotBeOfType(name); + error.Message.Should().Contain("directory", name); + error.Message.Should().NotContain(_root, name + " echoed the path in its refusal text"); + } + + Directory.Exists(path).Should().BeTrue(); + Directory.GetFileSystemEntries(path).Should().BeEmpty(); + TempFiles().Should().BeEmpty(); + } + + [Test] + public void AZeroByteFileIsRefusedOnTypedAndRawWriteAndLeftAlone() + { + var path = Path.Combine(_root, "zero.cc"); + File.WriteAllBytes(path, Array.Empty()); + + Assert.Throws(() => CultMesh.WriteSingleFileDocument(path, Key, Publication())); + Assert.Throws(() => CultMesh.WriteSingleFileDocumentPayload(path, Key, Raw, null, EmptyArray)); + Assert.Throws(() => CultMesh.ReadSingleFileDocument(path, Key)); + new FileInfo(path).Length.Should().Be(0); + } + + [Test] + public void AMeshWriteWaitsForTheStoreLock() + { + var path = Path.Combine(_root, "locked.cc"); + FileStream? held = new FileStream(path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + try + { + var write = Task.Run(() => CultMesh.WriteSingleFileDocumentPayload(path, Key, Raw, null, EmptyArray)); + + write.Wait(TimeSpan.FromMilliseconds(300)).Should().BeFalse("a Mesh write went ahead of the store lock"); + File.Exists(path).Should().BeFalse("a Mesh write landed while the lock was held"); + + held.Dispose(); + held = null; + write.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue("the write never completed after the lock was released"); + write.IsCompletedSuccessfully.Should().BeTrue(); + } + finally + { + held?.Dispose(); + } + + CultMesh.ReadSingleFileDocumentPayload(path, Key, "raw:schema").Should().Equal(EmptyArray); + } + + [Test] + public void ACacheOpeningDuringMeshWritesNeverSeesAnEmptyStore() + { + // Windows replaces with ReplaceFile, which leaves a moment with nothing at the path for a lock-free reader: 1,722 empty + // stores in 17,313 opens at b1ff2dcb. That is the store's replace, not Mesh's, and it is the same for a cache flush beside a + // cache open; the fork is in the cut report. + if (OperatingSystem.IsWindows()) + Assert.Ignore("Windows File.Replace is not atomic to a lock-free reader; see cut report fork mesh-reader-windows-replace-window."); + + var path = Path.Combine(_root, "race.cc"); + CultMesh.WriteSingleFileDocument(path, Key, Publication()); + const int writes = 500; + var stop = 0; + var errors = new ConcurrentQueue(); + var writer = Task.Run(() => + { + for (var index = 0; index < writes; index++) + { + try { CultMesh.WriteSingleFileDocument(path, Key, Publication()); } + catch (Exception ex) { errors.Enqueue("write: " + ex.GetType().Name + ": " + ex.Message); } + } + + Volatile.Write(ref stop, 1); + }); + + int opens = 0, empty = 0, reads = 0; + var clock = Stopwatch.StartNew(); + while (Volatile.Read(ref stop) == 0 && clock.Elapsed < TimeSpan.FromSeconds(60)) + { + try + { + opens++; + if (RecordsSeenByACache(path) == 0) empty++; + CultMesh.ReadSingleFileDocumentPayload(path, Key, SchemaId()); + reads++; + } + catch (Exception ex) + { + errors.Enqueue("read: " + ex.GetType().Name + ": " + ex.Message); + } + } + + writer.Wait(TimeSpan.FromSeconds(60)).Should().BeTrue(); + TestContext.Out.WriteLine($"mesh writes={writes} cache opens={opens} empty stores={empty} mesh reads ok={reads} errors={errors.Count}"); + errors.Take(3).Should().BeEmpty(); + empty.Should().Be(0, "a cache opened in the window where nothing was at the path"); + opens.Should().BeGreaterThan(0); + } + + [Test] + public void ConcurrentMeshWritersNeverErrorAndLeaveAReadableFile() + { + var path = Path.Combine(_root, "writers.cc"); + const int writers = 4, each = 100; + var errors = new ConcurrentQueue(); + var tasks = Enumerable.Range(0, writers).Select(writer => Task.Run(() => + { + for (var index = 0; index < each; index++) + { + try { CultMesh.WriteSingleFileDocument(path, Key, Publication("writer-" + writer)); } + catch (Exception ex) { errors.Enqueue(ex.GetType().Name + ": " + ex.Message); } + } + })).ToArray(); + + Task.WaitAll(tasks, TimeSpan.FromSeconds(120)).Should().BeTrue(); + TestContext.Out.WriteLine($"mesh writers={writers} writes each={each} errors={errors.Count}"); + errors.Take(3).Should().BeEmpty(); + File.Exists(path).Should().BeTrue("concurrent writers deleted the store"); + CultMesh.ReadSingleFileDocument(path, Key).ProducerId.Should().StartWith("writer-"); + RecordsSeenByACache(path).Should().Be(1); + TempFiles().Should().BeEmpty(); + } + + // The store's replace decides what a write leaves where a live link was, and a Mesh write leaves what a cache commit leaves: + // one replace, whatever the platform does with a link. + [Test] + public void AMeshWriteOverALiveLinkLeavesWhatACacheCommitLeaves() + { + string Outcome(string name, Action write) + { + var target = Path.Combine(_root, name + "-target.cc"); + var link = Path.Combine(_root, name + "-link.cc"); + CultMesh.WriteSingleFileDocument(target, Key, Publication("first")); + CreateLiveLinkOrIgnore(link, target); + var before = File.ReadAllBytes(target); + + write(link); + + CultMesh.ReadSingleFileDocument(link, Key).ProducerId.Should().Be("second", name); + return $"linkKept={IsALink(link)} targetChanged={!File.ReadAllBytes(target).SequenceEqual(before)}"; + } + + var viaMesh = Outcome("mesh", link => CultMesh.WriteSingleFileDocument(link, Key, Publication("second"))); + var viaCache = Outcome("cache", link => + { + using var cache = CultCacheMessagePack.Create(link, ReadOnlyOpen); + cache.Commit(batch => batch.Upsert(typeof(CultMeshBodyPublicationDocument), Publication("second"), Key)); + }); + + viaMesh.Should().Be(viaCache); + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshSingleFileWriteGuardTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSingleFileWriteGuardTests.cs new file mode 100644 index 00000000..e1cb1cb5 --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshSingleFileWriteGuardTests.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using FluentAssertions; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +#nullable enable + +namespace GameCult.Mesh.Tests; + +// A CultMesh single-file write goes through the cache's single-file store: it reads the file under the store lock, applies its +// one record, and keeps every other record. A store stamped with a newer format version or one it cannot read is refused and +// left byte-identical. +public sealed class CultMeshSingleFileWriteGuardTests +{ + private static readonly CultMeshSingleFileDocumentSchema Schema = new("sha256:guard", "guard.item", "1"); + private string _root = null!; + private string _path = null!; + + [SetUp] + public void SetUp() + { + _root = Path.Combine(Path.GetTempPath(), "cultmesh-write-guard", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + _path = Path.Combine(_root, "store.cc"); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_root)) Directory.Delete(_root, true); + } + + private void Write(string key, byte payload) => + CultMesh.WriteSingleFileDocumentPayload(_path, new CultRecordKey(key), Schema, null, new[] { payload }); + + [TestCase("cultcache.store.v4")] + [TestCase("cultcache.store.v9")] + public void WriteRefusesAStoreStampedWithANewerFormat(string newer) + { + Write("item:a", 1); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path)); + snapshot.FormatVersion = newer; + File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var before = File.ReadAllBytes(_path); + + var error = Assert.Throws(() => Write("item:a", 2))!; + + error.Message.Should().Contain(newer); + File.ReadAllBytes(_path).Should().Equal(before); + } + + [Test] + public void WriteRefusesAFileItCannotRead() + { + var garbage = new byte[] { 0xc1, 0xc1, 0xc1, 0xc1 }; + File.WriteAllBytes(_path, garbage); + + Assert.Catch(() => Write("item:a", 1)); + + File.ReadAllBytes(_path).Should().Equal(garbage); + } + + // Another writer's record, under a schema this write does not touch, is still in the file after the write. + [Test] + public void WriteKeepsAnotherWritersRecord() + { + var other = new CultMeshSingleFileDocumentSchema("sha256:other", "other.item", "1"); + Write("item:a", 1); + CultMesh.WriteSingleFileDocumentPayload(_path, new CultRecordKey("item:b"), other, null, new byte[] { 7 }); + + Write("item:a", 2); + + CultMesh.ReadSingleFileDocumentPayload(_path, new CultRecordKey("item:a"), Schema.SchemaId).Should().Equal(2); + CultMesh.ReadSingleFileDocumentPayload(_path, new CultRecordKey("item:b"), other.SchemaId).Should().Equal(7); + } + + [Test] + public void WriteReplacesTheSameRecordAndCreatesAMissingFile() + { + Write("item:a", 1); + Write("item:a", 2); + + CultMesh.ReadSingleFileDocumentPayload(_path, new CultRecordKey("item:a"), Schema.SchemaId).Should().Equal(2); + } + + // The write takes the same .lock a CultCache single-file store takes: while another holder has it, the write waits. + [Test] + public void WriteWaitsForTheStoreLockAnotherWriterHolds() + { + Write("item:a", 1); + var write = default(System.Threading.Tasks.Task); + using (new FileStream(_path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)) + { + write = System.Threading.Tasks.Task.Run(() => Write("item:a", 2)); + + write.Wait(TimeSpan.FromMilliseconds(750)).Should().BeFalse("the lock is held, so the write must wait"); + CultMesh.ReadSingleFileDocumentPayload(_path, new CultRecordKey("item:a"), Schema.SchemaId).Should().Equal(1); + } + + write.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue(); + CultMesh.ReadSingleFileDocumentPayload(_path, new CultRecordKey("item:a"), Schema.SchemaId).Should().Equal(2); + } + + // A variant at the very key being written is still not this write's to replace: a plain record would flatten it. + [Test] + public void WriteRefusesToReplaceAVariantAtTheSameKey() + { + Write("item:a", 1); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path)); + snapshot.FormatVersion = CultPersistedStoreSnapshot.FormatV2; + snapshot.Records[0].Payload = Array.Empty(); + snapshot.Records[0].Variant = new CultVariantDelta("item:base", Array.Empty()); + File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var before = File.ReadAllBytes(_path); + + Assert.Throws(() => Write("item:a", 2)); + + File.ReadAllBytes(_path).Should().Equal(before); + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 63747c29..5a9838c8 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -696,7 +696,7 @@ public sealed class CultMeshStreamingTests } [Test] - public async Task DocumentHandle_ExposesTypedSnapshotsAndSameSchemaAliases() + public async Task DocumentHandle_ExposesTypedSnapshotsAndWatches() { var subject = new Subject(); var route = new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, "co-located document slab"); @@ -733,17 +733,8 @@ public sealed class CultMeshStreamingTests snapshot.Text.Should().Be("primary"); handle.Latest().Text.Should().Be("primary"); - var alias = handle.AsSchemaAlias(); - var aliasSnapshot = await alias.LatestAsync(); - alias.Latest().Text.Should().Be("primary"); - alias.DocumentId.Should().Be(handle.DocumentId); - alias.SchemaName.Should().Be(handle.SchemaName); - alias.SchemaVersion.Should().Be(handle.SchemaVersion); - aliasSnapshot.Text.Should().Be("primary"); - aliasSnapshot.Revision.Should().Be(1); - - MeshNoteAliasDocument observed = null!; - using var subscription = alias.Watch(value => observed = value); + MeshNoteDocument observed = null!; + using var subscription = handle.Watch(value => observed = value); subject.OnNext(new MeshNoteDocument { Schema = "tests.mesh_note.v1", @@ -790,28 +781,7 @@ public sealed class CultMeshStreamingTests } [Test] - public void DocumentHandle_RejectsAliasTypesWithDifferentSchemaIdentity() - { - var handle = CultMesh.Document( - "mesh.note.current", - CultMesh.Verse("starbridge", "unity-pilot"), - _ => Task.FromResult(new MeshNoteDocument - { - Schema = "tests.mesh_note.v1", - Text = "primary", - Revision = 1 - }), - _ => new Subject()); - - Action act = () => handle.AsSchemaAlias(); - - act.Should() - .Throw() - .WithMessage("*tests.mesh_other*tests.mesh_note*"); - } - - [Test] - public async Task DocumentCatalog_IndexesHandlesByTypeAndSchemaAlias() + public async Task DocumentCatalog_IndexesHandlesByTypeAndSchema() { var current = new MeshNoteDocument { @@ -836,15 +806,12 @@ public sealed class CultMeshStreamingTests .Should() .BeSameAs(handle); - catalog.TryGetDocument(out var alias).Should().BeTrue(); - alias.DocumentId.Should().Be(handle.DocumentId); - alias.SchemaName.Should().Be(handle.SchemaName); - alias.SchemaVersion.Should().Be(handle.SchemaVersion); + catalog.TryGetDocument(out _).Should().BeFalse(); - var aliasSnapshot = await catalog.LatestAsync(); - aliasSnapshot.Text.Should().Be("catalog-primary"); - aliasSnapshot.Revision.Should().Be(3); - catalog.Latest().Text.Should().Be("catalog-primary"); + var snapshot = await catalog.LatestAsync(); + snapshot.Text.Should().Be("catalog-primary"); + snapshot.Revision.Should().Be(3); + catalog.Latest().Text.Should().Be("catalog-primary"); } [Test] @@ -894,21 +861,18 @@ public sealed class CultMeshStreamingTests cache, key, CultMesh.Verse("starbridge", "unity-pilot")); - var alias = handle.AsSchemaAlias(); handle.CanReplace.Should().BeTrue(); - alias.CanReplace.Should().BeTrue(); handle.CanSubmitPrediction.Should().BeFalse(); - alias.CanSubmitPrediction.Should().BeFalse(); handle.DocumentId.Should().Be(key.Value); handle.Sources.Should().ContainSingle().Which.SchemaId.Should().Be(handle.SchemaId); - var snapshot = await alias.LatestAsync(); + var snapshot = await handle.LatestAsync(); snapshot.Text.Should().Be("cache-initial"); - MeshNoteAliasDocument observed = null!; - using var subscription = alias.Watch(value => observed = value); - await alias.ReplaceAsync(new MeshNoteAliasDocument + MeshNoteDocument observed = null!; + using var subscription = handle.Watch(value => observed = value); + await handle.ReplaceAsync(new MeshNoteDocument { Schema = "tests.mesh_note.v1", Text = "cache-replaced", @@ -920,8 +884,8 @@ public sealed class CultMeshStreamingTests observed.Revision.Should().Be(2); var catalog = CultMesh.Documents(handle); - catalog.CanReplace().Should().BeTrue(); - await catalog.ReplaceAsync(new MeshNoteAliasDocument + catalog.CanReplace().Should().BeTrue(); + await catalog.ReplaceAsync(new MeshNoteDocument { Schema = "tests.mesh_note.v1", Text = "cache-catalog-replaced", @@ -929,7 +893,7 @@ public sealed class CultMeshStreamingTests }); cache.Get(key)!.Text.Should().Be("cache-catalog-replaced"); - Func act = () => alias.SubmitPredictionAsync(new MeshNoteAliasDocument + Func act = () => handle.SubmitPredictionAsync(new MeshNoteDocument { Schema = "tests.mesh_note.v1", Text = "cache-predicted", @@ -938,11 +902,11 @@ public sealed class CultMeshStreamingTests await act.Should().ThrowAsync(); } - // A database serves only what its shards own, including when a handle reads a same-schema instance of another CLR type - // out of the cache behind it. + // A database serves only what its shards own. A schema has one CLR type (retire-schema-alias), so there is no second type + // that could reach a record around the shard that owns its schema. [TestCase("tests.mesh_note.v1", true)] [TestCase("tests.unrelated.v1", false)] - public async Task DocumentHandle_ReadsASameSchemaInstanceOnlyWhenAShardOwnsIt(string ownedSchemaId, bool served) + public async Task DocumentHandle_ReadsARecordOnlyWhenAShardOwnsItsSchema(string ownedSchemaId, bool served) { var cache = new CultCache(); var key = new CultRecordKey("note:one"); @@ -952,7 +916,7 @@ public sealed class CultMeshStreamingTests RuntimeId = "reader", Shards = new[] { new CultNetShardDescriptor("notes", "reader", epoch: 1, isPrimary: true, schemaIds: new[] { ownedSchemaId }) } }); - var handle = CultMesh.Document(database, key, CultMesh.Verse("starbridge", "reader")); + var handle = CultMesh.Document(database, key, CultMesh.Verse("starbridge", "reader")); if (served) { @@ -996,19 +960,17 @@ public sealed class CultMeshStreamingTests database, key, CultMesh.Verse("starbridge", "pilot-a")); - var alias = handle.AsSchemaAlias(); handle.CanReplace.Should().BeFalse(); - alias.CanReplace.Should().BeFalse(); - alias.CanSubmitPrediction.Should().BeTrue(); + handle.CanSubmitPrediction.Should().BeTrue(); var catalog = CultMesh.Documents(handle); - catalog.CanSubmitPrediction().Should().BeTrue(); - var predictionWriter = catalog.PredictionWriter(); + catalog.CanSubmitPrediction().Should().BeTrue(); + var predictionWriter = catalog.PredictionWriter(); predictionWriter.Kind.Should().Be(CultMeshDocumentWriteKind.Prediction); MeshNoteDocument observed = null!; using var subscription = handle.Watch(value => observed = value); - await predictionWriter.WriteAsync(new MeshNoteAliasDocument + await predictionWriter.WriteAsync(new MeshNoteDocument { Schema = "tests.mesh_note.v1", Text = "predicted-thermal", @@ -1020,7 +982,7 @@ public sealed class CultMeshStreamingTests observed.Revision.Should().Be(5); (await handle.LatestAsync()).Text.Should().Be("predicted-thermal"); - var updated = await alias.PredictionWriter().UpdateAsync(value => new MeshNoteAliasDocument + var updated = await handle.PredictionWriter().UpdateAsync(value => new MeshNoteDocument { Schema = value.Schema, Text = "updated-as-prediction", @@ -1061,7 +1023,7 @@ public sealed class CultMeshStreamingTests }); var catalog = CultMesh.Documents(handle); - using var reactive = await catalog.PredictionWriter().ReactiveAsync( + using var reactive = await catalog.PredictionWriter().ReactiveAsync( new CultMeshReactiveDocumentOptions { FlushDelay = TimeSpan.FromMinutes(1) @@ -1671,7 +1633,7 @@ public sealed class CultMeshStreamingTests } [Test] - public async Task ReactiveDocument_SyncsSameSchemaAliasesAcrossRuntimeHandles() + public async Task ReactiveDocument_SyncsAcrossRuntimeHandles() { var cache = new CultCache(); var schemaId = CultDocumentRegistry.Shared.GetRequired().SchemaId; @@ -1712,13 +1674,11 @@ public sealed class CultMeshStreamingTests database, key, CultMesh.Verse("starbridge", "commander-rts")); - var observedByCommander = new List(); - using var commanderSubscription = commanderHandle - .AsSchemaAlias() - .Watch(observedByCommander.Add); + var observedByCommander = new List(); + using var commanderSubscription = commanderHandle.Watch(observedByCommander.Add); using var pilotReactive = await CultMesh .Documents(pilotHandle) - .PredictionWriter() + .PredictionWriter() .ReactiveAsync( new CultMeshReactiveDocumentOptions { @@ -1736,7 +1696,7 @@ public sealed class CultMeshStreamingTests commanderHandle.Context.RuntimeId.Should().Be("commander-rts"); observedByCommander.Last().Revision.Should().Be(2); - (await commanderHandle.AsSchemaAlias().LatestAsync()).Text.Should().Be("pilot-predicted"); + (await commanderHandle.LatestAsync()).Text.Should().Be("pilot-predicted"); } [Test] @@ -1781,12 +1741,9 @@ public sealed class CultMeshStreamingTests database, key, CultMesh.Verse("starbridge", "commander-rts")); - MeshNoteAliasDocument commanderSnapshot = null!; - using var commanderSubscription = commanderHandle - .AsSchemaAlias() - .Watch(value => commanderSnapshot = value); + MeshNoteDocument commanderSnapshot = null!; + using var commanderSubscription = commanderHandle.Watch(value => commanderSnapshot = value); using var pilotReactive = await pilotHandle - .AsSchemaAlias() .PredictionWriter() .ReactiveAsync(new CultMeshReactiveDocumentOptions { @@ -1814,7 +1771,7 @@ public sealed class CultMeshStreamingTests } [Test] - public async Task NodeDocument_OpensExplicitPredictionWriterForSameSchemaAlias() + public async Task NodeDocument_OpensExplicitPredictionWriter() { var schemaId = CultDocumentRegistry.Shared.GetRequired().SchemaId; using var temp = new TemporaryDirectory(); @@ -1854,7 +1811,7 @@ public sealed class CultMeshStreamingTests }, new CultRecordHandle(key)); using var pilotReactive = await node - .Document(key, CultMesh.Verse("starbridge", "pilot-a")) + .Document(key, CultMesh.Verse("starbridge", "pilot-a")) .PredictionWriter() .ReactiveAsync(new CultMeshReactiveDocumentOptions { @@ -1877,7 +1834,7 @@ public sealed class CultMeshStreamingTests pilotReactive.Document.DocumentId.Should().Be(key.Value); pilotReactive.Document.Context.RuntimeId.Should().Be("pilot-a"); node.Cache.Get(key)!.Text.Should().Be("node-one-call-prediction"); - (await commanderHandle.AsSchemaAlias().LatestAsync()).Revision.Should().Be(2); + (await commanderHandle.LatestAsync()).Revision.Should().Be(2); } [Test] @@ -1923,8 +1880,8 @@ public sealed class CultMeshStreamingTests DocumentRegistry = registry } }); - var handle = surface.SyncTo(node).Document(key.Value); - MeshNoteAliasDocument observed = null!; + var handle = surface.SyncTo(node).Document(key.Value); + MeshNoteDocument observed = null!; using var subscription = handle.Watch(value => observed = value); await sourceCache.UpsertAsync(new MeshNoteDocument { @@ -1982,7 +1939,7 @@ public sealed class CultMeshStreamingTests } })) { - var stale = await surface.SyncTo(crashedRuntime).Document(key.Value).LatestAsync(); + var stale = await surface.SyncTo(crashedRuntime).Document(key.Value).LatestAsync(); stale.Text.Should().Be("before-crash"); } @@ -2009,7 +1966,7 @@ public sealed class CultMeshStreamingTests var reconstructed = await surface .SyncTo(freshRuntime) .Documents(CultMesh.SnapshotDocument(key.Value)) - .LatestAsync(); + .LatestAsync(); reconstructed.Text.Should().Be("after-reboot-authority"); reconstructed.Revision.Should().Be(4); @@ -2060,7 +2017,7 @@ public sealed class CultMeshStreamingTests }); var readOnlyHandle = surface .SyncTo(node) - .Document(key.Value); + .Document(key.Value); readOnlyHandle.CanReplace.Should().BeFalse(); readOnlyHandle.CanSubmitPrediction.Should().BeFalse(); @@ -2628,34 +2585,6 @@ public sealed class CultMeshStreamingTests cache.Get(key)!.Text.Should().Be("published-directly"); } - [Test] - public void SingleFileDocumentHelpers_ReadLegacySingleDocumentSnapshots() - { - using var directory = new TemporaryDirectory(); - var filePath = Path.Combine(directory.Path, "legacy-single-document.ccmp"); - var key = new CultRecordKey("mesh-note:legacy-single-document"); - var descriptor = CultDocumentRegistry.Shared.GetRequired(); - var payload = CultDocumentMessagePackSerialization.SerializeUntyped(new MeshPublicationNoteDocument - { - Schema = "tests.mesh_publication_note.v1", - Text = "legacy-published", - Revision = 4 - }, typeof(MeshPublicationNoteDocument)); - - File.WriteAllBytes(filePath, WriteLegacySingleDocumentSnapshot( - key.Value, - descriptor.SchemaId, - descriptor.SchemaName, - descriptor.SchemaVersion, - "2026-06-27T12:00:00.0000000Z", - payload)); - - var document = CultMesh.ReadSingleFileDocument(filePath, key); - - document.Text.Should().Be("legacy-published"); - document.Revision.Should().Be(4); - } - [Test] public async Task DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments() { @@ -2671,7 +2600,7 @@ public sealed class CultMeshStreamingTests .Register(CultNetDocumentBinding.ForDocument(CultDocumentRegistry.Shared)); var requests = new List(); - var handle = CultMesh.DocumentFromPublication( + var handle = CultMesh.DocumentFromPublication( CultMeshDocumentPublicationSource.PeerSnapshot( () => new MeshSnapshotSchemaClient(request => { @@ -2692,16 +2621,16 @@ public sealed class CultMeshStreamingTests snapshot.Text.Should().Be("remote-snapshot"); snapshot.Revision.Should().Be(9); - handle.DocumentType.Should().Be(typeof(MeshNoteAliasDocument)); + handle.DocumentType.Should().Be(typeof(MeshNoteDocument)); handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.Network); handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be(key.Value); requests.Should().ContainSingle(); requests[0].SchemaIds.Should().ContainSingle().Which .Should().Be(CultDocumentRegistry.Shared.GetRequired().SchemaId); requests[0].RecordKeys.Should().ContainSingle().Which.Should().Be(key.Value); - (await catalog.LatestAsync()).Text.Should().Be("remote-snapshot"); + (await catalog.LatestAsync()).Text.Should().Be("remote-snapshot"); - var aliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot( + var aliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot( _ => Task.FromResult(new CultNetSnapshotResponseRawMessage { MessageId = "alias-schema", @@ -2729,7 +2658,7 @@ public sealed class CultMeshStreamingTests (await aliasSchemaHandle.LatestAsync()).Text.Should().Be("record-key-fallback"); - var mixedAliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot( + var mixedAliasSchemaHandle = CultMesh.DocumentFromPeerSnapshot( _ => Task.FromResult(new CultNetSnapshotResponseRawMessage { MessageId = "alias-schema-with-neighbor", @@ -2775,7 +2704,7 @@ public sealed class CultMeshStreamingTests // The recordKey match is exact (ordinal), not case-insensitive. A case-variant key must not // win even when it appears first in the response. var caseVariantKey = key.Value.ToUpperInvariant(); - var caseVariantKeyHandle = CultMesh.DocumentFromPeerSnapshot( + var caseVariantKeyHandle = CultMesh.DocumentFromPeerSnapshot( _ => Task.FromResult(new CultNetSnapshotResponseRawMessage { MessageId = "case-variant-key", @@ -2888,7 +2817,7 @@ public sealed class CultMeshStreamingTests CultMesh.PublicationDocument( firstKey, documentId: "daemon:first"), - CultMesh.PublicationDocument( + CultMesh.PublicationDocument( secondKey, documentId: "daemon:second", source: CultMeshDocumentPublicationSource.SingleFile(secondPath)) @@ -2902,10 +2831,10 @@ public sealed class CultMeshStreamingTests catalog.Documents.Select(document => document.DocumentId).Should().Equal("daemon:first", "daemon:second"); (await catalog.LatestAsync()).Text.Should().Be("first source"); - (await catalog.LatestAsync()).Text.Should().Be("second source"); + (await catalog.LatestAsync()).Text.Should().Be("second source"); catalog.Document().DocumentId.Should().Be("daemon:second"); catalog.Document().RouteHint.Description.Should().Be("publication catalog"); - catalog.Document().Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:second"); + catalog.Document().Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:second"); } [Test] @@ -2949,7 +2878,7 @@ public sealed class CultMeshStreamingTests CultMesh.PublicationDocument( firstKey, documentId: "local:first"), - CultMesh.PublicationDocument( + CultMesh.PublicationDocument( secondKey, documentId: "local:second", source: CultMeshDocumentPublicationSource.SingleFile(secondPath)) @@ -2962,16 +2891,16 @@ public sealed class CultMeshStreamingTests catalog.Documents.Select(document => document.DocumentId).Should().Equal("local:first", "local:second"); (await catalog.LatestAsync()).Text.Should().Be("first synced source"); - (await catalog.LatestAsync()).Text.Should().Be("second synced source"); + (await catalog.LatestAsync()).Text.Should().Be("second synced source"); node.Cache.Get(firstKey)!.Revision.Should().Be(31); (await node.Document(secondKey, CultMesh.Verse("starbridge", "unity-sync-catalog")) .LatestAsync()).Text.Should().Be("second synced source"); - (await node.Document(secondKey, CultMesh.Verse("starbridge", "unity-sync-catalog")) + (await node.Document(secondKey, CultMesh.Verse("starbridge", "unity-sync-catalog")) .LatestAsync()).Revision.Should().Be(32); } [Test] - public async Task PublicationHelpers_SyncConfiguredPeerSourceIntoLocalNodeAlias() + public async Task PublicationHelpers_SyncConfiguredPeerSourceIntoLocalNode() { var sourceCache = new CultCache(); var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared); @@ -3000,7 +2929,7 @@ public sealed class CultMeshStreamingTests } }); - var synced = await CultMesh.SyncDocumentFromPublicationAsync( + var synced = await CultMesh.SyncDocumentFromPublicationAsync( node, CultMeshDocumentPublicationSource.PeerSnapshot( () => new MeshSnapshotSchemaClient(request => @@ -3016,12 +2945,12 @@ public sealed class CultMeshStreamingTests MessageIdPrefix = "mesh-test-publication-sync", PollInterval = TimeSpan.FromMilliseconds(10) }); - var localAlias = await node.Document(key, verse).LatestAsync(); + var local = await node.Document(key, verse).LatestAsync(); synced.Text.Should().Be("publication-synced"); synced.Revision.Should().Be(51); - localAlias.Text.Should().Be("publication-synced"); - localAlias.Revision.Should().Be(51); + local.Text.Should().Be("publication-synced"); + local.Revision.Should().Be(51); requests.Should().ContainSingle().Which.RecordKeys.Should().Equal(key.Value); } @@ -3128,7 +3057,7 @@ public sealed class CultMeshStreamingTests } [Test] - public async Task SnapshotHelpers_DecodeSameSchemaAliasFromForeignSchemaId() + public async Task SnapshotHelpers_DecodeFromForeignSchemaId() { var key = new CultRecordKey("mesh-note:snapshot-alias-foreign-schema"); var snapshot = new CultNetSnapshotResponseRawMessage @@ -3154,7 +3083,7 @@ public sealed class CultMeshStreamingTests } }; - var documents = await CultMesh.FetchSnapshotDocumentsAsync( + var documents = await CultMesh.FetchSnapshotDocumentsAsync( "cultnet://foreign-schema.test:3075", new CultMeshSnapshotRequestOptions { @@ -3198,9 +3127,9 @@ public sealed class CultMeshStreamingTests }; var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry( typeof(MeshNoteDocument), - typeof(MeshNoteAliasDocument)); + typeof(MeshNoteDocument)); var registry = CultMesh.CreateCultNetDocumentRegistry( - new[] { typeof(MeshNoteDocument), typeof(MeshNoteAliasDocument) }, + new[] { typeof(MeshNoteDocument), typeof(MeshNoteDocument) }, cacheRegistry); var endpoint = CultMesh.SnapshotEndpoint( "cultnet://foreign-schema.test:3075", @@ -3231,7 +3160,7 @@ public sealed class CultMeshStreamingTests } }); - var synced = await endpoint.SyncDocumentAsync(node, key.Value); + var synced = await endpoint.SyncDocumentAsync(node, key.Value); synced.Text.Should().Be("foreign-schema-synced"); node.Cache.Get(key)!.Text.Should().Be("foreign-schema-synced"); @@ -3239,7 +3168,7 @@ public sealed class CultMeshStreamingTests } [Test] - public async Task SnapshotFacade_SyncsRemoteDocumentIntoLocalNodeWithSameSchemaAlias() + public async Task SnapshotFacade_SyncsRemoteDocumentIntoLocalNode() { var sourceCache = new CultCache(); var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared); @@ -3267,7 +3196,7 @@ public sealed class CultMeshStreamingTests } }); - var synced = await CultMesh.SyncDocumentFromPeerSnapshotAsync( + var synced = await CultMesh.SyncDocumentFromPeerSnapshotAsync( node, () => new MeshSnapshotSchemaClient(request => { @@ -3281,12 +3210,12 @@ public sealed class CultMeshStreamingTests DocumentRegistry = registry, Context = CultMesh.Verse("starbridge", "unity-raven").Context }); - var localAlias = await node.Document(key.Value, CultMesh.Verse("starbridge", "unity-raven")) + var local = await node.Document(key.Value, CultMesh.Verse("starbridge", "unity-raven")) .LatestAsync(); synced.Text.Should().Be("facade-synced"); synced.Revision.Should().Be(46); - localAlias.Text.Should().Be("facade-synced"); + local.Text.Should().Be("facade-synced"); node.Cache.Get(key)!.Revision.Should().Be(46); requests.Should().ContainSingle().Which.RecordKeys.Should().Equal(key.Value); } @@ -3326,9 +3255,9 @@ public sealed class CultMeshStreamingTests var sourceCache = new CultCache(); var cacheRegistry = CultMesh.CreateCultCacheDocumentRegistry( typeof(MeshNoteDocument), - typeof(MeshNoteAliasDocument)); + typeof(MeshNoteDocument)); var networkRegistry = CultMesh.CreateCultNetDocumentRegistry( - new[] { typeof(MeshNoteDocument), typeof(MeshNoteAliasDocument) }, + new[] { typeof(MeshNoteDocument), typeof(MeshNoteDocument) }, cacheRegistry); var key = new CultRecordKey("mesh-note:registry-helper"); await sourceCache.UpsertAsync(new MeshNoteDocument @@ -3339,7 +3268,7 @@ public sealed class CultMeshStreamingTests }, new CultRecordHandle(key)); var endpoint = "cultnet://registry-helper.test:3077"; - var documents = await CultMesh.FetchSnapshotDocumentsAsync( + var documents = await CultMesh.FetchSnapshotDocumentsAsync( endpoint, new CultMeshSnapshotRequestOptions { @@ -3350,15 +3279,15 @@ public sealed class CultMeshStreamingTests networkRegistry); var schemaId = cacheRegistry.GetRequired().SchemaId; - cacheRegistry.GetRequired().SchemaId.Should().Be(schemaId); + cacheRegistry.GetRequired().SchemaId.Should().Be(schemaId); + networkRegistry.GetByDocumentType(typeof(MeshNoteDocument)).Should().NotBeNull(); networkRegistry.GetByDocumentType(typeof(MeshNoteDocument)).Should().NotBeNull(); - networkRegistry.GetByDocumentType(typeof(MeshNoteAliasDocument)).Should().NotBeNull(); networkRegistry.GetBySchemaId(schemaId)!.DocumentType.Should().Be(typeof(MeshNoteDocument)); documents.Should().ContainSingle().Which.Text.Should().Be("registry-helper"); } [Test] - public async Task SnapshotEndpoint_ProvidesTypedHandlesAndSchemaAliases() + public async Task SnapshotEndpoint_ProvidesTypedHandles() { var sourceCache = new CultCache(); var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared); @@ -3390,7 +3319,7 @@ public sealed class CultMeshStreamingTests } }); - var fetchedAlias = await surface.FetchDocumentAsync(key.Value); + var fetched = await surface.FetchDocumentAsync(key.Value); using var node = await CultMesh.CreateNodeAsync( Path.Combine(Path.GetTempPath(), $"cultmesh-snapshot-endpoint-{Guid.NewGuid():N}.ccmp"), new CultMeshNodeOptions @@ -3405,12 +3334,12 @@ public sealed class CultMeshStreamingTests DocumentRegistry = registry } }); - var syncedAlias = await surface.SyncDocumentAsync(node, key.Value); - var aliasHandle = surface.Document(key.Value); - var aliasLatest = await aliasHandle.LatestAsync(); + var syncedDocument = await surface.SyncDocumentAsync(node, key.Value); + var endpointHandle = surface.Document(key.Value); + var endpointLatest = await endpointHandle.LatestAsync(); var catalog = surface.Documents( CultMesh.SnapshotDocument(key.Value, "daemon:mesh-note:snapshot-endpoint")); - var catalogAlias = await catalog.LatestAsync(); + var catalogLatest = await catalog.LatestAsync(); await sourceCache.UpsertAsync(new MeshNoteDocument { Schema = "tests.mesh_note.v1", @@ -3418,7 +3347,7 @@ public sealed class CultMeshStreamingTests Revision = 20 }, new CultRecordHandle(key)); var syncedEndpoint = surface.SyncTo(node); - var syncedHandle = syncedEndpoint.Document(key.Value); + var syncedHandle = syncedEndpoint.Document(key.Value); var syncedLatest = await syncedHandle.LatestAsync(); await sourceCache.UpsertAsync(new MeshNoteDocument { @@ -3428,16 +3357,16 @@ public sealed class CultMeshStreamingTests }, new CultRecordHandle(key)); var syncedCatalog = syncedEndpoint.Documents( CultMesh.SnapshotDocument(key.Value)); - var syncedCatalogAlias = await syncedCatalog.LatestAsync(); + var syncedCatalogLatest = await syncedCatalog.LatestAsync(); - fetchedAlias.Text.Should().Be("snapshot-endpoint"); - syncedAlias.Text.Should().Be("snapshot-endpoint"); - aliasLatest.Revision.Should().Be(19); + fetched.Text.Should().Be("snapshot-endpoint"); + syncedDocument.Text.Should().Be("snapshot-endpoint"); + endpointLatest.Revision.Should().Be(19); catalog.Document().DocumentId.Should().Be("daemon:mesh-note:snapshot-endpoint"); - catalogAlias.Text.Should().Be("snapshot-endpoint"); + catalogLatest.Text.Should().Be("snapshot-endpoint"); syncedLatest.Text.Should().Be("snapshot-endpoint-synced"); node.Cache.Get(key)!.Revision.Should().Be(21); - syncedCatalogAlias.Text.Should().Be("snapshot-endpoint-catalog-synced"); + syncedCatalogLatest.Text.Should().Be("snapshot-endpoint-catalog-synced"); requests.Should().HaveCount(6); requests.Should().OnlyContain(request => request.SchemaIds == null && @@ -3490,7 +3419,7 @@ public sealed class CultMeshStreamingTests } [Test] - public async Task CollectionHandle_SupportsSameSchemaAliases() + public async Task CollectionHandle_IsIndexedInItsCatalogByTypeAndSchema() { var cache = new CultCache(); await cache.UpsertAsync( @@ -3503,14 +3432,12 @@ public sealed class CultMeshStreamingTests new CultRecordHandle(new CultRecordKey("mesh-note:collection"))); var collection = CultMesh.Collection(cache); - var alias = collection.AsSchemaAlias(); var catalog = CultMesh.Collections(collection); - alias.CollectionId.Should().Be(collection.CollectionId); - alias.SchemaName.Should().Be(collection.SchemaName); catalog.Collections.Should().ContainSingle().Which.Should().BeSameAs(collection); - catalog.TryGetCollection(out var catalogAlias).Should().BeTrue(); - catalogAlias.CollectionId.Should().Be(collection.CollectionId); + catalog.TryGetCollection(out var byType).Should().BeTrue(); + byType.Should().BeSameAs(collection); + catalog.TryGetCollection(out _).Should().BeFalse(); catalog.TryGetCollectionBySchema("tests.mesh_note.v1", out var byVersion).Should().BeTrue(); byVersion.Should().BeSameAs(collection); catalog.CollectionBySchema("tests.mesh_note").Should().BeSameAs(collection); @@ -3518,13 +3445,13 @@ public sealed class CultMeshStreamingTests .Should() .BeSameAs(collection); - var snapshot = await alias.LatestAsync(); + var snapshot = await collection.LatestAsync(); snapshot.Should().ContainSingle().Which.Text.Should().Be("collection-alias"); - var catalogSnapshot = await catalog.LatestAsync(); + var catalogSnapshot = await catalog.LatestAsync(); catalogSnapshot.Should().ContainSingle().Which.Revision.Should().Be(4); - CultMeshCollectionChange observed = null!; - using var subscription = catalog.WatchChanges(change => observed = change); + CultMeshCollectionChange observed = null!; + using var subscription = catalog.WatchChanges(change => observed = change); await cache.UpsertAsync( new MeshNoteDocument { @@ -3569,11 +3496,10 @@ public sealed class CultMeshStreamingTests node, key, CultMesh.Verse("starbridge", "unity-pilot")); - var alias = handle.AsSchemaAlias(); MeshNoteDocument observed = null!; using var subscription = handle.Watch(value => observed = value); - await alias.ReplaceAsync(new MeshNoteAliasDocument + await handle.ReplaceAsync(new MeshNoteDocument { Schema = "tests.mesh_note.v1", Text = "node-replaced", @@ -3899,39 +3825,6 @@ public sealed class CultMeshStreamingTests ring.Stats().UnavoidableCopyCount.Should().Be(1); } - private static byte[] WriteLegacySingleDocumentSnapshot( - string key, - string schemaId, - string schemaName, - string schemaVersion, - string storedAt, - byte[] payload) - { - var buffer = new ArrayBufferWriter(); - var writer = new MessagePackWriter(buffer); - - writer.WriteArrayHeader(3); - writer.Write("cultcache.store.v1"); - writer.WriteArrayHeader(1); - writer.WriteArrayHeader(7); - writer.Write(schemaId); - writer.Write(schemaName); - writer.Write(schemaVersion); - writer.Write("Legacy Aetheria single document"); - writer.Write(storedAt); - writer.Write("legacy-hash"); - writer.WriteArrayHeader(0); - writer.WriteArrayHeader(1); - writer.WriteArrayHeader(4); - writer.Write(key); - writer.Write(schemaId); - writer.Write(storedAt); - writer.Write(payload); - writer.Flush(); - - return buffer.WrittenSpan.ToArray(); - } - private static CultMeshStreamCatalog CatalogWithByteStream() { var catalog = CultMesh.CreateStreamCatalog(); @@ -4011,20 +3904,6 @@ public sealed class CultMeshStreamingTests public int Revision { get; set; } } - [CultDocument("tests.mesh_note", "tests.mesh_note.v1")] - [MessagePackObject(AllowPrivate = true)] - internal sealed class MeshNoteAliasDocument - { - [Key(0)] - public string Schema { get; set; } = string.Empty; - - [Key(1)] - public string Text { get; set; } = string.Empty; - - [Key(2)] - public int Revision { get; set; } - } - [CultDocument("tests.mesh_publication_note", "tests.mesh_publication_note.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class MeshPublicationNoteDocument diff --git a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs index 2521aa8a..94f05d34 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs @@ -2,6 +2,7 @@ using System; using System.IO; using FluentAssertions; using GameCult.Caching; +using GameCult.Caching.MessagePack; using NUnit.Framework; #nullable enable @@ -23,8 +24,8 @@ public sealed class CultMeshVariantRefusalTests throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}."); } - private static NotSupportedException Refusal(string vector) => - Assert.Throws(() => + private static CultStoreUnreadableException Refusal(string vector) => + Assert.Throws(() => CultMesh.ReadSingleFileDocumentPayload(VectorPath(vector), new CultRecordKey("item:bellows"), ItemSchemaId))!; [Test] @@ -38,11 +39,47 @@ public sealed class CultMeshVariantRefusalTests message.Should().Contain("item:anvil").And.Contain(ItemSchemaId); } - [Test] - public void ReadSingleFileDocument_RefusesExtraRecordSlotInTheLegacyCatalogLayoutToo() + // The older catalog layout (the content hash at entry slot 5) is a store no C# reader reads: CultMesh refuses it as CultCache + // does, on a read and on a raw write, and a refused write leaves the bytes as they were. + [TestCase("legacy-catalog-plain.msgpack")] + [TestCase("legacy-catalog-extra-slot.msgpack")] + [TestCase("legacy-catalog-trailing.msgpack")] + public void TheOlderCatalogLayoutIsRefusedByMeshAndCultCacheAlike(string vector) { - var message = Refusal("legacy-catalog-extra-slot.msgpack").Message; - message.Should().Contain("item:anvil").And.Contain(ItemSchemaId); + Refusal(vector).Path.Should().Be(VectorPath(vector)); + + var directory = Path.Combine(Path.GetTempPath(), "cultmesh-legacy-layout", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + var path = Path.Combine(directory, vector); + File.Copy(VectorPath(vector), path); + var bytes = File.ReadAllBytes(path); + var raw = new CultMeshSingleFileDocumentSchema("raw:schema", "RawSchema", "1"); + + Assert.Throws(() => + CultMesh.WriteSingleFileDocumentPayload(path, new CultRecordKey("item:bellows"), raw, null, new byte[] { 0x90 }))! + .Path.Should().Be(path); + File.ReadAllBytes(path).Should().Equal(bytes); + + Assert.Throws(() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { FlushOnDispose = false, StoreFlushOnDispose = false }))! + .Path.Should().Be(path); + } + finally + { + Directory.Delete(directory, true); + } + } + + // A store in the current layout with one catalog slot malformed is refused as CultCache refuses it. + [TestCase("current-catalog-bad-canonical.msgpack")] + [TestCase("current-catalog-bad-members.msgpack")] + [TestCase("current-catalog-bad-compat.msgpack")] + [TestCase("current-catalog-bad-slot5-nil.msgpack")] + [TestCase("current-catalog-bad-slot5-int.msgpack")] + public void ReadSingleFileDocument_RefusesAMalformedCurrentCatalogEntry(string vector) + { + Refusal(vector).Path.Should().Be(VectorPath(vector)); } // Q6: CultMesh does not resolve variants. It refuses a variant key naming it and its base, and still reads the plain diff --git a/tests/GameCult.Networking.Tests/CarrierVectorTests.cs b/tests/GameCult.Networking.Tests/CarrierVectorTests.cs new file mode 100644 index 00000000..8d50bb76 --- /dev/null +++ b/tests/GameCult.Networking.Tests/CarrierVectorTests.cs @@ -0,0 +1,178 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // The C# reference's own bytes for the selection/v1 message carriers and the cultnet.error.v0 + // code/details shape, written to contracts/cultnet/interop/carrier-vectors.cs-written.json for + // the runtimes that decode and re-encode them (packages/cultnet-ts reads it; a Rust or Python + // reader may follow). Each vector carries the whole message's MessagePack bytes plus `expected`, + // the reference's own camelCase JSON of the same object (byte[] as base64), so a reader judges + // against what the reference serialized rather than a second hand-written spec. + // + // Gated like SelectionParityVectorTests: CULTNET_WRITE_VECTORS=1 rewrites the committed file; + // otherwise the committed bytes are compared with a fresh serialization so reference drift fails + // here instead of in a reader. + public sealed class CarrierVectorTests + { + private const string LeafA = "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff"; + private const string CiterId = "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc"; + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + private static CultNetSelection FullSelection() => new() + { + Schemas = new[] { "leaf_a.v9", LeafA }, + Keys = new[] { "a-eq", "\U0001F600-row" }, + Fields = new[] + { + new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon", "tool" } }, + new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "-2233759.25" } + }, + Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" }, Role = "Design" }, + Cited = new CultNetIncoming { Role = "Design", Exists = false }, + Projection = CultNetSelectionProjections.Document, + Descending = true, + Limit = 200, + Cursor = "opaque-cursor" + }; + + private static CultNetRawDocumentHeader Header(string key, params string[] tags) => new() + { + SchemaId = LeafA, + SchemaName = "leaf_a", + SchemaVersion = "1", + SchemaContentHash = "abc", + RecordKey = key, + StoredAt = "1970-01-01T00:00:00.000Z", + SourceRuntimeId = "rt", + SourceAgentId = "agent", + SourceRole = "role", + Tags = tags.Length == 0 ? null : tags + }; + + private static IEnumerable<(string Name, ICultNetSchemaMessage Message)> Cases() + { + yield return ("snapshot_request_v1_defaults", new CultNetSnapshotRequestV1Message { MessageId = "m" }); + yield return ("snapshot_request_v1_full_selection_and_shard", new CultNetSnapshotRequestV1Message + { + MessageId = "m-full", Selection = FullSelection(), ShardId = "shard-1", ShardEpoch = 7 + }); + yield return ("database_subscribe_v1_defaults", new CultNetDatabaseSubscribeV1Message { MessageId = "m", SubscriptionId = "sub" }); + yield return ("database_subscribe_v1_full", new CultNetDatabaseSubscribeV1Message + { + MessageId = "m-full", SubscriptionId = "sub-full", Selection = FullSelection(), IncludeSnapshot = false, + ConsumerRuntimeId = "consumer", BodyIds = new[] { "body-a" }, SupportedBodyTransports = new[] { "rudp", "quic" } + }); + yield return ("snapshot_response_raw_v1_headers", new CultNetSnapshotResponseRawV1Message + { + MessageId = "m", Matched = 2, AsOf = 5, Next = "next-cursor", + Headers = new[] { Header("a-eq"), Header("a-lo", "alpha", "beta") }, + ShardId = "shard-1", ShardEpoch = 3, ShardLogSequence = 41 + }); + yield return ("snapshot_response_raw_v1_documents_and_edges", new CultNetSnapshotResponseRawV1Message + { + MessageId = "m", Matched = 1, AsOf = 5, + Documents = new[] + { + new CultNetRawDocumentRecord { SchemaId = LeafA, RecordKey = "a-eq", StoredAt = "1970-01-01T00:00:00.000Z", Payload = new byte[] { 0x81, 0xA1, 0x6B, 0x01 } } + }, + Edges = new[] + { + new CultNetEdge + { + From = new CultNetRecordRef { SchemaId = CiterId, RecordKey = "citer-1" }, Role = "Design", + To = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" } + }, + new CultNetEdge + { + From = new CultNetRecordRef { SchemaId = CiterId, RecordKey = "citer-1" }, Role = "Design", + To = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" }, + PayloadEncoding = "messagepack", Payload = new byte[] { 1, 2, 3 } + } + } + }); + yield return ("error_plain", new CultNetErrorMessage { Error = "boom" }); + yield return ("error_selection_invalid", CultNetErrorMessage.ForSelectionInvalid( + new CultNetSelectionInvalidException("fields[0].index", "kind", "fields[0].index \"kind\" is not declared."))); + yield return ("error_cursor_stale", new CultNetErrorMessage + { + Error = "cursor_stale: stale", Code = "cursor_stale", Details = new CultNetErrorDetails { AsOf = 3, Current = 9 } + }); + yield return ("error_cursor_invalid_no_details", new CultNetErrorMessage { Error = "cursor_invalid: bad", Code = "cursor_invalid" }); + yield return ("error_write_not_authorized", new CultNetErrorMessage + { + Error = "write_not_authorized", Code = "write_not_authorized", Details = new CultNetErrorDetails { Field = "recordKey", Value = "k" } + }); + } + + private static object Vector(string name, ICultNetSchemaMessage message) + { + var bytes = MessagePackSerializer.Serialize(message.GetType(), message, CultNetSchemaMessageSerialization.Options); + return new + { + name, + schemaVersion = message.SchemaVersion, + messagePackBase64 = Convert.ToBase64String(bytes), + expected = WithoutDerivedMembers(JsonSerializer.SerializeToNode(message, message.GetType(), JsonOptions)) + }; + } + + // CultNetSelection.HasHop is an [IgnoreMember] convenience, not wire; System.Text.Json would emit it. + private static JsonNode? WithoutDerivedMembers(JsonNode? node) + { + switch (node) + { + case JsonObject obj: + obj.Remove("hasHop"); + foreach (var child in obj.Select(pair => pair.Value).ToArray()) WithoutDerivedMembers(child); + break; + case JsonArray array: + foreach (var child in array) WithoutDerivedMembers(child); + break; + } + return node; + } + + private static string VectorPath() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln"))) + directory = directory.Parent; + var root = directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory."); + return Path.Combine(root, "contracts", "cultnet", "interop", "carrier-vectors.cs-written.json"); + } + + [Test] + public void WriteCarrierVectors() + { + if (Environment.GetEnvironmentVariable("CULTNET_WRITE_VECTORS") != "1") + Assert.Ignore("Set CULTNET_WRITE_VECTORS=1 to (re)write the committed vector file."); + + var vectors = Cases().Select(c => Vector(c.Name, c.Message)).ToArray(); + File.WriteAllText(VectorPath(), JsonSerializer.Serialize(new { vectors }, new JsonSerializerOptions { WriteIndented = true })); + } + + [Test] + public void CommittedCarrierVectorsMatchAFreshSerialization() + { + using var committed = JsonDocument.Parse(File.ReadAllText(VectorPath())); + var byName = committed.RootElement.GetProperty("vectors").EnumerateArray() + .ToDictionary(v => v.GetProperty("name").GetString()!, v => v.GetProperty("messagePackBase64").GetString()!); + + Assert.That(byName.Count, Is.EqualTo(Cases().Count()), "the committed file must carry exactly the cases this writer produces"); + foreach (var (name, message) in Cases()) + { + var fresh = Convert.ToBase64String(MessagePackSerializer.Serialize(message.GetType(), message, CultNetSchemaMessageSerialization.Options)); + Assert.That(byName[name], Is.EqualTo(fresh), $"{name}: the committed bytes drifted from the reference serializer"); + } + } + } +} diff --git a/tests/GameCult.Networking.Tests/CultNetDocumentRegistryConflictTests.cs b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryConflictTests.cs new file mode 100644 index 00000000..6f48ee7e --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryConflictTests.cs @@ -0,0 +1,91 @@ +#nullable enable +using System; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // F7 (operator, 2026-09-30): "Schema aliasing sounds like a really bad idea, I support retiring it." One binding owns a + // document type and a schema id, in the reference runtime as in Rust, Kotlin and TypeScript. + public sealed class CultNetDocumentRegistryConflictTests + { + private static CultNetDocumentBinding Bind(string? schemaId = null) where T : class => + CultNetDocumentBinding.ForDocument(CultDocumentRegistry.Shared, schemaId); + + // Two bindings stand ahead of the holder, so a conflict check that looks only at the first holder misses it. + private static CultNetDocumentRegistry RegistryHolding(CultNetDocumentBinding holder) => + new CultNetDocumentRegistry(CultDocumentRegistry.Shared) + .Register(Bind()) + .Register(Bind()) + .Register(holder); + + [Test] + public void A_second_type_for_one_schema_id_is_refused_typed_in_either_order() + { + var first = Bind("f5.shared"); + var second = Bind("f5.shared"); + foreach (var (held, claimant) in new[] { (first, second), (second, first) }) + { + var registry = RegistryHolding(held); + var error = Assert.Throws(() => registry.Register(claimant))!; + Assert.That(error.SchemaId, Is.EqualTo("f5.shared")); + Assert.That(error.SchemaNames, Is.EqualTo(new[] { held.DocumentType.FullName, claimant.DocumentType.FullName })); + Assert.That(error.RecordKey, Is.EqualTo(string.Empty)); + Assert.That(error.Message, Does.Contain($"type \"{claimant.DocumentType.FullName}\"")); + // Nothing was bound: the schema id still resolves to the first type, and the claimant's type to nothing. + Assert.That(registry.GetBySchemaId("f5.shared"), Is.SameAs(held)); + Assert.That(registry.GetByDocumentType(claimant.DocumentType), Is.Null); + } + } + + [Test] + public void A_type_bound_to_one_schema_id_cannot_be_bound_to_another() + { + var own = Bind(); + var registry = RegistryHolding(own); + var error = Assert.Throws(() => registry.Register(Bind("f5.elsewhere")))!; + Assert.That(error.SchemaId, Is.EqualTo("f5.elsewhere")); + Assert.That(registry.GetBySchemaId("f5.elsewhere"), Is.Null); + Assert.That(registry.GetByDocumentType(typeof(ConflictNote)), Is.SameAs(own)); + } + + [Test] + public void Binding_a_type_to_the_schema_id_it_holds_again_replaces_the_binding() + { + var registry = RegistryHolding(Bind()); + var again = Bind(); + registry.Register(again); + Assert.That(registry.GetByDocumentType(typeof(ConflictNote)), Is.SameAs(again)); + Assert.That(registry.GetBySchemaId(again.SchemaId), Is.SameAs(again)); + } + + [CultDocument("f5.conflict-note", "f5.conflict-note.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ConflictNote + { + [Key(0)] public string Body = string.Empty; + } + + [CultDocument("f5.conflict-other-note", "f5.conflict-other-note.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ConflictOtherNote + { + [Key(0)] public string Body = string.Empty; + } + + [CultDocument("f5.conflict-filler-a", "f5.conflict-filler-a.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ConflictFillerA + { + [Key(0)] public string Body = string.Empty; + } + + [CultDocument("f5.conflict-filler-b", "f5.conflict-filler-b.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ConflictFillerB + { + [Key(0)] public string Body = string.Empty; + } + } +} diff --git a/tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs b/tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs new file mode 100644 index 00000000..67c2f06d --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs @@ -0,0 +1,80 @@ +#nullable enable +using System; +using System.IO; +using NUnit.Framework; + +// snippet:begin +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Networking; +using MessagePack; + +[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")] +[MessagePackObject] +public sealed class Note +{ + [Key(0)] [CultName] public string Title = string.Empty; + [Key(1)] public string Body = string.Empty; +} + +public static class CultNetFirstCall +{ + public static async Task Run() + { + var inbox = new CultCache(); // the receiver's cache + var documents = new CultNetDocumentRegistry(); + var received = new TaskCompletionSource(); + + // Receive: a loopback listener that applies each raw document put to the inbox. + using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0)); + server.OnCultNet(async (put, peer) => + { + await documents.ApplyRawDocumentPutMessageAsync(inbox, put); + received.TrySetResult(true); + }); + + // Send: a typed document as a cultnet.document_put_raw.v0 message. + using var client = new TcpFramedCultNetSchemaClient(); + client.Connect("127.0.0.1", server.LocalEndPoint.Port); + var handle = new CultRecordHandle(new CultRecordKey("note:hello")); + client.SendCultNet(documents.CreateRawDocumentPutMessage( + "put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" })); + + await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); + return inbox.Get(handle.Key); + } +} +// snippet:end + +namespace GameCult.Networking.Tests +{ + // The C# CultNet first-call snippet that docs/start/README.md shows verbatim: define a typed document, send it + // over a loopback TCP pipe, receive it into a second cache. DocsShowTheTestedSnippet fails when they differ. + public class CultNetFirstCallTests + { + [Test] + public async Task SendATypedDocumentOverLoopbackAndReceiveItIntoASecondCache() + { + var note = await CultNetFirstCall.Run(); + Assert.That(note, Is.Not.Null); + Assert.That(note!.Title, Is.EqualTo("Hello")); + Assert.That(note.Body, Is.EqualTo("sent over CultNet")); + } + + [Test] + public void DocsShowTheTestedSnippet() + { + var source = File.ReadAllText(SourcePath()); + const string begin = "// snippet:begin\n"; + var start = source.IndexOf(begin, StringComparison.Ordinal) + begin.Length; + var snippet = source[start..source.IndexOf("// snippet:end\n", StringComparison.Ordinal)]; + var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(SourcePath())!, "..", "..", "docs", "start", "README.md")); + Assert.That(docs.Replace("\r\n", "\n"), Does.Contain("```csharp\n" + snippet + "```"), + "docs/start/README.md no longer shows tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs verbatim"); + } + + static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path; + } +} diff --git a/tests/GameCult.Networking.Tests/CultNetJournalTests.cs b/tests/GameCult.Networking.Tests/CultNetJournalTests.cs index 6285d674..eb68b1fc 100644 --- a/tests/GameCult.Networking.Tests/CultNetJournalTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetJournalTests.cs @@ -733,7 +733,7 @@ namespace GameCult.Networking.Tests using var server = new Server(cache, ServerSecurityOptions.Development()); var logger = new CapturingLogger(); server.Logger = logger; - using var databaseServer = new CultNetDatabaseServer(server, database); + using var databaseServer = new CultNetDatabaseServer(server, database, new CultNetDatabaseServerOptions { RemoteWriteAuthority = new TestGrantsEverything() }); var handler = (Func)typeof(CultNetDatabaseServer) .GetField("_putHandler", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .GetValue(databaseServer)!; @@ -929,7 +929,7 @@ namespace GameCult.Networking.Tests var host = new Server(cache, ServerSecurityOptions.Development()); var logger = new CapturingLogger(); host.Logger = logger; - var databaseServer = new CultNetDatabaseServer(host, database); + var databaseServer = new CultNetDatabaseServer(host, database, new CultNetDatabaseServerOptions { RemoteWriteAuthority = new TestGrantsEverything() }); var peer = (CultNetServerPeer)System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(CultNetServerPeer)); const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; var put = (Func)typeof(CultNetDatabaseServer).GetField("_putHandler", flags)!.GetValue(databaseServer)!; diff --git a/tests/GameCult.Networking.Tests/CultNetRudpMultiPeerVectorTests.cs b/tests/GameCult.Networking.Tests/CultNetRudpMultiPeerVectorTests.cs new file mode 100644 index 00000000..0985d516 --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetRudpMultiPeerVectorTests.cs @@ -0,0 +1,155 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + /// + /// The multi-peer listener's golden in tests/vectors/rudp-multipeer-server: two peers on one socket, the + /// datagrams each sends and the datagrams the C# reference answers with. The C# reference is the only writer + /// (CULTNET_WRITE_VECTORS=1); every other run replays the scenario and compares it byte for byte. Other runtimes' + /// listeners replay the same transcript and are held to the same answers. + /// + public class CultNetRudpMultiPeerVectorTests + { + private const string WriteVariable = "CULTNET_WRITE_VECTORS"; + private const uint ConnectionId = 0x43554c54; + + private sealed class Client + { + public string Label = ""; + public Socket Socket = null!; + public CultNetRudpSession Session = null!; + } + + private static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant(); + + private static Socket Bind() + { + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + return socket; + } + + [Test] + public void TheReferenceListenerAnswersTwoPeersWithThePinnedBytes() + { + var transcript = Run(); + var path = Path.Combine(VectorDirectory(), "transcript.txt"); + if (Environment.GetEnvironmentVariable(WriteVariable) == "1") + { + File.WriteAllText(path, transcript); + Assert.Pass("wrote " + path); + } + Assert.That(transcript, Is.EqualTo(File.ReadAllText(path))); + } + + private static string VectorDirectory() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !Directory.Exists(Path.Combine(directory.FullName, "tests", "vectors"))) + directory = directory.Parent; + if (directory == null) throw new InvalidOperationException("tests/vectors not found above the test directory."); + return Path.Combine(directory.FullName, "tests", "vectors", "rudp-multipeer-server"); + } + + private static string Run() + { + var log = new StringBuilder(); + log.Append("# config connection_id=0x43554c54 initial_sequence=500 max_fragment_bytes=8 accept_payload=616363\n"); + log.Append("# in | send | out ; peer labels name remote endpoints\n"); + var serverSocket = Bind(); + using var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions + { + RuntimeId = "csharp-vector", + Socket = serverSocket, + ConnectionId = ConnectionId, + InitialSequence = 500, + MaxFragmentBytes = 8, + AcceptPayload = Encoding.ASCII.GetBytes("acc") + }); + var target = serverSocket.LocalEndPoint!; + Client Make(string label, uint sequence) => new Client + { + Label = label, + Socket = Bind(), + Session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId, InitialSequence = sequence }) + }; + var x = Make("X", 10); + var y = Make("Y", 20); + var clients = new[] { x, y }; + + void Deliver(Client client, CultNetRudpPacket packet) + { + var wire = CultNetRudpPacketCodec.Encode(packet); + log.Append($"in {client.Label} {Hex(wire)}\n"); + client.Socket.SendTo(wire, target); + while (!server.TryReceiveOnce(out _)) + System.Threading.Thread.Sleep(1); + Flush(); + } + + void Flush() + { + foreach (var client in clients) + { + var buffer = new byte[65535]; + EndPoint from = new IPEndPoint(IPAddress.Any, 0); + while (client.Socket.Poll(30_000, SelectMode.SelectRead)) + { + var received = client.Socket.ReceiveFrom(buffer, ref from); + var wire = buffer.AsSpan(0, received).ToArray(); + log.Append($"out {client.Label} {Hex(wire)}\n"); + // Clients that stay connected take the server's packets so their acks stay true. + var packet = CultNetRudpPacketCodec.Decode(wire); + if (client.Session.Connected || packet.PacketType == CultNetRudpPacketType.Accept) + client.Session.Receive(packet, 0); + } + } + } + + CultNetRudpSocketServerPeer PeerOf(Client client) => + server.Peers.Single(p => p.RemoteEndPoint.Equals(client.Socket.LocalEndPoint)); + + void Send(Client client, string channel, byte[] payload) + { + log.Append($"send {client.Label} {channel} {Hex(payload)}\n"); + server.Send(PeerOf(client), channel, payload); + Flush(); + } + + CultNetRudpPacket Data(Client client, string text) => + client.Session.Send("schema", Encoding.ASCII.GetBytes(text), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); + + var xConnect = x.Session.CreateConnect(0, Encoding.ASCII.GetBytes("hx")); + Deliver(x, xConnect); + Deliver(y, y.Session.CreateConnect(0, Encoding.ASCII.GetBytes("hy"))); + // A repeat of X's accepted Connect is answered with the Accept owed, not a new session. + Deliver(x, xConnect); + Deliver(x, Data(x, "ping-x")); + Deliver(y, Data(y, "ping-y")); + Send(x, "schema", Encoding.ASCII.GetBytes("pong")); + // Larger than the fragment size: three fragments, one per datagram. + Send(y, "schema", Encoding.ASCII.GetBytes("0123456789abcdefghijklmn")); + Send(x, "latest", Encoding.ASCII.GetBytes("sample")); + // X leaves; Y is undisturbed, and what X sends afterwards belongs to no session. + var late = Data(x, "after-bye"); + Deliver(x, x.Session.CreateDisconnect(Encoding.ASCII.GetBytes("bye"))); + Deliver(x, late); + Send(y, "schema", Encoding.ASCII.GetBytes("still-here")); + // Y reconnects from the same endpoint: a new generation replaces only Y. + y.Session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId, InitialSequence = 30 }); + Deliver(y, y.Session.CreateConnect(0, Encoding.ASCII.GetBytes("hy2"))); + Send(y, "schema", Encoding.ASCII.GetBytes("new-gen")); + log.Append($"# peers {server.Peers.Count} dropped {server.Stats.PacketsDropped}\n"); + foreach (var client in clients) client.Socket.Dispose(); + return log.ToString(); + } + } +} diff --git a/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs b/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs index 3aa2705d..23bb5f5b 100644 --- a/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs @@ -206,6 +206,33 @@ namespace GameCult.Networking.Tests AssertErrorMatchesSchema(message); } + [Test] + public void ErrorMessage_RemoteWriteRefusedRealBytes_MatchesItsSchema() + { + var request = new CultNetRemoteWriteRequest(null!, CultNetRemoteWriteOperation.Put, "some.schema.v1", new CultRecordKey("some:key"), null, null); + var message = CultNetErrorMessage.ForRemoteWriteRefused(request, "not granted"); + + Assert.That(message.Code, Is.EqualTo("write_not_authorized")); + Assert.That(message.Details!.Field, Is.EqualTo("recordKey")); + Assert.That(message.Details.Value, Is.EqualTo("some:key")); + AssertErrorMatchesSchema(message); + // The bytes cultnet-rs's error_message.rs write_not_authorized case decodes and re-encodes. + Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0")); + } + + [Test] + public void ErrorMessage_UnsupportedSchemaVersionRealBytes_MatchesItsSchema() + { + var message = CultNetErrorMessage.ForUnsupportedSchemaVersion("cultnet.hello.v1"); + + Assert.That(message.Code, Is.EqualTo("unsupported_schema_version")); + Assert.That(message.Details!.Field, Is.EqualTo("schemaVersion")); + Assert.That(message.Details.Value, Is.EqualTo("cultnet.hello.v1")); + AssertErrorMatchesSchema(message); + // The bytes cultnet-rs's error_message.rs unsupported_schema_version case decodes and re-encodes. + Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0")); + } + private static void AssertErrorMatchesSchema(CultNetErrorMessage message) { var validator = new MiniJsonSchemaValidator(SchemaDir()); diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs index e4c31d64..9ecc4556 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs @@ -600,6 +600,7 @@ namespace GameCult.Networking.Tests "selection.fixture.guard_probe", "selection.fixture.guard_probe.v1", "selection.fixture.guard_probe.v1", + Array.Empty(), "sha256:guard-probe", "{}", false, diff --git a/tests/GameCult.Networking.Tests/RemoteWriteAuthorityTests.cs b/tests/GameCult.Networking.Tests/RemoteWriteAuthorityTests.cs new file mode 100644 index 00000000..897674b1 --- /dev/null +++ b/tests/GameCult.Networking.Tests/RemoteWriteAuthorityTests.cs @@ -0,0 +1,439 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using GameCult.Caching; +using LiteNetLib; +using NUnit.Framework; +using R3; +using static GameCult.Networking.Tests.NetworkingTests; + +namespace GameCult.Networking.Tests +{ + // A peer's put or delete is applied or forwarded only when the server's RemoteWriteAuthority grants it. The wire handlers + // are reached through their private delegates with an uninitialized peer, as CultNetJournalTests.WireHandlers does: + // anything the handler tries to send throws on that peer, so a handler that throws answered the peer with a refusal and a + // handler that returns quietly sent nothing. + // The tests' own grant-everyone authority, for tests whose subject is not the grant. The library ships none. + internal sealed class TestGrantsEverything : ICultNetRemoteWriteAuthority + { + public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) => CultNetRemoteWriteDecision.Grant(); + } + + public sealed class RemoteWriteAuthorityTests + { + private const string ShardId = "authority-shard"; + private static readonly CultRecordKey KeyA = new("tests:authority:a"); + private static readonly CultRecordKey KeyB = new("tests:authority:b"); + private static readonly CultRecordKey Unowned = new("unrelated:authority"); + + private static NetworkSchemaNote Note(string text) => new() { Schema = "tests.networking_note.v1", Text = text }; + + private sealed class Rig + { + public CultCache Cache = null!; + public CultNetDatabase Database = null!; + public CultNetDatabaseServer DatabaseServer = null!; + public Server Host = null!; + public CultNetServerPeer Peer = null!; + public CapturingLogger Logger = null!; + public Func Put = null!; + public Func Delete = null!; + public string SchemaId = string.Empty; + public List Published = new(); + public IDisposable Subscription = null!; + + public string? TextOf(CultRecordKey key) => Cache.Get(key)?.Text; + public int LogLength => Database.GetMutationLog(ShardId).Count; + + public CultNetDocumentPutRawMessage PutOf(CultRecordKey key, string text) + { + var message = Database.Documents.CreateRawDocumentPutMessage("wire", new CultRecordHandle(key), Note(text)); + message.ShardId = ShardId; + message.ShardEpoch = 1; + return message; + } + + public CultNetDocumentDeleteMessage DeleteOf(CultRecordKey key) => + new() { MessageId = "wire-delete", SchemaId = SchemaId, RecordKey = key.Value, ShardId = ShardId, ShardEpoch = 1 }; + } + + private static Rig Build(ICultNetRemoteWriteAuthority? authority, bool primary = true, ICultNetShardWriteForwarder? forwarder = null, CultNetServerPeer? peer = null) + { + var cache = new CultCache(); + var schemaId = cache.Registry.GetRequired().SchemaId; + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + Shards = + [ + new CultNetShardDescriptor(ShardId, "primary", epoch: 1, isPrimary: primary, schemaIds: [schemaId], + primaryEndpoints: primary ? null : ["cultnet://runtime-owner:3075"]) + ] + }); + var host = new Server(cache, ServerSecurityOptions.Development()); + var logger = new CapturingLogger(); + host.Logger = logger; + var databaseServer = new CultNetDatabaseServer(host, database, new CultNetDatabaseServerOptions + { + RemoteWriteAuthority = authority, + ForwardNonPrimaryWrites = forwarder != null, + WriteForwarder = forwarder + }); + peer ??= (CultNetServerPeer)RuntimeHelpers.GetUninitializedObject(typeof(CultNetServerPeer)); + const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance; + var put = (Func)typeof(CultNetDatabaseServer).GetField("_putHandler", flags)!.GetValue(databaseServer)!; + var delete = (Func)typeof(CultNetDatabaseServer).GetField("_deleteHandler", flags)!.GetValue(databaseServer)!; + var rig = new Rig + { + Cache = cache, + Database = database, + DatabaseServer = databaseServer, + Host = host, + Peer = peer, + Logger = logger, + SchemaId = schemaId, + Put = message => put(message, peer!), + Delete = message => delete(message, peer!) + }; + rig.Subscription = database.WatchAllChanges().Subscribe(change => rig.Published.Add(change)); + return rig; + } + + // The handler answered the peer with a refusal: the send reached the (uninitialized) peer and failed there, so the + // refusal message was built first. A failure earlier, in building it, would not pass through the peer's send. + private static void AssertAnswered(Func handler, string because) + { + var failure = Assert.CatchAsync(async () => await handler(), because)!; + Assert.That(failure.ToString(), Does.Contain("CultNetServerPeer.SendCultNet").And.Not.Contain("ForRemoteWriteRefused"), because); + } + + // The server writes its own record through the host's door, which asks no remote authority. + private static async Task Seed(Rig rig, CultRecordKey key, string text) => await rig.Database.ApplyPutAsync(rig.PutOf(key, text)); + + [Test] + public async Task WithNoAuthorityAPeersPutAndDeleteAreRefusedAndChangeNothing() + { + var rig = Build(authority: null); + await Seed(rig, KeyA, "server"); + var publishedBefore = rig.Published.Count; + var logBefore = rig.LogLength; + Assert.That(rig.TextOf(KeyA), Is.EqualTo("server"), "the host's own door needs no remote authority"); + + AssertAnswered(() => rig.Put(rig.PutOf(KeyA, "peer")), "the peer is answered with a refusal"); + AssertAnswered(() => rig.Delete(rig.DeleteOf(KeyA)), "the peer is answered with a refusal"); + AssertAnswered(() => rig.Put(rig.PutOf(KeyB, "peer")), "a new key is refused too"); + + Assert.That(rig.TextOf(KeyA), Is.EqualTo("server")); + Assert.That(rig.TextOf(KeyB), Is.Null); + Assert.That(rig.Published, Has.Count.EqualTo(publishedBefore), "a refusal publishes no change"); + Assert.That(rig.LogLength, Is.EqualTo(logBefore), "a refusal appends no mutation log entry"); + Assert.That(rig.Logger.Warnings, Has.Some.Contains("raw put refused")); + Assert.That(rig.Logger.Warnings, Has.Some.Contains("raw delete refused")); + Assert.That(rig.Logger.Errors, Is.Empty, "a refusal is not a fault"); + } + + [Test] + public async Task AGrantedPutAndDeleteCommitAndTheAuthoritySawThisPeer() + { + var authority = new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant()); + var rig = Build(authority); + + await rig.Put(rig.PutOf(KeyA, "peer")); + Assert.That(rig.TextOf(KeyA), Is.EqualTo("peer")); + Assert.That(rig.Published, Has.Count.EqualTo(1)); + Assert.That(rig.LogLength, Is.EqualTo(1)); + + await rig.Delete(rig.DeleteOf(KeyA)); + Assert.That(rig.TextOf(KeyA), Is.Null); + + Assert.That(authority.Requests, Has.Count.EqualTo(2), "asked once per received write"); + Assert.That(authority.Requests.Select(request => request.Peer), Has.All.SameAs(rig.Peer)); + Assert.That(rig.Logger.Errors, Is.Empty); + } + + [Test] + public async Task TheRequestCarriesTheResolvedWriteAndScopedGrantsRefuseTheirNeighbours() + { + // The put names a foreign alias of the schema; the payload routes it to the canonical descriptor. + var recorded = new RecordingAuthority(request => + request.Operation == CultNetRemoteWriteOperation.Put && request.RecordKey.Equals(KeyA) + ? CultNetRemoteWriteDecision.Grant() + : CultNetRemoteWriteDecision.Refuse("scoped")); + var rig = Build(recorded); + await Seed(rig, KeyA, "server"); + var aliased = rig.PutOf(KeyA, "aliased"); + aliased.Document.SchemaId = "runtime.generated.network-note.ui.42"; + + await rig.Put(aliased); + + var request = recorded.Requests.Single(); + Assert.That(request.SchemaId, Is.EqualTo(rig.SchemaId), "the authority sees the canonical id, not the alias as sent"); + Assert.That(request.Operation, Is.EqualTo(CultNetRemoteWriteOperation.Put)); + Assert.That(request.RecordKey, Is.EqualTo(KeyA)); + Assert.That(request.ShardId, Is.EqualTo(ShardId)); + Assert.That(request.ShardEpoch, Is.EqualTo(1)); + Assert.That(rig.TextOf(KeyA), Is.EqualTo("aliased")); + + AssertAnswered(() => rig.Put(rig.PutOf(KeyB, "other-key")), "the peer is answered with a refusal"); + AssertAnswered(() => rig.Delete(rig.DeleteOf(KeyA)), "the peer is answered with a refusal"); + Assert.That(rig.TextOf(KeyB), Is.Null, "a grant scoped to key a refuses key b"); + Assert.That(rig.TextOf(KeyA), Is.EqualTo("aliased"), "a grant scoped to Put refuses Delete"); + Assert.That(recorded.Requests.Last().Operation, Is.EqualTo(CultNetRemoteWriteOperation.Delete)); + Assert.That(recorded.Requests.Last().SchemaId, Is.EqualTo(rig.SchemaId)); + } + + [Test] + public async Task ARefusedWriteIsNeverForwardedAndAGrantedOneIs() + { + var forwarder = new CountingForwarder(); + var refusing = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Refuse("no")), primary: false, forwarder); + + AssertAnswered(() => refusing.Put(refusing.PutOf(KeyA, "peer")), "the peer is answered with a refusal"); + AssertAnswered(() => refusing.Delete(refusing.DeleteOf(KeyA)), "the peer is answered with a refusal"); + Assert.That(forwarder.Puts, Is.EqualTo(0), "a refused put is not forwarded"); + Assert.That(forwarder.Deletes, Is.EqualTo(0), "a refused delete is not forwarded"); + + var unconfigured = Build(authority: null, primary: false, forwarder); + AssertAnswered(() => unconfigured.Put(unconfigured.PutOf(KeyA, "peer")), "the peer is answered with a refusal"); + Assert.That(forwarder.Puts, Is.EqualTo(0), "no authority forwards nothing"); + + var granting = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant()), primary: false, forwarder); + await granting.Put(granting.PutOf(KeyA, "peer")); + await granting.Delete(granting.DeleteOf(KeyA)); + Assert.That(forwarder.Puts, Is.EqualTo(1), "a granted write on a non-primary shard is forwarded as before"); + Assert.That(forwarder.Deletes, Is.EqualTo(1)); + } + + [Test] + public void RefusalPrecedesTheUnownedSchemaCheck() + { + var unowned = new MeshQuickstartNote { NoteId = "u", Body = "unowned" }; + var rig = Build(authority: null); + var put = rig.Database.Documents.CreateRawDocumentPutMessage("wire", new CultRecordHandle(Unowned), unowned); + put.ShardId = ShardId; + put.ShardEpoch = 1; + + AssertAnswered(() => rig.Put(put), "the peer is answered with a refusal"); + + Assert.That(rig.Logger.Warnings, Has.Some.Contains("authority"), "an ungranted put of an unowned schema is answered write_not_authorized"); + Assert.That(rig.Logger.Warnings, Has.None.Contains("No shard owns")); + + var granting = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant())); + var grantedPut = granting.Database.Documents.CreateRawDocumentPutMessage("wire", new CultRecordHandle(Unowned), unowned); + grantedPut.ShardId = ShardId; + grantedPut.ShardEpoch = 1; + AssertAnswered(() => granting.Put(grantedPut), "the peer is answered with a refusal"); + Assert.That(granting.Logger.Warnings, Has.Some.Contains("No shard owns"), "a granted put still meets the unowned-schema refusal"); + } + + [Test] + public async Task AnAuthorityThatThrowsRefusesAndIsLoggedAsAnError() + { + var rig = Build(new RecordingAuthority(_ => throw new InvalidOperationException("authority exploded"))); + await Seed(rig, KeyA, "server"); + + AssertAnswered(() => rig.Put(rig.PutOf(KeyA, "peer")), "the peer is answered with a refusal"); + AssertAnswered(() => rig.Delete(rig.DeleteOf(KeyA)), "the peer is answered with a refusal"); + + Assert.That(rig.TextOf(KeyA), Is.EqualTo("server"), "fail closed"); + Assert.That(rig.Logger.Errors, Has.Some.Contains("authority exploded")); + } + + [Test] + public void ARefusalWithNoReasonStillSaysWhy() + { + var rig = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Refuse())); + + AssertAnswered(() => rig.Put(rig.PutOf(KeyA, "peer")), "refused"); + + Assert.That(rig.Logger.Warnings, Has.Some.Contains("raw put refused: the write was not authorized")); + } + + [Test] + public void TheRefusalNamesTheKeyAndTheCode() + { + var request = new CultNetRemoteWriteRequest(null!, CultNetRemoteWriteOperation.Delete, "s", KeyA, null, null); + var message = CultNetErrorMessage.ForRemoteWriteRefused(request, "why not"); + + Assert.That(message.Code, Is.EqualTo("write_not_authorized")); + Assert.That(message.Error, Is.EqualTo("write_not_authorized: why not")); + Assert.That(message.Details!.Field, Is.EqualTo("recordKey")); + Assert.That(message.Details.Value, Is.EqualTo(KeyA.Value)); + Assert.That(message.RoutingHint, Is.Null); + Assert.That(CultNetErrorMessage.ForRemoteWriteRefused(request, null).Error, Is.EqualTo("write_not_authorized")); + } + + // A real LiteNetLib pair on loopback with both sockets bound to ephemeral ports (Server and Client bind fixed ports, + // which a parallel suite cannot share). The server side is a real NetPeer wrapped as the CultNetServerPeer the handlers + // answer; the client side decodes what arrives on the wire with the production schema decoder. + private sealed class WirePair : IDisposable + { + private readonly NetManager _serverManager; + private readonly NetManager _clientManager; + public CultNetServerPeer Peer { get; } + public ConcurrentQueue Errors { get; } = new(); + + public WirePair() + { + var serverListener = new EventBasedNetListener(); + _serverManager = new NetManager(serverListener) { UnsyncedEvents = true }; + var clientListener = new EventBasedNetListener(); + _clientManager = new NetManager(clientListener) { UnsyncedEvents = true }; + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + serverListener.ConnectionRequestEvent += request => request.AcceptIfKey("rwa-wire"); + serverListener.PeerConnectedEvent += serverSide => connected.TrySetResult(serverSide); + var decoder = new LiteNetLibTransportConnection(); + clientListener.NetworkReceiveEvent += (_, reader, _, _) => + { + var frame = decoder.Receive(reader.GetRemainingBytes()); + if (LiteNetLibTransportConnection.DecodeSchema(frame) is CultNetErrorMessage error) Errors.Enqueue(error); + }; + _serverManager.Start(0); + _clientManager.Start(0); + _clientManager.Connect("127.0.0.1", _serverManager.LocalPort, "rwa-wire"); + if (!connected.Task.Wait(TimeSpan.FromSeconds(10))) Assert.Fail("the wire pair did not connect"); + var serverPeer = connected.Task.Result; + Peer = new CultNetServerPeer(serverPeer, new LiteNetLibTransportConnection(serverPeer)); + } + + public async Task NextErrorAsync() + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + CultNetErrorMessage? error; + while (!Errors.TryDequeue(out error)) + { + if (DateTime.UtcNow > deadline) Assert.Fail("no reply arrived on the wire"); + await Task.Delay(5); + } + + return error; + } + + public void Dispose() + { + _clientManager.Stop(); + _serverManager.Stop(); + } + } + + [Test] + public async Task ThePeerReceivesTheTypedRefusalOnTheWire() + { + using var wire = new WirePair(); + var rig = Build(authority: null, peer: wire.Peer); + await Seed(rig, KeyA, "server"); + var logBefore = rig.LogLength; + + await rig.Put(rig.PutOf(KeyA, "peer")); + var putReply = await wire.NextErrorAsync(); + await rig.Delete(rig.DeleteOf(KeyA)); + var deleteReply = await wire.NextErrorAsync(); + await rig.Put(rig.PutOf(KeyB, "peer")); + var newKeyReply = await wire.NextErrorAsync(); + + foreach (var (reply, key) in new[] { (putReply, KeyA), (deleteReply, KeyA), (newKeyReply, KeyB) }) + { + Assert.That(reply.Code, Is.EqualTo("write_not_authorized")); + Assert.That(reply.Error, Does.StartWith("write_not_authorized: ")); + Assert.That(reply.Details!.Field, Is.EqualTo("recordKey")); + Assert.That(reply.Details.Value, Is.EqualTo(key.Value)); + } + + Assert.That(rig.TextOf(KeyA), Is.EqualTo("server")); + Assert.That(rig.TextOf(KeyB), Is.Null); + Assert.That(rig.LogLength, Is.EqualTo(logBefore)); + Assert.That(wire.Errors, Is.Empty, "one reply per refused write"); + } + + [Test] + public async Task AnAuthoritysRefusalReasonReachesThePeerAndAGrantSendsNothingBack() + { + using var wire = new WirePair(); + var rig = Build(new RecordingAuthority(request => request.RecordKey.Equals(KeyA) + ? CultNetRemoteWriteDecision.Grant() + : CultNetRemoteWriteDecision.Refuse("only a")), peer: wire.Peer); + + await rig.Put(rig.PutOf(KeyA, "peer")); + await rig.Put(rig.PutOf(KeyB, "peer")); + var reply = await wire.NextErrorAsync(); + + Assert.That(reply.Code, Is.EqualTo("write_not_authorized")); + Assert.That(reply.Error, Is.EqualTo("write_not_authorized: only a")); + Assert.That(reply.Details!.Value, Is.EqualTo(KeyB.Value)); + Assert.That(rig.TextOf(KeyA), Is.EqualTo("peer")); + await Task.Delay(100); + Assert.That(wire.Errors, Is.Empty, "the granted write was not answered with an error"); + } + + [Test] + public async Task ADeleteSentUnderASchemaAliasAsksTheAuthorityWithTheCanonicalId() + { + var authority = new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant()); + var rig = Build(authority); + await Seed(rig, KeyA, "server"); + var aliased = rig.DeleteOf(KeyA); + aliased.SchemaId = "tests.networking_note.v1"; + Assert.That(aliased.SchemaId, Is.Not.EqualTo(rig.SchemaId), "the fixture really sends an alias"); + + await rig.Delete(aliased); + + var request = authority.Requests.Single(); + Assert.That(request.Operation, Is.EqualTo(CultNetRemoteWriteOperation.Delete)); + Assert.That(request.SchemaId, Is.EqualTo(rig.SchemaId), "the authority sees the canonical id, not the alias as sent"); + Assert.That(rig.TextOf(KeyA), Is.Null, "the aliased delete was granted and applied"); + } + + [Test] + public async Task ANullDecisionRefusesPutAndDelete() + { + var rig = Build(new RecordingAuthority(_ => null!)); + await Seed(rig, KeyA, "server"); + + AssertAnswered(() => rig.Put(rig.PutOf(KeyA, "peer")), "a null decision is a refusal"); + AssertAnswered(() => rig.Delete(rig.DeleteOf(KeyA)), "a null decision is a refusal"); + AssertAnswered(() => rig.Put(rig.PutOf(KeyB, "peer")), "a null decision is a refusal"); + + Assert.That(rig.TextOf(KeyA), Is.EqualTo("server")); + Assert.That(rig.TextOf(KeyB), Is.Null); + Assert.That(rig.Logger.Warnings, Has.Some.Contains("raw put refused: the write was not authorized")); + Assert.That(rig.Logger.Warnings, Has.Some.Contains("raw delete refused: the write was not authorized")); + Assert.That(rig.Logger.Errors, Is.Empty, "a null decision is a refusal, not a fault"); + } + + private sealed class RecordingAuthority : ICultNetRemoteWriteAuthority + { + private readonly Func _decide; + public List Requests { get; } = new(); + public RecordingAuthority(Func decide) => _decide = decide; + + public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) + { + Requests.Add(request); + return _decide(request); + } + } + + private sealed class CountingForwarder : ICultNetShardWriteForwarder + { + public int Puts { get; private set; } + public int Deletes { get; private set; } + public Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message) { Puts++; return Task.CompletedTask; } + public Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message) { Deletes++; return Task.CompletedTask; } + } + + private sealed class CapturingLogger : GameCult.Logging.ILogger + { + public List Errors { get; } = new(); + public List Warnings { get; } = new(); + public void LogInfo(string message) { } + public void LogWarning(string message) => Warnings.Add(message); + public void LogError(string message) => Errors.Add(message); + public void LogDebug(string message) { } + } + } +} diff --git a/tests/GameCult.Networking.Tests/SoulP4cProbeTests.cs b/tests/GameCult.Networking.Tests/SoulP4cProbeTests.cs index a4796e7d..726dfea4 100644 --- a/tests/GameCult.Networking.Tests/SoulP4cProbeTests.cs +++ b/tests/GameCult.Networking.Tests/SoulP4cProbeTests.cs @@ -56,7 +56,7 @@ namespace GameCult.Networking.Tests using var server = new Server(cache, ServerSecurityOptions.Development()); var logger = new Logger(); server.Logger = logger; - using var databaseServer = new CultNetDatabaseServer(server, database); + using var databaseServer = new CultNetDatabaseServer(server, database, new CultNetDatabaseServerOptions { RemoteWriteAuthority = new TestGrantsEverything() }); var handler = (Func)typeof(CultNetDatabaseServer) .GetField("_putHandler", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .GetValue(databaseServer)!; diff --git a/tests/vectors/document-variants-c0/current-catalog-bad-canonical.msgpack b/tests/vectors/document-variants-c0/current-catalog-bad-canonical.msgpack new file mode 100644 index 00000000..cd6bd37f --- /dev/null +++ b/tests/vectors/document-variants-c0/current-catalog-bad-canonical.msgpack @@ -0,0 +1,2 @@ +“²cultcache.store.v1‘•ÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788µfixtures.precut2.item¸fixtures.precut2.item.v1ÙGsha256:dadf30bc920849ee89ed5d70cc73027070dd7571c5f878db1901f3abc459cc0c’”ªitem:anvilÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4032226+00:00Ä’¥anvil”¬item:bellowsÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4114746+00:00Ä +’§bellows* \ No newline at end of file diff --git a/tests/vectors/document-variants-c0/current-catalog-bad-compat.msgpack b/tests/vectors/document-variants-c0/current-catalog-bad-compat.msgpack new file mode 100644 index 00000000..e00f8bf4 Binary files /dev/null and b/tests/vectors/document-variants-c0/current-catalog-bad-compat.msgpack differ diff --git a/tests/vectors/document-variants-c0/current-catalog-bad-members.msgpack b/tests/vectors/document-variants-c0/current-catalog-bad-members.msgpack new file mode 100644 index 00000000..4a900c8e --- /dev/null +++ b/tests/vectors/document-variants-c0/current-catalog-bad-members.msgpack @@ -0,0 +1,2 @@ +“²cultcache.store.v1‘—ÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788µfixtures.precut2.item¸fixtures.precut2.item.v1ÙGsha256:dadf30bc920849ee89ed5d70cc73027070dd7571c5f878db1901f3abc459cc0cÚp{"schemaName":"fixtures.precut2.item","schemaVersion":"fixtures.precut2.item.v1","members":[{"slot":0,"name":"Name","type":"System.String","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":true},{"slot":1,"name":"Count","type":"System.Int32","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":false}]}‘ÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788’”ªitem:anvilÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4032226+00:00Ä’¥anvil”¬item:bellowsÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4114746+00:00Ä +’§bellows* \ No newline at end of file diff --git a/tests/vectors/document-variants-c0/current-catalog-bad-slot5-int.msgpack b/tests/vectors/document-variants-c0/current-catalog-bad-slot5-int.msgpack new file mode 100644 index 00000000..5f21bf61 Binary files /dev/null and b/tests/vectors/document-variants-c0/current-catalog-bad-slot5-int.msgpack differ diff --git a/tests/vectors/document-variants-c0/current-catalog-bad-slot5-nil.msgpack b/tests/vectors/document-variants-c0/current-catalog-bad-slot5-nil.msgpack new file mode 100644 index 00000000..f6fc971f Binary files /dev/null and b/tests/vectors/document-variants-c0/current-catalog-bad-slot5-nil.msgpack differ diff --git a/tests/vectors/document-variants-c0/generate.py b/tests/vectors/document-variants-c0/generate.py index c6e5d2da..75326dac 100644 --- a/tests/vectors/document-variants-c0/generate.py +++ b/tests/vectors/document-variants-c0/generate.py @@ -6,8 +6,20 @@ at test time. Run: python generate.py (needs msgpack). extra-slot-full-payload.msgpack v1 header, record item:anvil gains slot 4, payload stays full legacy-catalog-extra-slot.msgpack v1 header, catalog entry in the older layout (contentHash at slot - 5), so CultMesh's legacy snapshot reader takes it, plus the - extra-slot record + 5), plus the extra-slot record; no C# reader reads the older layout + legacy-catalog-plain.msgpack v1 header, the older-layout catalog entry, the plain v1 records: refused by + every C# reader, CultMesh included + legacy-catalog-trailing.msgpack legacy-catalog-plain plus three bytes after the array + current-catalog-bad-canonical.msgpack + current-catalog-bad-members.msgpack + current-catalog-bad-compat.msgpack + v1 header, plain records, and the first catalog entry of the current layout with one slot + malformed (canonical schema json an int; members an int; a compatible id an int). Refused + by every C# reader, CultMesh included + current-catalog-bad-slot5-nil.msgpack + current-catalog-bad-slot5-int.msgpack + the same, with slot 5 (the compatible ids) nil or an integer: neither is a string, so neither is + the older layout v1-base.msgpack NOT generated here: written once by the Python runtime at CultLib 69a21bb (SingleFileMessagePackBackingStore.push of alpha and beta, type vectors.item, before any C0 code existed) @@ -38,6 +50,21 @@ write("extra-slot-full-payload.msgpack", [header, catalog, [anvil + [["item:bellows", []]]] + records[1:]]) variant = ["item:anvil-big", anvil[1], anvil[2], b"", ["item:anvil", [[0, [[1, ""]], "", 99]]]] write("variant-v2.msgpack", ["cultcache.store.v2", catalog, records + [variant]]) -legacy_entry = [catalog[0][0], catalog[0][1], catalog[0][2], catalog[0][4], None, catalog[0][3], []] +legacy = lambda entry: [entry[0], entry[1], entry[2], entry[4], None, entry[3], []] +legacy_entry = legacy(catalog[0]) write("legacy-catalog-extra-slot.msgpack", [header, [legacy_entry], [anvil + [["item:bellows", []]]]]) +write("legacy-catalog-plain.msgpack", [header, [legacy(entry) for entry in catalog], records]) +(here / "legacy-catalog-trailing.msgpack").write_bytes( + msgpack.packb([header, [legacy(entry) for entry in catalog], records], use_bin_type=True) + b"\x01\x02\x03") +current = catalog[0] +for name, entry in ( + ("current-catalog-bad-canonical", current[:4] + [5]), + ("current-catalog-bad-members", current[:6] + [5]), + ("current-catalog-bad-compat", current[:5] + [[7]] + current[6:]), + ("current-catalog-bad-slot5-nil", current[:5] + [None] + current[6:]), + ("current-catalog-bad-slot5-int", current[:5] + [7] + current[6:]), +): + # One catalog entry and the records that use it: the older-layout reader takes an entry's slot 5 for a string, so a second + # current-layout entry would make it refuse for that reason, not for the malformed slot under test. + write(name + ".msgpack", [header, [entry], [record for record in records if record[1] == current[0]]]) diff --git a/tests/vectors/document-variants-c0/legacy-catalog-plain.msgpack b/tests/vectors/document-variants-c0/legacy-catalog-plain.msgpack new file mode 100644 index 00000000..345d12f7 --- /dev/null +++ b/tests/vectors/document-variants-c0/legacy-catalog-plain.msgpack @@ -0,0 +1,2 @@ +“²cultcache.store.v1’—ÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788µfixtures.precut2.item¸fixtures.precut2.item.v1Úp{"schemaName":"fixtures.precut2.item","schemaVersion":"fixtures.precut2.item.v1","members":[{"slot":0,"name":"Name","type":"System.String","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":true},{"slot":1,"name":"Count","type":"System.Int32","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":false}]}ÀÙGsha256:dadf30bc920849ee89ed5d70cc73027070dd7571c5f878db1901f3abc459cc0c�—ÙGsha256:670deacd06aedb90235e6bd873674c10467a05b4adcd62dbc019279efcbcbb11µfixtures.precut2.note¸fixtures.precut2.note.v1Ùç{"schemaName":"fixtures.precut2.note","schemaVersion":"fixtures.precut2.note.v1","members":[{"slot":0,"name":"Text","type":"System.String","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":false}]}ÀÙGsha256:8552c4cd5a0a455fc43a37c2314d28617b7b9d36e43ee7ef2dd18cb148dbfe35�“”ªitem:anvilÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4032226+00:00Ä’¥anvil”¬item:bellowsÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4114746+00:00Ä +’§bellows*”«note:originÙGsha256:670deacd06aedb90235e6bd873674c10467a05b4adcd62dbc019279efcbcbb11Ù!2026-09-13T15:14:36.4120244+00:00Ä‘´written before cut 2 \ No newline at end of file diff --git a/tests/vectors/document-variants-c0/legacy-catalog-trailing.msgpack b/tests/vectors/document-variants-c0/legacy-catalog-trailing.msgpack new file mode 100644 index 00000000..2755f37c --- /dev/null +++ b/tests/vectors/document-variants-c0/legacy-catalog-trailing.msgpack @@ -0,0 +1,2 @@ +“²cultcache.store.v1’—ÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788µfixtures.precut2.item¸fixtures.precut2.item.v1Úp{"schemaName":"fixtures.precut2.item","schemaVersion":"fixtures.precut2.item.v1","members":[{"slot":0,"name":"Name","type":"System.String","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":true},{"slot":1,"name":"Count","type":"System.Int32","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":false}]}ÀÙGsha256:dadf30bc920849ee89ed5d70cc73027070dd7571c5f878db1901f3abc459cc0c�—ÙGsha256:670deacd06aedb90235e6bd873674c10467a05b4adcd62dbc019279efcbcbb11µfixtures.precut2.note¸fixtures.precut2.note.v1Ùç{"schemaName":"fixtures.precut2.note","schemaVersion":"fixtures.precut2.note.v1","members":[{"slot":0,"name":"Text","type":"System.String","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":false}]}ÀÙGsha256:8552c4cd5a0a455fc43a37c2314d28617b7b9d36e43ee7ef2dd18cb148dbfe35�“”ªitem:anvilÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4032226+00:00Ä’¥anvil”¬item:bellowsÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4114746+00:00Ä +’§bellows*”«note:originÙGsha256:670deacd06aedb90235e6bd873674c10467a05b4adcd62dbc019279efcbcbb11Ù!2026-09-13T15:14:36.4120244+00:00Ä‘´written before cut 2 \ No newline at end of file diff --git a/tests/vectors/document-variants-c2a/README.md b/tests/vectors/document-variants-c2a/README.md new file mode 100644 index 00000000..a9d5b98b --- /dev/null +++ b/tests/vectors/document-variants-c2a/README.md @@ -0,0 +1,12 @@ +# document-variants-c2a vectors + +`c2a-written-ids.msgpack` and `c2a-written-ids-directory/` are what `C2aGateWriter` writes: one record holding element ids, as a +single-file store (header v3) and as a directory store (header v5). The writer stamps every record with one fixed clock, so the +bytes are the same on every run, and `C2aGateWriter.TheCommittedVectorsAreWhatC2aWrites` compares the live output to these files +byte for byte (the directory as relative path and bytes; `*.lock` files hold no state and are left out). + +Regenerate them only when C2a's format moves on purpose: +`CULTLIB_GATE_OUT= dotnet test tests/GameCult.Caching.Tests --filter FullyQualifiedName~C2aStamps`, then copy the single +file and the directory store (the page under `store.cc.records/` is named by its content hash) over these. + +`old-reader-refusal.sh` is the gate that reads them with the pinned older readers; its header says how to run it. diff --git a/tests/vectors/document-variants-c2a/c2a-written-ids-directory/store.cc b/tests/vectors/document-variants-c2a/c2a-written-ids-directory/store.cc new file mode 100644 index 00000000..05040c81 Binary files /dev/null and b/tests/vectors/document-variants-c2a/c2a-written-ids-directory/store.cc differ diff --git a/tests/vectors/document-variants-c2a/c2a-written-ids-directory/store.cc.records/.commit.lock b/tests/vectors/document-variants-c2a/c2a-written-ids-directory/store.cc.records/.commit.lock new file mode 100644 index 00000000..e69de29b diff --git a/tests/vectors/document-variants-c2a/c2a-written-ids-directory/store.cc.records/578e09fc6a9cafb49d3eea249d7a5e1f861f36aa7dafe23f0605a87393403722.msgpack b/tests/vectors/document-variants-c2a/c2a-written-ids-directory/store.cc.records/578e09fc6a9cafb49d3eea249d7a5e1f861f36aa7dafe23f0605a87393403722.msgpack new file mode 100644 index 00000000..0012b9d8 Binary files /dev/null and b/tests/vectors/document-variants-c2a/c2a-written-ids-directory/store.cc.records/578e09fc6a9cafb49d3eea249d7a5e1f861f36aa7dafe23f0605a87393403722.msgpack differ diff --git a/tests/vectors/document-variants-c2a/c2a-written-ids.msgpack b/tests/vectors/document-variants-c2a/c2a-written-ids.msgpack new file mode 100644 index 00000000..038fe60c Binary files /dev/null and b/tests/vectors/document-variants-c2a/c2a-written-ids.msgpack differ diff --git a/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderGateProbe.cs b/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderGateProbe.cs new file mode 100644 index 00000000..c9390d85 --- /dev/null +++ b/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderGateProbe.cs @@ -0,0 +1,164 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // Copied into a checkout of cultlib-unity-v2.0.1 (b8fc5eb0) by old-reader-refusal.sh. Not part of this tree's suite. + // The 2.0.1 reader meets the stores C2a writes. The source is "committed" (tests/vectors/document-variants-c2a) or, when + // CULTLIB_GATE_OUT is set, "live" (what C2aGateWriter just wrote at the tree under test). Every refusal names the format, and + // the store is byte-identical after it. + public class OldReaderGateProbe + { + private const string SingleName = "c2a-written-ids.msgpack"; + private const string DirectoryName = "c2a-written-ids-directory"; + private const string ManifestName = "store.cc"; + + [CultDocument("tests.gate_probe", "tests.gate_probe.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class GateProbeDocument + { + [Key(0)] + [CultName] + public string Name = string.Empty; + } + + public static IEnumerable Sources() + { + yield return "committed"; + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT"))) + yield return "live"; + } + + private static string SourceRoot(string source) => source == "live" + ? Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT")! + : Path.Combine(Environment.GetEnvironmentVariable("CULTLIB_ROOT")!, "tests", "vectors", "document-variants-c2a"); + + private string _temp = string.Empty; + + [SetUp] + public void CreateTemp() + { + _temp = Path.Combine(Path.GetTempPath(), $"cultlib-gate-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_temp); + } + + [TearDown] + public void DeleteTemp() + { + if (Directory.Exists(_temp)) + Directory.Delete(_temp, recursive: true); + } + + private string CopySingle(string source, string name) + { + var file = Path.Combine(_temp, name); + File.Copy(Path.Combine(SourceRoot(source), SingleName), file); + return file; + } + + private string CopyDirectory(string source, string name) + { + var root = Path.Combine(_temp, name); + CopyTree(Path.Combine(SourceRoot(source), DirectoryName), root); + return Path.Combine(root, ManifestName); + } + + private static void CopyTree(string from, string to) + { + Directory.CreateDirectory(to); + foreach (var file in Directory.GetFiles(from)) + File.Copy(file, Path.Combine(to, Path.GetFileName(file))); + foreach (var directory in Directory.GetDirectories(from)) + CopyTree(directory, Path.Combine(to, Path.GetFileName(directory))); + } + + // Every file under the root with its content hash, in a fixed order: a tree compares equal only if nothing in it moved. + private static string[] Tree(string root) => + Directory.GetFiles(root, "*", SearchOption.AllDirectories) + .OrderBy(file => file, StringComparer.Ordinal) + .Select(file => Path.GetRelativePath(root, file) + " " + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(file)))) + .ToArray(); + + private static void Names(Exception? error, string format, string what, string source) + { + Assert.That(error, Is.Not.Null, what); + TestContext.Out.WriteLine($"OLD-READER csharp refused {what} [{source}]: " + error!.Message); + Assert.That(error.ToString(), Does.Contain(format), what); + } + + [TestCaseSource(nameof(Sources))] + public void V3IsRefusedAtAttach(string source) + { + var file = CopySingle(source, "attach.cc"); + var before = File.ReadAllBytes(file); + using var cache = new CultCache(); + Names(Assert.Catch(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(file))), "cultcache.store.v3", "attach", source); + Assert.That(File.ReadAllBytes(file), Is.EqualTo(before)); + } + + [TestCaseSource(nameof(Sources))] + public void V3IsRefusedByPushAllOfAStoreThatWasNeverAttached(string source) + { + var file = CopySingle(source, "detached.cc"); + var before = File.ReadAllBytes(file); + var store = new SingleFileMessagePackBackingStore(file); + Names(Assert.Catch(() => store.PushAll()), "cultcache.store.v3", "PushAll of a detached store", source); + Assert.That(File.ReadAllBytes(file), Is.EqualTo(before)); + } + + [TestCaseSource(nameof(Sources))] + public async Task V3SwappedInAfterAttachIsRefusedByFlushAndPushAll(string source) + { + var file = Path.Combine(_temp, "swap.cc"); + using var cache = new CultCache(); + var store = new SingleFileMessagePackBackingStore(file); + cache.AddBackingStore(store); + await cache.AddAsync(new GateProbeDocument { Name = "staged" }); + File.Copy(Path.Combine(SourceRoot(source), SingleName), file, overwrite: true); + var before = File.ReadAllBytes(file); + + Names(Assert.Catch(() => cache.FlushAllBackingStores()), "cultcache.store.v3", "flush after a swap", source); + Assert.That(File.ReadAllBytes(file), Is.EqualTo(before)); + Names(Assert.Catch(() => store.PushAll()), "cultcache.store.v3", "PushAll after a swap", source); + Assert.That(File.ReadAllBytes(file), Is.EqualTo(before)); + } + + [TestCaseSource(nameof(Sources))] + public void V5IsRefusedByTheDirectoryStoreAtAttach(string source) + { + var manifest = CopyDirectory(source, "attach-directory"); + var before = Tree(Path.GetDirectoryName(manifest)!); + using var cache = new CultCache(); + Names(Assert.Catch(() => cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest))), "cultcache.store.v5", "directory attach", source); + Assert.That(Tree(Path.GetDirectoryName(manifest)!), Is.EqualTo(before)); + } + + [TestCaseSource(nameof(Sources))] + public async Task V5SwappedInAfterAttachIsRefusedByTheDirectoryStoreAtFlush(string source) + { + var root = Path.Combine(_temp, "swap-directory"); + Directory.CreateDirectory(root); + var manifest = Path.Combine(root, ManifestName); + using var cache = new CultCache(); + var store = new DirectoryMessagePackBackingStore(manifest); + cache.AddBackingStore(store); + await cache.AddAsync(new GateProbeDocument { Name = "staged" }); + CopyTree(Path.Combine(SourceRoot(source), DirectoryName), root); + var before = Tree(root); + + Names(Assert.Catch(() => cache.FlushAllBackingStores()), "cultcache.store.v5", "directory flush after a swap", source); + Assert.That(Tree(root), Is.EqualTo(before)); + Names(Assert.Catch(() => store.PushAll()), "cultcache.store.v5", "directory PushAll after a swap", source); + Assert.That(Tree(root), Is.EqualTo(before)); + } + } +} diff --git a/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderRefListProbe.cs b/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderRefListProbe.cs new file mode 100644 index 00000000..6c6cb029 --- /dev/null +++ b/tests/vectors/document-variants-c2a/old-reader-probes/OldReaderRefListProbe.cs @@ -0,0 +1,64 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using NUnit.Framework; +using RefListHolder = GameCult.Caching.Tests.RefListDriftTests.RefListHolder; +using RefListTarget = GameCult.Caching.Tests.RefListDriftTests.RefListTarget; + +namespace GameCult.Caching.Tests +{ + // Copied into a checkout of cultlib-unity-v2.0.1 (b8fc5eb0) by old-reader-refusal.sh. Not part of this tree's suite. + // It attaches the store the 1.0.60 writer produced and prints the REFLIST-SUMMARY line that + // RefListDriftTests.A_1_0_60_Written_Store_Opens_Like_2_0_1 prints at head; 'gate csharp' diffs the two lines. The document + // types are 2.0.1's own RefListDriftTests.RefListTarget and RefListHolder. + public class OldReaderRefListProbe + { + // RefListDriftTests.A_1_0_60_Written_Store_Opens_Like_2_0_1 is this test's body at head; the two must stay the same code. + [Test] + public async Task A_1_0_60_Written_Store_Attaches_As_Compatible_Drift() + { + var copy = Path.Combine(Path.GetTempPath(), $"reflist-gate-{Guid.NewGuid():N}.cc"); + File.Copy(Path.Combine(Environment.GetEnvironmentVariable("CULTLIB_GATE_OUT")!, "v1-0-60-ref-list.cc"), copy); + try + { + string[] warnings; + using (var cache = new CultCache()) + { + var store = new SingleFileMessagePackBackingStore(copy); + cache.AddBackingStore(store); + await cache.PullAllBackingStoresAsync(); + warnings = store.LastSchemaMigrationReports.SelectMany(report => report.Warnings).Select(warning => warning.Code).Distinct().OrderBy(code => code, StringComparer.Ordinal).ToArray(); + Assert.That(warnings, Does.Contain("gained_reference_semantics")); + var holder = cache.GetAll().Single(); + Assert.That(holder.Items.Select(item => item.Key.Value), Is.EqualTo(new[] { "alpha", "beta" })); + + // The write both runtimes make: the targets as they are, the holder with its list reversed. + foreach (var target in cache.GetAll().ToArray()) await cache.UpsertAsync(target, cache.TryGetHandle(target)); + holder.Items = holder.Items.AsEnumerable().Reverse().ToArray(); + await cache.UpsertAsync(holder, cache.TryGetHandle(holder)); + await cache.FlushAsync(); + } + + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(copy)); + var records = snapshot.Records.OrderBy(record => record.Key, StringComparer.Ordinal).Select(record => record.Key + "=" + record.SchemaId).ToArray(); + var listId = snapshot.Records.Single(record => record.Key == "list").SchemaId; + var slot = snapshot.SchemaCatalog.Single(entry => entry.SchemaId == listId).Members.Single(member => member.MemberName == "Items"); + using var reopened = new CultCache(); + reopened.AddBackingStore(new SingleFileMessagePackBackingStore(copy)); + await reopened.PullAllBackingStoresAsync(); + var items = reopened.GetAll().Single().Items.Select(item => item.Key.Value).ToArray(); + Assert.That(items, Is.EqualTo(new[] { "beta", "alpha" })); + Assert.That(reopened.Get(reopened.GetAll().Single().Items[0].Key)!.Name, Is.EqualTo("beta")); + TestContext.Out.WriteLine("REFLIST-SUMMARY " + System.Text.Json.JsonSerializer.Serialize(new { records, items, itemsSlot = new { slot.IsReference, slot.TargetSchemaName }, warnings, header = snapshot.FormatVersion })); + } + finally + { + File.Delete(copy); + } + } + } +} diff --git a/tests/vectors/document-variants-c2a/old-reader-probes/old-reader-v3.test.ts b/tests/vectors/document-variants-c2a/old-reader-probes/old-reader-v3.test.ts new file mode 100644 index 00000000..083ad75a --- /dev/null +++ b/tests/vectors/document-variants-c2a/old-reader-probes/old-reader-v3.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; + +import { documentPayloadFromBytes } from "../src/document"; +import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store"; + +// Copied into a checkout of cultcache-rs-v0.3.1 (02cf04b5) by old-reader-refusal.sh. Not part of this tree's suite. +// The vector is the store C2aGateWriter wrote through CultCacheMessagePack.Create. +const vector = resolve(__dirname, "../../../../tests/vectors/document-variants-c2a/c2a-written-ids.msgpack"); +const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" }; + +async function copyOfTheVector(): Promise<{ file: string; bytes: Buffer }> { + const bytes = await readFile(vector); + const file = join(await mkdtemp(join(tmpdir(), "cultcache-oldreader-")), "store.msgpack"); + await writeFile(file, bytes); + return { file, bytes }; +} + +function refusesV3(label: string) { + return (error: Error) => { + console.log(`OLD-READER typescript refused ${label}: ` + error.message); + return /cultcache\.store\.v3/u.test(error.message); + }; +} + +test("old TypeScript reader refuses the C2a element-id store at read", async () => { + const { file, bytes } = await copyOfTheVector(); + await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pullAll(), refusesV3("pullAll")); + assert.equal(Buffer.compare(await readFile(file), bytes), 0); +}); + +test("old TypeScript reader refuses a per-record push to the C2a element-id store", async () => { + const { file, bytes } = await copyOfTheVector(); + await assert.rejects(() => new SingleFileMessagePackBackingStore(file).push(envelope), refusesV3("push")); + assert.equal(Buffer.compare(await readFile(file), bytes), 0); +}); + +test("old TypeScript reader refuses a whole-store pushAll over the C2a element-id store", async () => { + const { file, bytes } = await copyOfTheVector(); + await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pushAll([envelope]), refusesV3("pushAll")); + assert.equal(Buffer.compare(await readFile(file), bytes), 0); +}); diff --git a/tests/vectors/document-variants-c2a/old-reader-probes/old_reader_v3.rs b/tests/vectors/document-variants-c2a/old-reader-probes/old_reader_v3.rs new file mode 100644 index 00000000..0965eff2 --- /dev/null +++ b/tests/vectors/document-variants-c2a/old-reader-probes/old_reader_v3.rs @@ -0,0 +1,53 @@ +// Copied into a checkout of cultcache-rs-v0.3.1 (02cf04b5) by old-reader-refusal.sh. Not part of this tree's suite. +// The vector is the store C2aGateWriter wrote through CultCacheMessagePack.Create. +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, PushAllOptions, SingleFileMessagePackBackingStore}; + +fn copy_of_the_vector() -> (tempfile::TempDir, std::path::PathBuf, Vec) { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("store.msgpack"); + let vector = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/vectors/document-variants-c2a/c2a-written-ids.msgpack"); + std::fs::copy(vector, &path).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + (temp, path, bytes) +} + +fn envelope() -> CultCacheEnvelope { + CultCacheEnvelope { + key: "probe".into(), + r#type: "probe".into(), + payload: vec![0x80], + stored_at: "2026-10-22T00:00:00Z".into(), + schema_id: None, + } +} + +fn refused_v3(label: &str, error: anyhow::Error) { + let message = format!("{error:#}"); + println!("OLD-READER rust refused {label}: {message}"); + assert!(message.contains("cultcache.store.v3"), "{message}"); +} + +#[test] +fn old_rust_reader_refuses_the_c2a_element_id_store_at_read() { + let (_temp, path, bytes) = copy_of_the_vector(); + refused_v3("pull_all", SingleFileMessagePackBackingStore::new(&path).pull_all().unwrap_err()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); +} + +#[test] +fn old_rust_reader_refuses_a_per_record_push_to_the_c2a_element_id_store() { + let (_temp, path, bytes) = copy_of_the_vector(); + refused_v3("push", SingleFileMessagePackBackingStore::new(&path).push(&envelope()).unwrap_err()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); +} + +#[test] +fn old_rust_reader_refuses_a_whole_store_push_all_over_the_c2a_element_id_store() { + let (_temp, path, bytes) = copy_of_the_vector(); + let error = SingleFileMessagePackBackingStore::new(&path) + .push_all(std::slice::from_ref(&envelope()), PushAllOptions::default()) + .unwrap_err(); + refused_v3("push_all", error); + assert_eq!(std::fs::read(&path).unwrap(), bytes); +} diff --git a/tests/vectors/document-variants-c2a/old-reader-probes/test_old_reader_v3.py b/tests/vectors/document-variants-c2a/old-reader-probes/test_old_reader_v3.py new file mode 100644 index 00000000..83a7e2ea --- /dev/null +++ b/tests/vectors/document-variants-c2a/old-reader-probes/test_old_reader_v3.py @@ -0,0 +1,41 @@ +import tempfile +import unittest +from pathlib import Path + +from cultcache_py import CultCacheEnvelope, SingleFileMessagePackBackingStore + + +# Copied into a checkout of cultcache-rs-v0.3.1 (02cf04b5) by old-reader-refusal.sh. Not part of this tree's suite. +# The vector is the store C2aGateWriter wrote through CultCacheMessagePack.Create. +class OldReaderV3(unittest.TestCase): + def setUp(self) -> None: + vector = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c2a" / "c2a-written-ids.msgpack" + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.path = Path(self._tmp.name) / "store.msgpack" + self.bytes = vector.read_bytes() + self.path.write_bytes(self.bytes) + self.envelope = CultCacheEnvelope(key="probe", type="probe", payload=b"\x80", stored_at="2026-10-22T00:00:00Z") + + def refuses_v3(self, label: str, action) -> None: + with self.assertRaises(ValueError) as caught: + action(SingleFileMessagePackBackingStore(self.path)) + print(f"OLD-READER python refused {label}:", caught.exception) + self.assertIn("cultcache.store.v3", str(caught.exception)) + self.assertEqual(self.path.read_bytes(), self.bytes) + + def test_old_python_reader_refuses_the_c2a_element_id_store_at_read(self) -> None: + self.refuses_v3("pull_all", lambda store: store.pull_all()) + + def test_old_python_reader_refuses_a_per_record_push(self) -> None: + self.refuses_v3("push", lambda store: store.push(self.envelope)) + + def test_old_python_reader_refuses_a_whole_store_push_all(self) -> None: + self.refuses_v3("push_all", lambda store: store.push_all([self.envelope])) + + def test_old_python_reader_refuses_a_delete(self) -> None: + self.refuses_v3("delete", lambda store: store.delete("probe", "probe")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/vectors/document-variants-c2a/old-reader-refusal.sh b/tests/vectors/document-variants-c2a/old-reader-refusal.sh new file mode 100755 index 00000000..bfafa01d --- /dev/null +++ b/tests/vectors/document-variants-c2a/old-reader-refusal.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# The C2a format gate: a reader from before element ids must refuse what C2a writes, and a store the 1.0.60 writer made must +# open alike at 2.0.1 and at the tree under test. Each old revision is a resolved SHA with its tag beside it, because the +# verification clone holds ancestors, not tags; an environment variable overrides each. +# +# C# OLD_READER_REV_CSHARP b8fc5eb0 cultlib-unity-v2.0.1 +# TypeScript, OLD_READER_REV_LEAF 02cf04b5 cultcache-rs-v0.3.1 (the whole-store writer fix; no cultcache-ts tag carries it) +# Python, Rust +# 1.0.60 writer OLD_WRITER_REV 45c2f400 cultlib-unity-v1.0.60 +# +# old-reader-refusal.sh stage csharp|typescript|python|rust|writer +# Run in a CultLib clone. Commits the old revision plus the C2a-written vectors and that runtime's probes onto a scratch +# ref and prints its SHA. The tree at that SHA is the old code with extra tests ("writer" is the 1.0.60 writer). +# old-reader-refusal.sh probe csharp|typescript|python|rust|writer +# Run at the root of a staged tree, with the runtime's toolchain on PATH. Exit status zero means every probe passed; the +# refusal messages are printed. +# old-reader-refusal.sh gate csharp|typescript|python|rust +# Run in a CultLib clone at the revision under test: stage, then probe. For csharp, C2aGateWriter first writes the live +# v3 and v5 stores into $GATE_OUT (default /tmp/gate) and the 2.0.1 probes read them beside the committed vectors; the +# 1.0.60 writer then writes a ref-list store there, 2.0.1 and the tree under test both open it, write the same documents +# back and reread the file, and their REFLIST-SUMMARY lines (records by schema id, the list's items in order, the Items +# slot's catalog entry, warnings, header) must be identical. +# +# Every stage asserts how many cases it PASSED, so a probe that was renamed out of its filter or a leaf test that was skipped +# fails the gate by name ('-count') instead of passing with nothing run; a C# result that is not Passed (an [Ignore]d +# case is NotExecuted) fails '-notpassed' and Python's skipped/expected-failure forms fail 'python-notpassed'. The expected counts are the constants below; +# each is confirmed against the probe's source: C#, the Passed results in the TRX the run wrote (by class); Rust, the sum of 'N passed'; +# Python, 'Ran N tests'; TypeScript, node's 'pass N'. Each stage also counts the refusal lines it printed (EXPECT_REFUSED_*). +set -euo pipefail + +here="tests/vectors/document-variants-c2a" +self="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +OLD_READER_REV_CSHARP="${OLD_READER_REV_CSHARP:-b8fc5eb0}" # cultlib-unity-v2.0.1 +OLD_READER_REV_LEAF="${OLD_READER_REV_LEAF:-02cf04b5}" # cultcache-rs-v0.3.1 +OLD_WRITER_REV="${OLD_WRITER_REV:-45c2f400}" # cultlib-unity-v1.0.60 + +EXPECT_C2A_WRITER=2 # C2aGateWriter: C2aStamps..., TheCommittedVectorsAreWhatC2aWrites +EXPECT_READER_GATE=10 # OldReaderGateProbe: five TestCaseSource tests, each over the committed and the live store +EXPECT_READER_REFLIST=1 # OldReaderRefListProbe +EXPECT_OLD_WRITER=1 # OldWriterRefListProbe +EXPECT_HEAD_REFLIST=1 # RefListDriftTests.A_1_0_60_Written_Store_Opens_Like_2_0_1 +EXPECT_RUST=3 # tests/old_reader_v3.rs +EXPECT_PYTHON=4 # test_old_reader_v3.py +EXPECT_TYPESCRIPT=3 # old-reader-v3.test.ts + +# Refusals each stage must PRINT: every probe writes one 'OLD-READER refused