Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 063835f679 | |||
| 23d254a501 | |||
| 7002c252ed | |||
| a6861adadb | |||
| 5e610b1b6e | |||
| 447b310b4d | |||
| 43aa451fb9 | |||
| a7c22e6ee5 | |||
| ba25b43369 | |||
| 31d86e2a50 | |||
| abd1a92eb2 | |||
| 7760219b7c | |||
| de8a062ab9 | |||
| f6c9bf5f97 | |||
| 9d6ee72eb3 | |||
| 44149afb7b | |||
| e57fbaee90 | |||
| 0c19eddb77 | |||
| 43db7af238 | |||
| eb8939011e | |||
| 512e0e249f | |||
| 89755b08b5 | |||
| 3d70e3395d | |||
| 7d2cde047f | |||
| ef648d1a66 | |||
| 6231ecad5d | |||
| e12ee63840 | |||
| 976f8c3bf2 | |||
|
|
de00274cd0 | ||
| a499c58108 | |||
| 8ca1d18454 | |||
| e6a279248c | |||
| 55bf180647 | |||
|
|
356687b717 | ||
|
|
a2bb0bced4 | ||
|
|
ef82811f2e | ||
|
|
a56ae56b7d | ||
| 0fc0eb45ee | |||
| aff69c2914 | |||
|
|
073efd06e7 | ||
| 88545542bb | |||
|
|
c9cdbc7edc | ||
|
|
2aade7fdd3 | ||
|
|
27d9fbca94 | ||
|
|
e08d5c47f4 | ||
|
|
cc5a9cec9b | ||
|
|
a04014e0b4 | ||
|
|
b55521d50d | ||
|
|
5679417a82 | ||
|
|
a97861945c | ||
|
|
6c130581a1 | ||
|
|
99273f468c | ||
|
|
2f372eb2f1 | ||
|
|
f378d4fdb0 | ||
|
|
8598fd3d12 | ||
|
|
c2d2c1d126 | ||
|
|
c4f6e76839 | ||
|
|
87f53498ed | ||
|
|
de25b50abd | ||
|
|
3d37fda496 | ||
|
|
cc931932fc | ||
|
|
ba548d192d | ||
|
|
d546ef9c7f | ||
|
|
d1a4ebdd68 | ||
|
|
688562c316 | ||
|
|
bf55c10199 | ||
|
|
4c1537885d | ||
|
|
70f8135d2c | ||
|
|
4571a0d4e7 | ||
| 325da81428 | |||
| e4f550ef78 | |||
| 59365cea35 | |||
| 5ba3e9e2aa | |||
| 69703358c0 | |||
|
|
96c5fb1ca4 | ||
|
|
b8e40dc372 | ||
|
|
1704fb5060 | ||
|
|
041a769475 | ||
|
|
fc0f83a385 | ||
|
|
3e6ec8567c | ||
|
|
2f3b30ab85 | ||
|
|
819df6a666 | ||
|
|
6ca9c455b7 | ||
|
|
1136080aaf | ||
|
|
636b387cf1 | ||
|
|
6c441d1cda | ||
|
|
34cceedf98 | ||
|
|
4e89f07bff | ||
|
|
bd2aff8ed0 | ||
|
|
4f6ee0db83 | ||
|
|
04207007d6 | ||
|
|
80f1cfa385 | ||
|
|
4b5f343a44 | ||
|
|
140eca5138 | ||
|
|
e4d2446dbe | ||
|
|
56b0c1679c | ||
|
|
cbb336f501 | ||
|
|
86faf53f86 | ||
|
|
62c734f5b2 |
148 changed files with 33833 additions and 646 deletions
64
.github/workflows/cultmesh-portability.yml
vendored
64
.github/workflows/cultmesh-portability.yml
vendored
|
|
@ -8,6 +8,12 @@ on:
|
|||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
# A newer push or PR update replaces the older run on the same ref. Tag pushes and
|
||||
# workflow_dispatch runs are never cancelled: a tag run carries a release.
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}
|
||||
|
||||
jobs:
|
||||
reactive-and-resource-lifetime:
|
||||
name: ${{ matrix.os }} reactive semantics and leases
|
||||
|
|
@ -16,12 +22,16 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# The forge runner is Ubuntu-only: Windows legs run on workflow_dispatch (GitHub or a Windows runner), Ubuntu gates push and PR.
|
||||
# The forge runner (label `ubuntu-latest`) is Linux-only and no Windows runner exists: the Windows leg runs on workflow_dispatch only (GitHub or a Windows runner), the ubuntu-latest leg gates push and PR. Windows push/PR triggers return in the PR that registers a Windows runner.
|
||||
os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "ubuntu-latest"]' || '["ubuntu-latest"]') }}
|
||||
|
||||
steps:
|
||||
- name: Check out CultLib
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# The declared-dependency-range test builds the admitted releases from their tags.
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
|
@ -45,6 +55,34 @@ jobs:
|
|||
- name: Install Python CultLib packages
|
||||
run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
|
||||
|
||||
# The forge runner images carry no cargo either (the cross-runtime store tests build the Rust interop example); GitHub's hosted images do.
|
||||
- name: Install Rust toolchain
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v cargo >/dev/null && [ ! -x "$HOME/.cargo/bin/cargo" ]; then
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
fi
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The forge runner images carry no pwsh; GitHub's Windows and hosted images do.
|
||||
- name: Install PowerShell
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v pwsh >/dev/null; then pwsh -Version; exit 0; fi
|
||||
SUDO=""; [ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
||||
PWSH_VERSION=7.4.6
|
||||
curl -fsSL "https://github.com/PowerShell/PowerShell/releases/download/v${PWSH_VERSION}/powershell-${PWSH_VERSION}-linux-x64.tar.gz" -o "${RUNNER_TEMP:-/tmp}/powershell.tar.gz"
|
||||
$SUDO mkdir -p /opt/microsoft/powershell/7
|
||||
$SUDO tar -xzf "${RUNNER_TEMP:-/tmp}/powershell.tar.gz" -C /opt/microsoft/powershell/7
|
||||
$SUDO chmod +x /opt/microsoft/powershell/7/pwsh
|
||||
$SUDO ln -sf /opt/microsoft/powershell/7/pwsh /usr/local/bin/pwsh
|
||||
echo "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1" >> "$GITHUB_ENV"
|
||||
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 pwsh -Version
|
||||
|
||||
- name: Verify explicit reactive scheduling across runtimes
|
||||
shell: pwsh
|
||||
run: ./scripts/verify-reactive-document-scaling.ps1
|
||||
|
|
@ -60,8 +98,8 @@ jobs:
|
|||
--logger "console;verbosity=minimal"
|
||||
|
||||
# The TypeScript side of the CultMesh authority rules. This job is the
|
||||
# only one that runs on both Windows and Linux for main, codex/** and
|
||||
# pull requests: cultnet-interop.yml is windows-latest and main-only, so
|
||||
# only one that runs on Linux for main, codex/** and pull requests (and on
|
||||
# Windows too under workflow_dispatch): cultnet-interop.yml is windows-latest and dispatch-only, so
|
||||
# until these steps existed the shared verifier's tests never ran on
|
||||
# Linux at all, and the browser client's never ran in CI on any OS.
|
||||
# packages/cultnet-ts verifies both vector files (content.test.ts judges both
|
||||
|
|
@ -77,6 +115,26 @@ jobs:
|
|||
- name: Verify the CultCache document owner with the TypeScript cache
|
||||
run: npm run test --workspace packages/cultcache-ts
|
||||
|
||||
# The Python cache's unit suite ran only in cultnet-interop.yml (windows-latest, dispatch-only), so on Linux
|
||||
# it ran nowhere. It sits after the TypeScript cache's test so the built cultcache-ts store holder exists for the
|
||||
# cross-runtime store-lock test, which skips without it.
|
||||
- name: Verify the CultCache document owner with the Python cache
|
||||
run: python -m unittest discover -s packages/cultcache-py/tests
|
||||
|
||||
# cargo test for packages/cultcache-rs ran in no workflow: the Rust cache's own unit and doc tests were unwatched
|
||||
# (only the interop example built, inside the C# store tests). The target directory is under RUNNER_TEMP, away from the checkout.
|
||||
- name: Verify the CultCache document owner with the Rust cache
|
||||
shell: bash
|
||||
run: CARGO_TARGET_DIR="$RUNNER_TEMP/cultcache-rs-target" cargo test --manifest-path packages/cultcache-rs/Cargo.toml
|
||||
|
||||
# tests/GameCult.Caching.Tests, the C# reference cache's own suite (about 1,260 tests, under a minute), ran in no
|
||||
# forge workflow; only the Mesh tests above exercised the reference.
|
||||
# The forge runner is globalization-invariant, so ADeterministicIdIsTheSameUnderEveryCulture... cannot construct a
|
||||
# culture and throws CultureNotFoundException; it is excluded here and still runs wherever ICU is present
|
||||
# (variants:follow_up:culture-test-invariant-image).
|
||||
- name: Verify the CultCache document owner with the C# reference
|
||||
run: dotnet test tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj --filter "FullyQualifiedName!~ADeterministicIdIsTheSameUnderEveryCulture" --logger "console;verbosity=minimal"
|
||||
|
||||
# Packs the four TypeScript packages and installs them into a clean consumer, so a package that builds but does
|
||||
# not install or load fails here.
|
||||
- name: Verify the packed TypeScript packages install and load from a clean consumer
|
||||
|
|
|
|||
2
.github/workflows/cultnet-interop.yml
vendored
2
.github/workflows/cultnet-interop.yml
vendored
|
|
@ -1,7 +1,7 @@
|
|||
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.
|
||||
# runner is Ubuntu-only (no Windows runner exists; push and pull_request return in the PR that registers one), so a push or pull_request trigger would queue a job that can never run.
|
||||
# Run it from GitHub or any Windows runner.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
|
|
|||
17
.github/workflows/quic-native.yml
vendored
17
.github/workflows/quic-native.yml
vendored
|
|
@ -18,6 +18,12 @@ on:
|
|||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
# A newer push or PR update replaces the older run on the same ref. Tag pushes and
|
||||
# workflow_dispatch runs are never cancelled: a tag run carries a release.
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
|
@ -49,11 +55,12 @@ jobs:
|
|||
|
||||
# workflow_dispatch and a cultmesh-quic-native-v* tag run the two builds.
|
||||
# A dry (workflow_dispatch) run uploads artifacts and creates no release.
|
||||
# No Windows runner is registered on the forge, so this job is workflow_dispatch only
|
||||
# (run it on GitHub or a Windows runner). The tag trigger returns in the PR that registers one.
|
||||
# Until then a tag push skips the release job, which needs this build.
|
||||
build-win32-x64:
|
||||
name: Build win32-x64
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v'))
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
|
|
@ -92,8 +99,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
# Q6 A: this pinned Debian 13 container is the only permitted Linux
|
||||
# release builder. The digest is the pin; the `debian:13` tag is only a
|
||||
# label. The artifact links against this image's glibc (2.41); a bare
|
||||
# ubuntu-latest runner carries glibc 2.39, which is not the deploy host's
|
||||
# label. The artifact links against this image's glibc (2.41);
|
||||
# a bare runner's OS image has its own glibc (a GitHub ubuntu-latest image carried 2.39), which is not the deploy host's
|
||||
# OS (docs/typescript-quic-realtime-cut.md:2192-2197).
|
||||
container:
|
||||
image: debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1
|
||||
|
|
|
|||
|
|
@ -140,6 +140,15 @@ A malformed put or delete is `message_invalid`, not a per-operation code: the pe
|
|||
- A shard snapshot represents a log sequence. Applying it replaces the local
|
||||
shard view and advances the replica cursor to that sequence.
|
||||
- Realtime subscriptions publish domain changes, not storage envelopes.
|
||||
- A subscription's `database_change_raw` messages may carry `commitId` and `commitCount`: the changes of one cache
|
||||
commit that this subscription delivers (`commitCount` of them, at least two). Both fields are optional and additive; a
|
||||
peer that does not know them ignores them and applies each change alone. A replica that knows them applies the
|
||||
commit's changes as one commit, so the replica judges the result (a swap of two unique-index values) and not the state
|
||||
between records. A replica whose cache refuses a commit is faulted, never silent. `commitId` is opaque and valid only
|
||||
within the subscription. The C# subscription server and database server tag commits; other runtimes adopt next.
|
||||
- The C# writer sets every key of a `database_change_raw` message, nil where unset: a removed change carries `document`
|
||||
nil, an added or updated one `recordKey` and `schemaId` nil. The schema accepts those nils; an added or updated change
|
||||
still needs a document, and a removed one a `recordKey` and `schemaId`.
|
||||
- Client-owned input documents may be predicted locally only inside explicit
|
||||
`CultNetClientAuthorityScope` declarations.
|
||||
- Predicted input state is not authoritative shared state. The shard log still
|
||||
|
|
|
|||
|
|
@ -10,15 +10,29 @@
|
|||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"subscriptionId": { "type": "string", "minLength": 1 },
|
||||
"changeKind": { "enum": ["added", "updated", "removed"] },
|
||||
"document": { "$ref": "./cultnet.raw-document-record.schema.json" },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"schemaId": { "type": "string", "minLength": 1 }
|
||||
"document": {
|
||||
"oneOf": [{ "type": "null" }, { "$ref": "./cultnet.raw-document-record.schema.json" }]
|
||||
},
|
||||
"recordKey": { "type": ["string", "null"], "minLength": 1 },
|
||||
"schemaId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"commitId": { "type": ["string", "null"], "minLength": 1 },
|
||||
"commitCount": { "type": ["integer", "null"], "minimum": 2 }
|
||||
},
|
||||
"allOf": [
|
||||
"oneOf": [
|
||||
{
|
||||
"if": { "properties": { "changeKind": { "const": "removed" } } },
|
||||
"then": { "required": ["recordKey", "schemaId"] },
|
||||
"else": { "required": ["document"] }
|
||||
"properties": {
|
||||
"changeKind": { "const": "removed" },
|
||||
"recordKey": { "type": "string" },
|
||||
"schemaId": { "type": "string" }
|
||||
},
|
||||
"required": ["recordKey", "schemaId"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"changeKind": { "enum": ["added", "updated"] },
|
||||
"document": { "$ref": "./cultnet.raw-document-record.schema.json" }
|
||||
},
|
||||
"required": ["document"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
19096
contracts/cultnet/governance/fork-card-vectors.json
Normal file
19096
contracts/cultnet/governance/fork-card-vectors.json
Normal file
File diff suppressed because one or more lines are too long
197
contracts/cultnet/governance/fork-card.md
Normal file
197
contracts/cultnet/governance/fork-card.md
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# CultNet fork-desk contract v0 (specification)
|
||||
|
||||
Status: specification, drafted for Heimdall (Bifrost receiver), Huginn (the second door) and Void (Ratatoskr desk). No runtime implements it yet.
|
||||
Rule source: `/opt/commons/handoff/fork-desk-answer-binding-20261110.md` (Heimdall; sections 1.1 to 1.5, 1.3a to 1.3d), amended by the Persona briefs of 2026-11-10 (Eureka's fit check). Where this page differs from that file it says so under "Differences from the binding file".
|
||||
Format source: the fork desk artifact in `/opt/commons/handoff/fork-desk-original/` (ruling `eureka-substrate:ruling:fork-desk-nibu-format`).
|
||||
Files here: `fork-card.schema.json` (one schema, a `$defs` entry per document), `fork-card-vectors.json`. Conformance: `tests/GameCult.Networking.Tests/GovernanceVectorTests.cs` (C#) and `packages/cultnet-ts/test/governance-vectors.test.ts` (TypeScript), both generated against vectors written by `scripts/gen-governance-vectors.py`. Each suite computes every hash with its own code and replays every scenario through a reference judge written from this page.
|
||||
|
||||
All documents are CultNet map-mode: every key written, nil where absent, closed schemas. Field names are snake_case as in the binding file and the mind's records; `schemaVersion` keeps CultNet's spelling (open choice 1). The bytes of every document are pinned in the vectors as `messagePackHex`.
|
||||
|
||||
## Documents
|
||||
|
||||
| schema id | what | who writes it |
|
||||
|---|---|---|
|
||||
| `gamecult.fork.card.v0` | the fork as shown | the publisher (a Mind's card projection) |
|
||||
| `gamecult.fork.answer_request.v0` | the answer a decider sends | the client; Bifrost forwards it unchanged to Huginn's second door |
|
||||
| `gamecult.desk_answer.v0` | the stored answer with one tagged outcome | Huginn only |
|
||||
| `gamecult.fork.answer_result.v0` | what the sender gets back for a stored answer | Bifrost, from the stored `desk_answer` |
|
||||
| `gamecult.fork.refusal.v0` | a body refused at the door, nothing stored (but `fork_too_large` on a stale answer, which sits over a stored `Refused(stale_fork)`) | Bifrost |
|
||||
| `gamecult.fork.record.v0` | a fork's answers plus the derived counted answer | a projection of the stored `desk_answer`s |
|
||||
| `gamecult.fork.ruling_cite.v0` | the citation a ruling carries | the Mind admitting the ruling |
|
||||
| `gamecult.fork.list_request.v0` | list a Mind's forks (Transport) | the desk |
|
||||
| `gamecult.fork.list.v0` | a page of entries, counts and a stamp | Bifrost, projected from Huginn |
|
||||
| `gamecult.fork.read_request.v0` | read one fork (Transport) | the desk |
|
||||
| `gamecult.fork.entry.v0` | one card with its record | Bifrost, projected from Huginn |
|
||||
| `gamecult.fork.unavailable.v0` | the outcome is not confirmed (the second door or Huginn did not answer); retry with the same `request_id` | Bifrost |
|
||||
|
||||
### card
|
||||
|
||||
`{schemaVersion, fork_id, title, context, options[{id, text}], recommendation, depends[], leans, flag, raised_in, deciders[], status, ruling, ruled_from[], order, options_hash}`.
|
||||
|
||||
- `fork_id`: the mind's `question` key, `<campaign>:question:<label>`.
|
||||
- **Hashed (the shown members):** `title`, `context`, `options` (each `{id, text}`, in shown order), `recommendation` (an option id, **never nil**: the stored `question` requires `recommended`), `depends` (strings, each one of the stored question's `depends` lines verbatim, at most 8, each at most 1000 UTF-8 bytes (the stored `Line`), in shown order, empty when none; the stored kind holds prose lines, so a line may read like a reference such as `campaign:kind:label` but is hashed as the text it is).
|
||||
- **Not hashed:** `fork_id` (bound beside the hash), `leans` (an option id or nil), `flag`, `raised_in`, `deciders`, `status` (`open` or `closed`), `ruling` (the ruling id when a ruling closed the fork; nil when the card is open, and nil on a closed card whose question was withdrawn or otherwise resolved with no ruling), `ruled_from` (answer ids a ruling has been admitted from; non-empty only on a closed card), `order`, and `options_hash` itself.
|
||||
- **`deciders` is read-only data.** The list is set outside the Mind, through Bifrost's admin path, and **Bifrost sends it per call**: Huginn keeps no copy (M5). The card projection copies the list the call carried. **The list holds one to 64 ids** (Huginn's bound), on the card and in the per-call list alike (vectors `deciders_at_the_cap_of_64`, `deciders_65`); the per-call list has no schema here, so the bound is stated for the cut that builds it. A question's own `audience` may only narrow it. A Mind never writes it, so a Mind cannot add itself and answer its own fork.
|
||||
- Rules a schema cannot say, each with a negative vector: a `depends` line is at most 1000 UTF-8 bytes (the schema bounds 8 lines and 1000 characters; bytes need the rule); option ids are unique; `order` is unique among a Mind's cards (a list with two cards on one `order` is refused, naming both fork ids); `recommendation` and `leans` name an option; deciders are unique; an open card names no `ruling` and its `ruled_from` is empty (a ruling that answers a fork closes it); `ruled_from` is non-empty only when `ruling` is set; a closed card may name no ruling (the question was withdrawn, superseded or deferred: step 6 reads `status`, not the ruling); `options_hash` equals the hash of the shown members.
|
||||
|
||||
### Options hash (canonical bytes)
|
||||
|
||||
`options_hash` = lowercase hex SHA-256 of the UTF-8 bytes of the following, byte for byte:
|
||||
|
||||
1. Build an object with exactly the five members `title`, `context`, `options`, `recommendation`, `depends`. Each option is an object with exactly `id` and `text`.
|
||||
2. **NFC-normalise every string, object keys included.** No trimming, no newline rewriting, no case folding. A string with a lone surrogate is not a card (it has no UTF-8). Normalisation stability makes NFC stable across Unicode versions for already-assigned code points; a runtime's own normaliser is trusted and the vectors include a decomposed pair and astral code points (open choice 2).
|
||||
3. Serialise as RFC 8785 (JCS): no whitespace; object keys sorted by UTF-16 code unit, which for these ASCII keys is `context, depends, options, recommendation, title` and, inside an option, `id, text`; array order is as shown (option order and `depends` order are part of the hash).
|
||||
4. Strings are written in double quotes; only `"` and `\` are backslash-escaped, U+0008, U+0009, U+000A, U+000C, U+000D as `\b \t \n \f \r`, every other code point below U+0020 as `\u00xx` in lowercase hex; everything else is written raw as UTF-8 (U+007F, U+2028, `<`, `&` and astral code points included).
|
||||
5. **Numbers and nulls:** the member set contains none. There is no number to canonicalise (a number anywhere in the five members is a defect) and `recommendation` is never nil, so no nil is hashed.
|
||||
|
||||
`fork_id` is not in the hash and is bound beside it in its own field, so one hash cannot be replayed under another fork. The publisher computes the hash and stores it in the card; the client recomputes it from what it rendered and refuses to send on a mismatch; the receiver recomputes from its stored fork, never from the client.
|
||||
|
||||
Vectors `optionsHash`: the seven of the binding file (`heimdall_A..G`), computed by the Python generator, by the C# suite and by the TypeScript suite independently, plus text, title, context and `depends`-order mutations, a case showing the unshown members do not move the hash, escapes and markup, and astral ids. Each suite also mutates every shown member, an option id included, and asserts the hash changes.
|
||||
|
||||
### answer_request
|
||||
|
||||
`{schemaVersion, fork_id, options_hash, answer, retracted, retracts, answered_at, request_id}`. **`request_id`** is set by the client, a lowercase D-format UUID, one per intended answer, kept across retries; the first stored answer that carries it binds it to the stamped `answerer`. "Same body" means every field equal, `answered_at` included, so a client resends identical bytes. A **retry with the same `request_id` and the same body returns the stored outcome unchanged** (a `Refused` one included; a replayed `stale_fork` carries the **current** card, because a stored `desk_answer` has no card field and no storage is added for one) and **never adds a Revision**; the same `request_id` with a different body is `request_id_conflict` (409, a refusal, nothing stored). The same id from another account is another request, judged on its own: Y reusing X's `request_id` with a body identical to X's gets **Y's own new answer, with its own answer id, stamped Y**; X's stored outcome (a `Refused` one included) is never returned in its place, and Y's request neither conflicts with it nor replays it. An `of` that names the counted answer is allowed, because answer ids are not secret between deciders (vectors V14: an off-list X's `Refused`, then a decider Y's identical body is its own `Counted`; X's `Counted`, then Y's identical body is its own `Revision` whose `of` names X's answer, and X's later retry still returns X's answer). Both suites assert it on every scenario: a response's `answerer` is the step's account, and an id first bound by another account never yields that account's answer id or document. **`retracted` is part of the body**: the same `request_id` with `retracted: true` and then `false` (or the reverse) and every other field equal is `request_id_conflict`, for the illegal forms as for any other (vectors `R1_retracted_true_then_false_...`, `R1_retracted_false_then_true_...`). Each illegal form's own retry, byte for byte, replays its stored `bad_retraction` (vectors `R1_a_retraction_with_an_answer_...`, `R1_a_retraction_with_no_target_...`, `R1_a_plain_answer_with_a_target_...`). A body refused at step 1 or 2 binds nothing, so its id is free to reuse; so a target-less retraction resent with `retracted: false` (no answer, no retraction) is `body_invalid` at step 2, not a conflict. The lookup comes after steps 1 and 2 and before the fork is looked up, so a retry gets its stored outcome even if the card has since changed. `answer` is `{choice, note}` (at least one non-nil) or nil on a retraction. `choice` is an option id; `note` is the person's words, verbatim, 1 to 4000 **UTF-8 bytes**, not blank. A schema counts characters, not bytes, so its `maxLength: 4000` (code points) is only the looser bound and both suites enforce the byte limit as a rule: 4000 ASCII bytes pass; 1000 astral characters (4000 bytes) pass and 1001 (4004) fail; `e` plus a combining acute is 3 bytes, so 1333 of them and an `x` pass and 1334 fail; 1999 two-byte characters plus three ASCII (4001 bytes) fail. A note over the limit is `body_invalid`. `answered_at` is the client's claim. **A request never carries `answerer` or `authority`**: both are stamped by Huginn (`answerer` from the verified subject the receiver passes; `authority` from the door's configuration). A body that names either key at any depth is refused whole, never stripped (`body_forbidden_field`); any other unknown key is `body_unknown_field`. The request type Bifrost sends over the second door is this document; it has no field for either key, so a client cannot supply them by construction, and Bifrost's scan is the first line, not the only one. How Huginn receives the verified subject is the door's authenticated context, not a body field (open choice 3). In map-mode `retracted` is always written (the binding file's optional `retracted` becomes `false`).
|
||||
|
||||
### desk_answer (the stored kind)
|
||||
|
||||
`{schemaVersion, answer_id, fork_id, outcome, of, retracts, code, options_hash, choice, note, answered_at, received_at, claim_in_future, answerer, authority, request_id}`, key `<campaign>:desk_answer:<question-label>.n<seq>`; `answer_id` is `n<seq>`, assigned by Huginn per question, and **an answer is named by its answer id** everywhere (`retracts`, `of`, `ruled_from`).
|
||||
|
||||
The stamped `authority` field of a stored answer is an **attribution**: the role the verified account holds (`Operator`, `Steward`, `Member`), copied from Bifrost's state. A stored answer confers no authority. Authority exists only where a ruling cites a counted answer ("Operator authority" below); the field keeps the binding file's spelling (open choice 8). One tagged `outcome`, each variant carrying only its own link:
|
||||
|
||||
- `Counted`: the answer that counts. `of`, `retracts`, `code` nil.
|
||||
- `Revision`: a later passing answer, kept, not counted while the first stands. `of` names the counted answer at the time.
|
||||
- `Retraction`: the stored form of `retracted: true` plus `retracts`. `retracts` names the answer retracted; `choice` and `note` nil. The illegal pair (a retraction with no target, or a target with no retraction) cannot be written.
|
||||
- `Refused`: any refusal from steps 4 to 7 or R1 and R2, with its `code`, stored too. `choice` and `note` keep what the body carried, and so does `retracts`: a refused body that named a target (a retraction of any refusal code, or a plain answer carrying one) projects it as `n<seq>`, never nil; a refused body that named none has `retracts` nil. `of` is nil.
|
||||
|
||||
`received_at` is Huginn's admission time; "first" means first by admission order (the `n<seq>` ordinal), never by `answered_at`. `claim_in_future` is true when `answered_at` is more than five minutes after `received_at`: accepted but flagged. It is a function of the two stored values, so the writer compares against the `received_at` it stores, at the precision it stores it (a flag decided against an unrounded clock and a stored second-truncated `received_at` disagrees with the document it sits in). **Not stored, and no answer id:** a body refused at step 1 or 2 (Bifrost; it never reaches Huginn), and a body naming an unknown fork (step 3; Huginn answers it from its stored forks, but with no question there is nothing to key a record under). Each gets a `refusal` and is dropped. A malformed body is therefore never stored, and neither is an unknown fork. Every refusal from step 4 on is stored. **`unavailable`** (503) is not a refusal and not in the refusal set: Bifrost answers it when the second door or Huginn did not answer after the auth steps and the body shape passed (a bad body is still refused first). **It says the outcome is NOT CONFIRMED, never that nothing was stored**: a timeout after the request reached Huginn may have stored it. It carries no answer id. The desk retries the same request with the same `request_id` and learns the outcome: the stored one if Huginn had it, a fresh one if not. It covers **calls 1 and 2 as well as the answer POST** when Huginn cannot be reached (Transport; a read has no outcome to confirm, so a retry is plain). `retryAfterSeconds` is nil when Bifrost has no hint.
|
||||
|
||||
**The mind's record and this document.** Huginn keeps the answer as its own record kind (`epiphany.pipeline.desk_answer.v2`, key `<campaign>:desk_answer:<question-label>.n<seq>`, the outcome as one externally tagged value, the fork as `fork`). The second door projects it to this document and nothing else crosses: `answer_id` is the key's `n<seq>` part, `fork_id` is the stored `fork`, the tagged outcome flattens into `outcome`, `of`, `retracts` and `code`, and the six refusal codes are spelt as in the schema (snake_case; the record's enum spells them in the mind's own PascalCase). On the wire an answer is named by its `n<seq>` alone, always inside the fork whose `fork_id` the document carries; a runtime that holds the full record key converts at the door, in one place. `body_sha256` and any other digest Huginn keeps to decide "same body" stay in the record: Huginn alone decides a replay or a `request_id_conflict` and Bifrost never compares bodies, so no digest is a field of this document. A digest is a valid way to decide it only if it covers `fork_id`, `options_hash`, `answer.choice`, `answer.note`, `retracted`, `retracts` and `answered_at` (every field of the request but `request_id`), is computed by Huginn and never read from a client.
|
||||
|
||||
**Huginn's digest, pinned in the vectors (`bodyDigests`; informative, not a wire document).** SHA-256 of one compact JSON object as `serde_json::to_string` writes it (UTF-8, no spaces), lowercase hex, keys in this order, `null` for a missing value, strings verbatim: `fork`, `options_hash`, `answered_at`, `choice`, `note`, `retracts` (the **full stored key** `<campaign>:desk_answer:<label>.n<seq>`: the door converts the wire's `n<seq>` before the digest), then, **only for the three illegal forms** (R1: a retraction carrying an answer, a retraction naming no target, a plain answer carrying a target), `illegal_retraction: true` and `retracted: true|false`. `request_id` is not in it. A plain answer and a legal retraction carry neither trailing key, so their digests are the ones Huginn main pins (`8a4d5b2e`, `9f807538`) and do not move. `9f807538` is pinned for a target no door produces (`Q1.1`); the golden for the real legal retraction is the door's form, the wire's `n1` converted to `Q1.n1` (`84576840`, vector `legal_retraction_door_form`, also main-valid). Source: Eureka's `desk-body-digest-order-20261110.md`, computed with `sha256sum`; the generator, the C# suite and the TypeScript suite recompute every digest from the input fields. **Status:** the three `retracted` goldens (`935cab1a`, `82b24ccc`, `34ef86ed`, and `79eee623` for form (i) with a null target) are the form of `cut-desk-digest-retracted.r2`, merged as Huginn #30. Eureka reports that these four and the door-form legal retraction (`84576840`) reproduce byte for byte on Huginn main `f0ca504` (the #30 fix); that run is hers, reported to us, and was not rerun here. Before #30, Huginn main (`7ea11b4`) wrote `illegal_retraction` alone, so forms (i) and (iii) collided there (`69074cc6`, pinned as `illegal_i_and_iii_alike_on_main`). The wire contract does not wait on it: the conflict scenarios are pinned in `scenarios`.
|
||||
|
||||
### Counting rules (in order; the first failure refuses with that code)
|
||||
|
||||
| step | rule | refusal | stored |
|
||||
|---|---|---|---|
|
||||
| 1 | verified session or bearer | `unauthenticated` (401) | no |
|
||||
| 2 | closed body; no `answerer`/`authority` at any depth; type-correct; not (no `answer` and not a retraction) | `body_forbidden_field`, `body_unknown_field`, `body_invalid` (400) | no |
|
||||
| 3 | `fork_id` is a stored fork | `fork_unknown` (404) | no |
|
||||
| 4 | `options_hash` equals the stored fork's hash | `stale_fork` (409) | **yes**; the result carries the current card |
|
||||
| 5 | the stamped `answerer` is on `deciders` | `not_a_decider` (403) | yes |
|
||||
| 6 | the fork is open (`status: open`) | `fork_closed` (409) | yes |
|
||||
| 7 | `choice`, if present, is an option id | `choice_invalid` (400) | yes |
|
||||
| 8 | first passing answer for `(fork_id, options_hash)` is `Counted`; later passing answers are `Revision`s naming the counted one | | yes |
|
||||
| 9 | a Matrix edit, redaction or reaction changes nothing | | no event is an answer |
|
||||
|
||||
**Retraction** (`retracted: true`, `retracts: <answer id>`, `answer: nil`, `options_hash` still carried, so step 4 applies) passes steps 1 to 6, then:
|
||||
|
||||
- R1 form: it carries no `answer`, it names a `Counted` or `Revision` answer of this fork under this `options_hash`; else `bad_retraction` (400). A retraction naming an answer already retracted is `already_retracted` (409). `retracts` on a body that is not a retraction is `bad_retraction`.
|
||||
- R2 who: the original answerer or any other decider; step 5 already requires a decider.
|
||||
- R3 is dropped and its label stays vacant, so R4 keeps its name in the binding file. A ruling that answers a fork closes it (Eureka and Heimdall, 2026-11-10), so a ruling cannot cite an answer on an open fork (that is the stale-click gap, and a card with `ruled_from` set and `status: open` is invalid), and a retraction after a ruling meets step 6: refused `fork_closed`, stored. There is no `ruled` code (vector `V10a_a_ruling_that_closed_the_fork_makes_a_retraction_fork_closed`).
|
||||
- R4 counted answer: retracting the counted answer removes it from the count and the next passing answer in admission order counts again. Retracting an answer that was not counted changes nothing. After every answer is retracted, a fresh answer is `Counted`. Records stay append-only.
|
||||
|
||||
**The counted answer is derived**, not stored: the first `Counted` or `Revision` answer under the card's current `options_hash` that no `Retraction` names. When the card changes, answers under the old hash stop counting and the next answer under the new hash is `Counted` (vectors `a_changed_card_...`). The `record` projection carries it as `counted_answer_id`.
|
||||
|
||||
### Operator authority
|
||||
|
||||
A ruling may carry `authority: Operator` only if it cites (`ruling_cite.answer_id`) a stored `desk_answer` whose outcome is `Counted` and whose stamped attribution (the answer's `authority` field) is `Operator` (or a stamped relay of her words from a mapped door, which this contract does not model). The citation is what gives a ruling authority: an attribution on an answer is a fact about the account, and authority exists only where a ruling cites a counted answer. The cited answer id is what `ruled_from` lists on the closed card. Negative vectors (`rulingCites`): no citation, a `Revision`, a `Refused` answer, a Counted answer by a non-operator, an answer that does not exist. Other authorities need no citation.
|
||||
|
||||
## Transport
|
||||
|
||||
Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-spec-20261110.md`) and Heimdall's binding file, `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`, section 1.3c, which is the source for the second door. **Everything in this section is spec text for Bifrost to build; no route, scope or audience below exists in Bifrost today.** The routes (with the `{mind}` segment), the scope names `desk.read` and `desk.answer`, the audience `bifrost-desk` and the auth order are Heimdall's rulings (his file, sections 4.1 to 4.3a); this page writes them as documents and vectors.
|
||||
|
||||
**Authorities.** Cards are projected **read-only from Huginn**: Bifrost never authors a card, a ruling or a status, and the desk never writes one. Bifrost verifies the bearer, resolves the actor from its `sub` (a `UserAccount.Id`), and for an answer forwards a typed `DeskAnswer` on the peer-checked second door to Huginn, then returns **Huginn's outcome unchanged**: the stored `desk_answer` in an `answer_result`, or its refusal. The wire of the second door is **pending**: Eureka's cut spec follows her record PR, so this page defines the documents the desk sees and not the bytes between Bifrost and Huginn (open choice 3).
|
||||
|
||||
| call | route (proposal) | request | response | token |
|
||||
|---|---|---|---|---|
|
||||
| 1 list | `GET /api/desk/{mind}/forks?status=&cursor=&limit=` | `list_request`: `mind` from the path, the rest from the query string | `list` | audience `bifrost-desk`, scope `desk.read` |
|
||||
| 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) or `fork_too_large` (422) | `desk.read` |
|
||||
| 3 answer | `POST /api/desk/{mind}/answers` | `answer_request` as the body; `fork_id` stays in the body, the Mind is the route's and is no body field | `answer_result` (200 for `Counted`, `Revision` and `Retraction`; a stored `Refused` answer keeps the `answer_result` body and takes its code's status from the counting-rules table: `stale_fork` 409, `not_a_decider` 403, `fork_closed` 409, `choice_invalid` 400, `bad_retraction` 400, `already_retracted` 409, as the vectors' `results` pin; a replay returns the same status as the first call; the desk reads `outcome` and `code` from the body), or a `refusal` (`fork_unknown`, or `fork_too_large` when a stale answer's card re-read is past the cap), or `unavailable` | audience `bifrost-desk`, scope `desk.answer` |
|
||||
|
||||
**The `{mind}` segment** (Heimdall's rulings M1 to M5; vectors `transport.mindRoutes`).
|
||||
|
||||
- **M1.** `{mind}` is looked up in a **fixed configured list** of Mind ids, each mapped in configuration to that Mind's socket on the peer-checked door. The segment is percent-decoded once and compared, ordinally, with the list's ids: no case folding, trimming or Unicode normalisation, and a decoding failure is a miss. It is **never joined into a path, a socket name or a query**, so a value off the list opens nothing (`../x`, `a/b`, `a%2fb`, `eureka%00`, a 4 KB string, `GAMECULT-CHAT` for `gamecult-chat`, a trailing space, bytes that are not UTF-8: all unknown, 404, no socket opened).
|
||||
- **M2.** An unknown Mind gets **the same refusal as an unknown fork**: `fork_unknown`, 404, the same bytes (the document's `error` names neither the Mind nor the fork). The list of Minds is not probeable. Auth is judged first, so a caller without a valid token, scope or Active membership learns nothing about which Minds exist.
|
||||
- **M3.** On the POST the route's Mind must hold `fork_id`: step 3 is judged against **that Mind's** stored forks, so a `fork_id` the route's Mind does not hold is `fork_unknown` and nothing is stored, in either Mind. A fork cannot be answered through another Mind's route. A body that names `mind` is `body_unknown_field`: the Mind is the route's.
|
||||
- **M4.** Scopes are **not per Mind**: `desk.read` and `desk.answer` open the routes of every configured Mind; every reader sees every fork's card and outcome, and which forks a caller may answer is the decider list.
|
||||
- **M5. The decider list is sent per call.** Bifrost holds the admin-set list and sends it with **every answer, read and list request** to Huginn, as a typed field on the same peer-checked door; Huginn keeps no copy of it. Because it travels with read and list too, a projected card's `deciders` is the list of that call, and step 5 judges an answer against the list the same call carried. It comes only from Bifrost's admin-set state: never from a Mind and never from any request body a client controls: no HTTP field, header or query parameter on any desk route sets or extends it, and a body naming `deciders` is `body_unknown_field` (scenario `M5_...`). A request carrying it from a Mind's own door or session is refused. **This is Bifrost and Huginn work and no vector here can test it** (the typed field and the door's wire are pending, open choice 3); the rule is stated so that the cut that builds the request has it. (Heimdall's ruling, #minds 2026-11-10 19:26Z; it replaces the earlier reading of a list Huginn receives once and holds.) **Envelope names:** the desk's envelope on that door uses the names this page uses, `mind`, `authority`, `subject` and `deciders`, with no conversion at the door (Eureka, #minds 2026-11-10 19:58Z); `deciders` is a per-call list of short ids at the envelope's top level beside `subject`, never inside the request body, and Huginn keeps no copy.
|
||||
|
||||
Calls 1 and 2 are enough for a read-only desk. Every response is the pinned MessagePack document or its JSON form with the same keys (open choice 1 of `vote-api.md`: the media type is unsettled).
|
||||
|
||||
**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document; an unknown key, `authority` included, is `request_invalid` here, whatever Huginn's own decode does with it) and `request_id_conflict` are added to the door refusals, and so is `fork_too_large` (422, list and read; see List and Read). **`request_id_conflict` is answer-only**: the idempotency key exists only on the answer. A door `request_id_conflict` on a read or list is a door fault and reaches the client as 503 `unavailable`, never as `request_id_conflict` (Persona, 2026-11-11, agreed with Heimdall; vectors `transport.doorFaults`, each carrying the client response's bytes). The `unavailable` text's "same request_id" retry hint is for answers; reads and lists carry no request_id..
|
||||
|
||||
**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c<N>`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s<N>`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. **A page may hold fewer than `limit` entries** when the next entry would push the encoded response past the receiver's response budget (Huginn: 65536 bytes); it then holds at least one, `next_cursor` is `c<order of the last entry returned>` so the next page resumes below it, and the counts and the stamp are those of the full list, unchanged. **`fork_too_large`** (HTTP 422) refuses a page whose **first** entry alone is past the budget: 422 because the request is well formed and authorised but names a resource the list cannot carry, and 413 is for a request body that is too large, which this is not; retrying returns the same refusal. Its `error` names the fork id and its `order`, for example `fork_too_large: fork <fork_id> (order <n>) alone is past the response budget`. **A refusal's `error` text is informational and not pinned**: the contract pins the code, the HTTP status, that the text names the fork id (and the order on a list), and the `c<order>` continuation on a list; the vectors' wording is a sample, and the judges check those four things, not the bytes of the text. The client continues the list with cursor `c<n>`, the cards strictly below that order, which skips this fork; it is then readable only by id (the read route returns the whole entry, and refuses with the same code only when that entry alone is past the cap, see Read); `c<n>` is valid only when n is at least 1, so for order 0 there is nothing below and the client has reached the end. `limit` 0 returns no entries and never refuses. (Ruled by Persona, 2026-11-11.) An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`).
|
||||
|
||||
**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Bifrost applies visibility at the door; Huginn returns the whole record to the door.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). **A read is refused `fork_too_large` (422) when the whole entry is past the reply cap** (Huginn's daemon caps every reply at 65536 bytes; the cap, not the page budget, is what a read meets). Its `error` names the fork id and has no order and no cursor to carry, because there is nothing to continue with (sample: `fork_too_large: fork <fork_id> alone is past the response budget`) (vector `transport.readTooLarge`; the cap is in no vector). **The answer route meets the same cap:** when an answer is `stale_fork`, Bifrost re-reads the current card to attach it, and if that read is past the cap the answer is refused `fork_too_large` (422) with the read form of the text, the same document as a read. Huginn has already stored the answer as `Refused(stale_fork)`, as for any stale answer, and it is never counted; the client is told `fork_too_large` instead of receiving the current card, and a replay of the same `request_id` gets the same 422 while the fork stays oversize. It is never `unavailable` (vector `transport.answerTooLarge`). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands.
|
||||
|
||||
**Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n<seq>` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way.
|
||||
|
||||
**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by <first fork id> and <second fork id>`); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `listShortPages` (three scenarios: a page that stops short and resumes below its last entry; `fork_too_large` on a first entry of order 3, continued with `c3`; and a short page ending at order 1 followed by `fork_too_large` on order 0, where the client is at the end; each step carries its bytes, and the receiver's budget is in no vector, so the judges check a short page as a nonempty strict prefix of the unbudgeted page and a refusal as naming the entry the page would open with); `doorFaults` (three cases: a door `request_id_conflict` is `unavailable` 503 on read and list and itself 409 on the answer); `readTooLarge` (one case: a read whose entry is past the cap, refused `fork_too_large` 422 with the read-form text and its bytes); `answerTooLarge` (one case: a stale answer whose card re-read is past the cap, the same 422 document over a stored `Refused(stale_fork)`: `outcome` `Refused`, `code` `stale_fork`, `counted` false); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked.
|
||||
|
||||
**Bifrost must still build:** all three routes; the `bifrost-desk` audience and the `desk.read` and `desk.answer` scopes on the OIDC token it issues to chat; the configured Mind list and its sockets (M1); the projection of cards and records from Huginn, the stamp, the cursor and the counts; the `DeskAnswer` call on the second door and the pass-through of its outcome; the decider list, sent with every answer, read and list request from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the per-call decider list it receives with each answer, read and list request, keeping no copy (M5), and the read side the projection uses (Eureka's cut spec, after her record PR). **The desk may build today** against the vectors: nothing in them needs a live service.
|
||||
|
||||
## Old format, the mind's record, and what maps to what
|
||||
|
||||
The old desk (`index.html`) read `questions/<id>` and wrote `answers/<id>` in a shared store. The mind's `question` record is the source in the new design; the card is its projection.
|
||||
|
||||
| old desk | mind `question` record | card |
|
||||
|---|---|---|
|
||||
| `qid`, `id` | key `campaign:question:label` | `fork_id` |
|
||||
| `title` | `title` | `title` |
|
||||
| `body` | `question` (the long text) | `context` |
|
||||
| `depends`: a free string | `depends`: up to 8 prose lines (`huginn schema question`) | `depends`: the lines verbatim |
|
||||
| `options[{key, label, text}]` | `options[{label, text}]` | `options[{id, text}]` |
|
||||
| option `key` | option `label` (a slug, the id) | option `id` |
|
||||
| option `label` (a human title) | none | **dropped**: not stored, not hashed; shown text is `text` |
|
||||
| `rec` (a key) | `recommended` (a label) | `recommendation` (an id), never nil |
|
||||
| `lean` ("Self leans" mark) | none | `leans` (an id or nil), not hashed |
|
||||
| `flag` (the provisional line) | none | `flag` (a string or nil), not hashed |
|
||||
| `status` open or closed, `ruling` | derived from the resolution (status is never written): any resolution of the question closes it; the ruling is the one that answered it | `status`, `ruling`, both a projection (`ruling` is nil on an open card and on a card closed with no ruling) |
|
||||
| `order` | none (unique rank per Mind, from 0) | `order` |
|
||||
| none | `raised_in`, `asked_on` | `raised_in`; `asked_on` is not carried |
|
||||
| none | none | `deciders` (outside the Mind), `ruled_from`, `options_hash` |
|
||||
| answer `{choice: key or null, note, question, updatedAt}`, one mutable document per question | none | `answer_request` then an append-only `desk_answer` |
|
||||
| `note`: her words verbatim | | `answer.note`, verbatim |
|
||||
| "Clear pick": `choice` back to null so the note stands alone | | an `answer` with `choice` nil and `note` set, as the first answer or as a `Revision` |
|
||||
| "Changing an answer is fine until it's admitted as a ruling" | | a later answer is a `Revision`; `Retraction` withdraws one; neither changes who counted first, and a ruling that answers the fork closes it, after which no answer or retraction is taken (`fork_closed`) |
|
||||
|
||||
Differences that need Void's choice: the old page **overwrites** one answer per question and the new store **appends**, so the booth cannot show "your answer" as one mutable field (it shows the latest answer by the person, plus its history); the old page needs no identity and the new one stamps `answerer` from a verified session; the old `label` has nowhere to live unless the card's `text` carries it.
|
||||
|
||||
## Transport note for Void: Matrix event versus how the mind carries questions
|
||||
|
||||
How a mind carries a question today: a typed `question` record in Huginn (`huginn view`, `huginn query`); the Persona relays it to the operator in chat, and the operator's reply comes back as chat text that Self reads and admits as a `ruling` in her words. Identity is whoever the chat attributes the message to; nothing binds an answer to the question text she saw.
|
||||
|
||||
Option A, **a Matrix event in the Mind's room with the answer as a reply.** Gains: it sits where people already are, needs no sign-in beyond Matrix, and the thread shows the exchange. Loses: the sender is an mxid, not a Bifrost account, so `answerer` and `authority` have no verified source; an edit or redaction changes the visible message but not the decision (binding rule 9), so the thread and the record can disagree; there is nowhere to carry `options_hash`, so a stale card cannot be refused; and a reply is free text, so `choice` is lost.
|
||||
|
||||
Option B, **Bifrost receives the answer and Huginn stores it** (the design in this page; Ratatoskr PR #24 also withdraws the Matrix carrier). Gains: a verified account, a bound hash, refusals that are stored and visible, one counted answer, retractions. Loses: the booth needs Bifrost sign-in, which does not reach chat yet (Bifrost PR #9 and #37 design it), so until then the desk is read-only; and the exchange is no longer in the room.
|
||||
|
||||
**Recommendation: B, with Matrix as presentation only.** The Mind may post a card as a listing and a link into the desk; no Matrix event is ever an answer (rule 9). Confidence: high on the rule (it is Heimdall's and Eureka's already), moderate on the interim, where read-only until sign-in is a real cost; if the operator needs to answer before sign-in reaches chat, the honest stopgap is the old path (chat text, Self admits a ruling in her words) rather than a Matrix reply the record cannot bind.
|
||||
|
||||
## Differences from the binding file
|
||||
|
||||
- Spelling: every field is as the binding file names it (`fork_id`, `options_hash`, `answered_at`, `retracted`, `retracts`, `answer.choice`, `answer.note`, `recommendation`, `depends`, option `id`), except that `schemaVersion` keeps CultNet's camelCase and `answer_id` replaces the earlier `receipt id` (Persona brief).
|
||||
- Map-mode writes every key: `retracted` is always present; nothing is "optional".
|
||||
- `Refused` stores every refusal from steps 4 to 7 and R1 and R2 (his section 1.3b); only steps 1 and 2 and `fork_unknown` are not stored. The binding file does not say whether `fork_unknown` is stored; it cannot be keyed without a question, so it is not (open choice 4).
|
||||
- The binding file's `choice_valid` has no named refusal code; this page names it `choice_invalid`. `fork_known` is `fork_unknown`. A type-invalid body is `body_invalid`.
|
||||
- `ruled` (his R3 and V10) is dropped: a ruling that answers a fork closes it, so the case is `fork_closed` (V4, V10a). His file drops it too.
|
||||
- Heimdall's section 1.3e (Q1 to Q6, V12 to V18) is the source for `request_id`; this page matches it, with two notes. Q1's open item is settled by his ruling: a replayed `stale_fork` returns the current card. In V14 a decider's identical body is a `Revision` whose `of` necessarily names the first account's counted answer id (the record shows it to every decider), so "never contains X's answer_id" holds for Y's answer id and document, not for `of` (Heimdall: `of` naming the counted answer is allowed); the vector with an off-list first account pins the case with a `Counted`, which has no `of`.
|
||||
- `request_id` (on the request and the stored `desk_answer`) and `request_id_conflict` are this page's: his file has no idempotency key, and a retry after `unavailable` needs one. Eureka's `desk_answer` kind and Huginn's request type must carry it.
|
||||
- Transport: the routes, the `{mind}` segment, the scopes, the audience and the auth order are his rulings (4.1 to 4.3a); the documents (list, read, `unavailable`) are this page's. The note limit is 4000 UTF-8 bytes here, as his 1.1 leaves the unit unsaid.
|
||||
- The recommendation is never nil. The binding file's earlier vectors D and E used a nil recommendation; its updated D and E use `"a"`, as here.
|
||||
- Hash vectors: A, B, C, D, E, F and G of the binding file are reproduced digest for digest by the Python generator, the C# suite and the TypeScript suite (the generator prints `same` for each; none differs).
|
||||
|
||||
## Open choices
|
||||
|
||||
1. Case of field names: snake_case for the fork documents (as the binding file) beside camelCase for the vote documents (as Bifrost's serializer). One convention would be tidier.
|
||||
2. NFC across runtimes relies on each runtime's normaliser; a pinned table, as `write-receipt.md` does for letters, would remove the reliance. The risk is only for code points assigned after a runtime's Unicode version.
|
||||
3. How Huginn learns the verified subject: the second door's authenticated context (the peer credential names Bifrost the service, not the member), so the subject must travel beside the request. This contract has no envelope for it.
|
||||
4. Whether `fork_unknown` and shape refusals should leave an audit record somewhere other than a `desk_answer`.
|
||||
5. Whether `leans` and `flag`, which the booth shows, should join the hash. The binding file's five members do not include them; a changed lean would not make an answer stale.
|
||||
6. What the person sees of a retracted counted answer when the next answer counts: this contract only says the count moves.
|
||||
7. `received_at` is not in the binding file's stored fields; it is kept for `claim_in_future` and could be dropped if the `n<seq>` answer ordinal alone decided answer order.
|
||||
8. The stamped `authority` field is an attribution, not an authority (above); renaming it `attribution` would say so, at the price of a new key the door must also refuse in a body.
|
||||
9. The claims Bifrost verifies are PR #24's open item 5. (The audience `bifrost-desk`, the three routes and the scope names are ruled by Heimdall.)
|
||||
10. The second door's wire between Bifrost and Huginn (pending Eureka's cut spec). `unavailable` is a document of its own, outside the refusal set, so a desk can retry it; calls 1 and 2 use it too when Huginn is down (vectors `huginn_down_*`).
|
||||
11. While a known Mind's door is down its routes answer `unavailable` (503) and an unknown Mind answers `fork_unknown` (404), so the two are distinguishable for that period. M2 makes them identical only when every door is up; making them identical when one is down means answering `unavailable` for any `{mind}` not on the list, which tells a client nothing about which Minds exist but also hides a typo. Heimdall's to rule; the vectors do not pin it.
|
||||
12. A fork's answers are unbounded in number, so a fork's entry can grow past the reply cap and become unreadable (`fork_too_large` on read, and on list when it is the first entry of a page). Whether the contract should cap the answers per fork is for Persona and Heimdall to rule; nothing here decides it.
|
||||
13. The read form of `fork_too_large` is a target, not current behaviour: Huginn's door answers an over-cap read as `Refused(ResponseTooLarge{bytes, limit})`, which carries no fork id and is not `fork_too_large`. Bifrost maps it to `fork_too_large` (422) using the route's `fork_id`; until it does, a read past the cap reaches the client as 503 `unavailable`. The same holds for the answer route: a `stale_fork` answer whose card re-read is past the cap is a Bifrost target too (`fork_too_large` 422 over a stored `Refused(stale_fork)`), and until it is built the client sees 503 `unavailable`.
|
||||
987
contracts/cultnet/governance/fork-card.schema.json
Normal file
987
contracts/cultnet/governance/fork-card.schema.json
Normal file
|
|
@ -0,0 +1,987 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/governance/fork-card.schema.json",
|
||||
"title": "CultNet fork-desk contract v0",
|
||||
"description": "A fork card, the answer request a decider sends, the stored desk_answer with its tagged outcome, the result and door refusals returned to the sender, the record a fork keeps, and the ruling citation. Map-mode: every key is written, nil where absent. A request never carries answerer or authority: Huginn stamps both. Rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md. Spec: fork-card.md. Vectors: fork-card-vectors.json.",
|
||||
"$defs": {
|
||||
"hex64": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"instant": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,7})?Z$"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"answerId": {
|
||||
"type": "string",
|
||||
"pattern": "^n[1-9][0-9]*$"
|
||||
},
|
||||
"option": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"text"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"ref": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"kind",
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"fork_id",
|
||||
"title",
|
||||
"context",
|
||||
"options",
|
||||
"recommendation",
|
||||
"depends",
|
||||
"leans",
|
||||
"flag",
|
||||
"raised_in",
|
||||
"deciders",
|
||||
"status",
|
||||
"ruling",
|
||||
"ruled_from",
|
||||
"order",
|
||||
"options_hash"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.card.v0"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"context": {
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"$ref": "#/$defs/option"
|
||||
}
|
||||
},
|
||||
"recommendation": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"depends": {
|
||||
"type": "array",
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1000
|
||||
}
|
||||
},
|
||||
"leans": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"flag": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"raised_in": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ref"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"deciders": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"maxItems": 64
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"open",
|
||||
"closed"
|
||||
]
|
||||
},
|
||||
"ruling": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ruled_from": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
}
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"words": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4000,
|
||||
"pattern": "^(?![\\t-\\r \\u0085 -
]+$)[\\s\\S]+$",
|
||||
"description": "At most 4000 UTF-8 bytes, which a schema cannot say: maxLength here counts code points and is only the looser bound (a code point is at least one byte). The byte limit is a rule both suites enforce (fork-card.md, answer_request)."
|
||||
},
|
||||
"answerObject": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"choice",
|
||||
"note"
|
||||
],
|
||||
"properties": {
|
||||
"choice": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"note": {
|
||||
"$ref": "#/$defs/words"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"choice",
|
||||
"note"
|
||||
],
|
||||
"properties": {
|
||||
"choice": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"note": {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"choice",
|
||||
"note"
|
||||
],
|
||||
"properties": {
|
||||
"choice": {
|
||||
"type": "null"
|
||||
},
|
||||
"note": {
|
||||
"$ref": "#/$defs/words"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"answerRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"fork_id",
|
||||
"options_hash",
|
||||
"answer",
|
||||
"retracted",
|
||||
"retracts",
|
||||
"answered_at",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.answer_request.v0"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"answer": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerObject"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"retracted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"retracts": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswerCounted": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer_id",
|
||||
"fork_id",
|
||||
"outcome",
|
||||
"of",
|
||||
"retracts",
|
||||
"code",
|
||||
"options_hash",
|
||||
"choice",
|
||||
"note",
|
||||
"answered_at",
|
||||
"received_at",
|
||||
"claim_in_future",
|
||||
"answerer",
|
||||
"authority",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.desk_answer.v0"
|
||||
},
|
||||
"answer_id": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"outcome": {
|
||||
"const": "Counted"
|
||||
},
|
||||
"of": {
|
||||
"type": "null"
|
||||
},
|
||||
"retracts": {
|
||||
"type": "null"
|
||||
},
|
||||
"code": {
|
||||
"type": "null"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"choice": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"note": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/words"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"received_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"claim_in_future": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswerRevision": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer_id",
|
||||
"fork_id",
|
||||
"outcome",
|
||||
"of",
|
||||
"retracts",
|
||||
"code",
|
||||
"options_hash",
|
||||
"choice",
|
||||
"note",
|
||||
"answered_at",
|
||||
"received_at",
|
||||
"claim_in_future",
|
||||
"answerer",
|
||||
"authority",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.desk_answer.v0"
|
||||
},
|
||||
"answer_id": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"outcome": {
|
||||
"const": "Revision"
|
||||
},
|
||||
"of": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"retracts": {
|
||||
"type": "null"
|
||||
},
|
||||
"code": {
|
||||
"type": "null"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"choice": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"note": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/words"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"received_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"claim_in_future": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswerRetraction": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer_id",
|
||||
"fork_id",
|
||||
"outcome",
|
||||
"of",
|
||||
"retracts",
|
||||
"code",
|
||||
"options_hash",
|
||||
"choice",
|
||||
"note",
|
||||
"answered_at",
|
||||
"received_at",
|
||||
"claim_in_future",
|
||||
"answerer",
|
||||
"authority",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.desk_answer.v0"
|
||||
},
|
||||
"answer_id": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"outcome": {
|
||||
"const": "Retraction"
|
||||
},
|
||||
"of": {
|
||||
"type": "null"
|
||||
},
|
||||
"retracts": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"code": {
|
||||
"type": "null"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"choice": {
|
||||
"type": "null"
|
||||
},
|
||||
"note": {
|
||||
"type": "null"
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"received_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"claim_in_future": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswerRefused": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer_id",
|
||||
"fork_id",
|
||||
"outcome",
|
||||
"of",
|
||||
"retracts",
|
||||
"code",
|
||||
"options_hash",
|
||||
"choice",
|
||||
"note",
|
||||
"answered_at",
|
||||
"received_at",
|
||||
"claim_in_future",
|
||||
"answerer",
|
||||
"authority",
|
||||
"request_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.desk_answer.v0"
|
||||
},
|
||||
"answer_id": {
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"outcome": {
|
||||
"const": "Refused"
|
||||
},
|
||||
"of": {
|
||||
"type": "null"
|
||||
},
|
||||
"retracts": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"code": {
|
||||
"enum": [
|
||||
"stale_fork",
|
||||
"not_a_decider",
|
||||
"fork_closed",
|
||||
"choice_invalid",
|
||||
"bad_retraction",
|
||||
"already_retracted"
|
||||
]
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"choice": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"note": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/words"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answered_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"received_at": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"claim_in_future": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"answerer": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"authority": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"request_id": {
|
||||
"$ref": "#/$defs/requestId"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deskAnswer": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/deskAnswerCounted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/deskAnswerRevision"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/deskAnswerRetraction"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/deskAnswerRefused"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answerResult": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"answer",
|
||||
"card",
|
||||
"counted_answer_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.answer_result.v0"
|
||||
},
|
||||
"answer": {
|
||||
"$ref": "#/$defs/deskAnswer"
|
||||
},
|
||||
"card": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/card"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"counted_answer_id": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"code",
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.refusal.v0"
|
||||
},
|
||||
"code": {
|
||||
"enum": [
|
||||
"unauthenticated",
|
||||
"body_forbidden_field",
|
||||
"body_unknown_field",
|
||||
"body_invalid",
|
||||
"fork_unknown",
|
||||
"fork_too_large",
|
||||
"insufficient_scope",
|
||||
"forbidden",
|
||||
"request_invalid",
|
||||
"request_id_conflict"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"record": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"fork_id",
|
||||
"options_hash",
|
||||
"counted_answer_id",
|
||||
"answers"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.record.v0"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"options_hash": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"counted_answer_id": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"answers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/deskAnswer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rulingCite": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"ruling_id",
|
||||
"authority",
|
||||
"answer_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.ruling_cite.v0"
|
||||
},
|
||||
"ruling_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"authority": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"answer_id": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/answerId"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string",
|
||||
"pattern": "^c[1-9][0-9]*$",
|
||||
"description": "Opaque to a client. c<N> means: the cards whose order is below N."
|
||||
},
|
||||
"stamp": {
|
||||
"type": "string",
|
||||
"pattern": "^s[0-9]+$",
|
||||
"description": "s<N>, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents. Two lists with the same stamp are the same state."
|
||||
},
|
||||
"listRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"mind",
|
||||
"status",
|
||||
"cursor",
|
||||
"limit"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.list_request.v0"
|
||||
},
|
||||
"mind": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"status": {
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"open",
|
||||
"recorded"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cursor": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/cursor"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 50
|
||||
}
|
||||
}
|
||||
},
|
||||
"readRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"mind",
|
||||
"fork_id"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.read_request.v0"
|
||||
},
|
||||
"mind": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"fork_id": {
|
||||
"$ref": "#/$defs/id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"card",
|
||||
"record"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.entry.v0"
|
||||
},
|
||||
"card": {
|
||||
"$ref": "#/$defs/card"
|
||||
},
|
||||
"record": {
|
||||
"$ref": "#/$defs/record"
|
||||
}
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"mind",
|
||||
"stamp",
|
||||
"open_count",
|
||||
"recorded_count",
|
||||
"forks",
|
||||
"next_cursor"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.list.v0"
|
||||
},
|
||||
"mind": {
|
||||
"$ref": "#/$defs/id"
|
||||
},
|
||||
"stamp": {
|
||||
"$ref": "#/$defs/stamp"
|
||||
},
|
||||
"open_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"recorded_count": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"forks": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"$ref": "#/$defs/entry"
|
||||
}
|
||||
},
|
||||
"next_cursor": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/cursor"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"unavailable": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"code",
|
||||
"error",
|
||||
"retryAfterSeconds"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.fork.unavailable.v0"
|
||||
},
|
||||
"code": {
|
||||
"const": "unavailable"
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"retryAfterSeconds": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "The outcome is NOT CONFIRMED: the request may have reached Huginn and been stored before the reply was lost. Retry with the same request_id to learn the outcome."
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
"description": "Set by the client, a lowercase D-format UUID. Bound to the stamped answerer by the first stored answer that carries it."
|
||||
}
|
||||
}
|
||||
}
|
||||
1231
contracts/cultnet/governance/vote-api-vectors.json
Normal file
1231
contracts/cultnet/governance/vote-api-vectors.json
Normal file
File diff suppressed because it is too large
Load diff
107
contracts/cultnet/governance/vote-api.md
Normal file
107
contracts/cultnet/governance/vote-api.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# CultNet vote API v0 (specification)
|
||||
|
||||
Status: specification, drafted for Heimdall (Bifrost) and a Ratatoskr voting module. No server implements it yet.
|
||||
Files here: `vote-api.schema.json` (one schema, a `$defs` entry per document), `vote-api-vectors.json`. Conformance suites: `tests/GameCult.Networking.Tests/GovernanceVectorTests.cs` (C#) and `packages/cultnet-ts/test/governance-vectors.test.ts` (TypeScript). The vectors are written by `scripts/gen-governance-vectors.py`.
|
||||
Facts it rests on: `/opt/commons/handoff/bifrost-browser-voting-facts.md` (Bifrost `416f11a`; `416f11a..276f611` changes four files: a Standard-close concurrency fix in `MotionGovernanceService.cs`, two test files and `docs/deploy-main-to-live.md`; none touches the sealed API, so the facts hold at `276f611`) and Bifrost PR #37 (`docs/ratatoskr-voting-auth.md`, see "PR #37" below).
|
||||
|
||||
## Scope
|
||||
|
||||
**Which API covers which.** This contract covers **sealed-ballot motions only** (`gamecult.sealed_ballot.v0`). **Standard motions are not covered here.** Bifrost PR #37 also serves them (`PUT /api/votes/motions/{id}/ballot`, a receipt `{motionId, choice, revision, castAtUtc, receiptId}`), and that API is #37's own: this page defines no document for it, and a Standard motion id sent to any exchange below is answered `not_found`. Today Standard motions have no JSON read or cast API at all, no Mind path, and no receipt (open choice 1). The contract names four exchanges and one refusal document:
|
||||
|
||||
| exchange | request | response |
|
||||
|---|---|---|
|
||||
| motion list | `gamecult.vote.motion_list_request.v0` | `gamecult.vote.motion_list.v0` |
|
||||
| motion read | `gamecult.vote.motion_read_request.v0` | `gamecult.vote.motion.v0` |
|
||||
| cast (commit or reveal) | `gamecult.vote.cast.v0` | `gamecult.vote.receipt.v0` |
|
||||
| any refusal | | `gamecult.vote.refusal.v0` |
|
||||
|
||||
Every document is CultNet map-mode: all keys are written, nil where absent, and the schema is closed (`additionalProperties: false`). Instants are RFC 3339 UTC with a `Z` and up to seven fractional digits. Ids are lowercase `D`-format UUIDs. A motion states `highStakes`, a boolean fixed when the motion opens and never changed after (Bifrost PR #37, D2): it is the property that makes a commit demand a fresh sign-in (`reauthentication_required`). The bytes of every document are pinned by `messagePackHex` in the vectors (shortest integers, shortest strings, keys in the order the schema lists them); JSON with the same keys is the xenos-boundary form of the same documents (open choice 4: the wire format).
|
||||
|
||||
## Documents
|
||||
|
||||
- **motion_list_request**: `{schemaVersion, phase}`; `phase` is `Commit`, `Reveal`, `Closed` or nil for all.
|
||||
- **motion_list**: `{schemaVersion, motions: [{id, title, round, phase, commitClosesAtUtc, closesAtUtc, highStakes}]}`. **Heimdall must add this endpoint**: Bifrost has no list route for sealed motions today, only fetch-by-id. No pagination in v0 (open choice 2).
|
||||
- **motion_read_request**: `{schemaVersion, motionId}`.
|
||||
- **motion**: Bifrost's `SealedMotionState` with its names: `{schemaVersion, id, title, summary, round, priorMotionId, ballotSchema, phase, candidates (two or more), commitClosesAtUtc, closesAtUtc, highStakes, yourVoterId, yourCommitment (a receipt or nil), announcement}`. It never carries a tally, a turnout, or another voter's commitment or ballot (a `tally` key is a negative vector). `yourVoterId` is the voter's `UserAccount.Id`, the `voterUserAccountId` of the commitment.
|
||||
- **cast**: `{schemaVersion, motionId, verb, commitment, ballot}` with exactly one of two shapes: `verb: "commit"` with a 64-hex `commitment` and `ballot: nil`, or `verb: "reveal"` with `commitment: nil` and a `ballot`. One document for both verbs; today's two routes (`/commit`, `/reveal`) may stay, taking this document as the body, with a `verb` that must match the route (open choice 3).
|
||||
- **ballot** (inside a reveal): `{schema: "gamecult.sealed_ballot.v0", motionId, round, candidate, reason, salt, persona}`. `candidate` and `reason` are single-line and **already trimmed** (no Unicode White_Space at either end, using the White_Space list of `write-receipt.md`); the contract refuses an untrimmed value rather than trimming it, so the bytes hashed are the bytes sent. **This is a behaviour change Bifrost must make:** it trims `candidate` and `reason` silently on reveal today (facts file section 3c, `SealedBallotService.cs:154, 385-387`), so an untrimmed reveal is accepted and hashed trimmed; under this contract it is `invalid` (open choice 5). `reason` is 1 to 280 characters counted in UTF-16 code units, as Bifrost counts. A JSON Schema `maxLength` counts code points, so the schema alone accepts up to 280 astral characters; both suites add the rule (vectors `cast_reveal_reason_140_astral_280_units` valid, `ballot_reason_141_astral_282_units` refused). `salt` is 32 to 128 lowercase hex. `persona` is `""` for a member and a printable-ASCII, space-free id of at most 64 characters for a Mind.
|
||||
- **receipt**: Bifrost's `SealedBallotReceipt`: `{schemaVersion, motionId, voterUserAccountId, commitment, revision (>= 1), committedAtUtc, revealed}`. The receipt for a reveal has `revealed: true` and **echoes nothing of the ballot** (no candidate, reason or salt; negative vectors for each).
|
||||
- **refusal**: `{schemaVersion, code, error, retryAfterSeconds}`; the `code` set is closed:
|
||||
|
||||
| code | HTTP | trigger |
|
||||
|---|---|---|
|
||||
| `unauthenticated` | 401 | no bearer, or one Heimdall does not know (the API must answer this, not redirect to a sign-in page) |
|
||||
| `reauthentication_required` | 401 | a commit on a `highStakes` motion with no sign-in fresh enough (#37 D2: `prompt=login`, `max_age`); the voter signs in again at the top level and retries |
|
||||
| `forbidden` | 403 | authenticated but not an Active member, or a Mind key used on a route it may not call |
|
||||
| `not_found` | 404 | unknown motion id, a Standard motion id, or a reveal with no prior commit |
|
||||
| `wrong_phase` | 409 | commit outside Commit, reveal outside Reveal |
|
||||
| `contended` | 409 | a reveal raced the close fence: send it again |
|
||||
| `invalid` | 400 | the body fails the schema or an in-document rule (untrimmed text, candidate not in the motion, `round` or `motionId` mismatch, `persona` wrong for the credential) |
|
||||
| `commitment_mismatch` | 400 | a reveal whose canonical bytes do not hash to the stored commitment |
|
||||
| `payload_too_large` | 413 | body over 4096 bytes |
|
||||
| `rate_limited` | 429 | cast rate exceeded, per account; `retryAfterSeconds >= 1`, mirrored in `Retry-After` |
|
||||
| `held` | 503 | the Ratatoskr voting brake is set (#37 D5): nothing is read or stored while it is |
|
||||
|
||||
Each code has a vector (`refusals`), and every failure of the schema has a negative vector (`invalid`).
|
||||
|
||||
**Not on Bifrost's member API yet.** The **413 body cap of 4096 bytes** and the **429 per-account cast limit** are not on Bifrost's member sealed API today (facts file sections 3c and 5): both exist only on the Mind intake route (4096 bytes; 30 requests a minute per source address). #37 proposes 30 casts per 10 minutes per account for the member routes (its D6). `reauthentication_required` and `held` are #37 proposals (D2, D5) and exist nowhere in code. All four are spec text until Heimdall builds them.
|
||||
|
||||
**Two refusals Bifrost must split first.** Bifrost returns a reveal whose bytes do not hash to the stored commitment as a plain `400` `Invalid` ("The ballot does not match the stored commitment."), the same status and error type as every other bad body, so `commitment_mismatch` and `invalid` are one today. It returns a contended reveal (a race with the close fence, `SealedBallotService.cs:186-189`) as `WrongPhase` `409`, the same type as a reveal outside the Reveal phase, so `contended` and `wrong_phase` are one today. A client cannot tell "send it again" from "too late", or "wrong ballot" from "malformed body", until Bifrost splits each pair.
|
||||
|
||||
## Commitment
|
||||
|
||||
`commitment = lowercase hex SHA-256 of the UTF-8 bytes of`
|
||||
|
||||
gamecult.sealed_ballot.v0 \n {motionId} \n {voterUserAccountId} \n {persona} \n {round} \n {candidate} \n {reason} \n {salt}
|
||||
|
||||
with `\n` U+000A, ids in `D` format, `round` in decimal, `persona` empty for a member. This is Bifrost's formula as the code has it (`SealedBallotService.cs:154, 385-387`); `docs/sealed-ballot-motion.md:22-23` omits `{persona}` and is stale. The vectors give the canonical bytes (`canonicalHex`) and the digest for a member, a Mind, non-ASCII text, and a decomposed-versus-precomposed pair (no normalisation: they are different ballots). `pairs` pins what a reveal must match: the right ballot matches; a changed reason, another voter id, or a dropped persona does not, and the answer is `commitment_mismatch`.
|
||||
|
||||
## Receipts the voter can check later
|
||||
|
||||
A cast returns the receipt. The voter checks it later in two places that must agree byte for byte with it: `motion.yourCommitment` on a motion read (a commit receipt is present from the commit; the revision counts re-commits), and, after close, the `commitment` the result lists for them. The contract adds no receipt id or signature: the receipt is the commitment, its revision and the time (open choice 6).
|
||||
|
||||
## Personas
|
||||
|
||||
A Persona votes only on sealed motions, through a Mind key (Bifrost binds an Ed25519 public key 1:1 to an Active member account). The ballot's `persona` is the bound persona id and the commitment covers it, so a Mind's ballot cannot be replayed as a member's (`pairs`: dropping the persona does not match). The Mind-intake route today takes a signed `MindEnvelope` anonymously; this contract does not model that envelope, nonce or signature (open choice 7). Standard motions have no Mind path.
|
||||
|
||||
## Authentication
|
||||
|
||||
On the wire the credential is an opaque `Authorization: Bearer` header. The token route, claims, scopes and audience belong to Heimdall and Bifrost and are not designed here. A request without a usable bearer is answered `unauthenticated` as a typed refusal.
|
||||
|
||||
**Auth per route, now and after #37.** Routes are Bifrost's; this contract fixes the documents, not the paths (open choice 3).
|
||||
|
||||
| exchange | today (`/api/sealed-motions`, facts file 3c) | after #37 (`/api/votes`) |
|
||||
|---|---|---|
|
||||
| motion list | no route | bearer, scope `votes.read` |
|
||||
| motion read | session cookie + Active member | bearer, scope `votes.read` |
|
||||
| commit, reveal | session cookie + Active member | bearer, scope `votes.cast`, audience `bifrost-votes`; a high-stakes commit also needs a fresh sign-in |
|
||||
| Mind cast | anonymous; the Ed25519 signature on a `MindEnvelope` is the credential | unchanged: Minds never use `/api/votes` (#37) |
|
||||
| open a motion, Mind-key registry | session cookie + moderator | unchanged, cookie only |
|
||||
|
||||
Today a member route has **no bearer scheme**: it accepts the host-only `__Host-bifrost_session` cookie and answers an unauthenticated call with a redirect to the sign-in page, not a 401. After #37 `/api/votes` is bearer-only and answers a typed 401. The wire header stays an opaque bearer either way; only Bifrost knows which of the two it is checking.
|
||||
|
||||
## PR #37 (Bifrost `docs/ratatoskr-voting-auth.md`, head `447a7cb`)
|
||||
|
||||
Checked 2026-11-10 by an Eyes pass over the PR and the schema; conflicts for Heimdall:
|
||||
|
||||
1. **Standard motions.** The PR serves them (`PUT /api/votes/motions/{id}/ballot {choice, comment?}`, a receipt `{motionId, choice, revision, castAtUtc, receiptId}`); the contract is sealed-only and answers `not_found` for a Standard id.
|
||||
2. **Verb model.** The PR keeps `POST .../commit` and `.../reveal` and adds `GET .../result`; the contract has one `cast` document and no result document.
|
||||
3. **List and read as GET.** The PR names GET routes; the contract's request documents carry a `phase` filter and a `motionId`. The mapping (query string and path) is undefined until Heimdall fixes the routes.
|
||||
4. **Refusals.** The PR lists 401/403/404/409/429 with `WWW-Authenticate`; the contract adds 400 (`invalid`, `commitment_mismatch`) and 413, and a typed body.
|
||||
5. **Minds.** The PR says Minds never use `/api/votes` and keeps the anonymous signed `/mind` route; the contract carries `persona` in the ballot and a `forbidden` case for a Mind key on a route it may not call. The PR's token is never a Persona's.
|
||||
6. **Authority.** The contract's `unauthenticated` says a bearer "Heimdall does not know"; the PR makes Bifrost the OIDC issuer (a second client, scopes `votes.read` and `votes.cast`, audience `bifrost-votes`) and notes that Bifrost OIDC is not live (its D7).
|
||||
7. **Rate limits.** The PR keys per account (30 casts per 10 minutes); the contract now says per account too, and neither exists on the member API yet (above).
|
||||
8. **Wire format.** The PR's errors are JSON; the contract's vectors are MessagePack. Nothing settles the media type.
|
||||
9. **The PR's seven open decisions** (consent for `votes.cast`, re-authentication, no refresh token, grant revocation, a Ratatoskr brake, rate numbers, MAS rollout order) assume nothing the contract contradicts. D2 (re-authentication at cast) is answered yes for sealed commit in this contract: `reauthentication_required`, with `highStakes` fixed at open. D5 (a brake) is `held`.
|
||||
|
||||
## Open choices for Heimdall
|
||||
|
||||
1. Standard motions in v0 or a v1 (the PR already serves them).
|
||||
2. Pagination and filters on the list.
|
||||
3. One `cast` route or the two existing ones; `verb` redundancy.
|
||||
4. Wire format: MessagePack documents with a JSON export, or JSON only (the PR is JSON).
|
||||
5. Refuse untrimmed `candidate` and `reason` (this contract) or keep trimming server-side.
|
||||
6. Whether a receipt should carry an id or a server signature so a voter can prove it to a third party.
|
||||
7. The Mind path: keep the signed envelope on its own route, or take Mind keys as a bearer credential.
|
||||
8. `reason` length in UTF-16 code units (as Bifrost) or code points.
|
||||
9. Whether the result document (`GET .../result`) joins this contract.
|
||||
420
contracts/cultnet/governance/vote-api.schema.json
Normal file
420
contracts/cultnet/governance/vote-api.schema.json
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/governance/vote-api.schema.json",
|
||||
"title": "CultNet vote API v0 (sealed-ballot motions)",
|
||||
"description": "Typed documents for listing, reading and casting on sealed-ballot motions, and the receipt a cast returns. Map-mode: every key is written, nil where absent. Spec: vote-api.md. Vectors: vote-api-vectors.json.",
|
||||
"$defs": {
|
||||
"uuid": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
"instant": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,7})?Z$"
|
||||
},
|
||||
"hex64": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"trimmedLine": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^(?![\\t-\\r \\u0085 -
])[^\\n\\r\\u0085
]+(?<![\\t-\\r \\u0085 -
])$"
|
||||
},
|
||||
"ballot": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema",
|
||||
"motionId",
|
||||
"round",
|
||||
"candidate",
|
||||
"reason",
|
||||
"salt",
|
||||
"persona"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "gamecult.sealed_ballot.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"round": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"candidate": {
|
||||
"$ref": "#/$defs/trimmedLine"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 280,
|
||||
"pattern": "^(?![\\t-\\r \\u0085 -
])[^\\n\\r\\u0085
]+(?<![\\t-\\r \\u0085 -
])$"
|
||||
},
|
||||
"salt": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{32,128}$"
|
||||
},
|
||||
"persona": {
|
||||
"type": "string",
|
||||
"pattern": "^[!-~]{0,64}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionListRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"phase"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.motion_list_request.v0"
|
||||
},
|
||||
"phase": {
|
||||
"oneOf": [
|
||||
{
|
||||
"enum": [
|
||||
"Commit",
|
||||
"Reveal",
|
||||
"Closed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionListItem": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"title",
|
||||
"round",
|
||||
"phase",
|
||||
"commitClosesAtUtc",
|
||||
"closesAtUtc",
|
||||
"highStakes"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"round": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"phase": {
|
||||
"enum": [
|
||||
"Commit",
|
||||
"Reveal",
|
||||
"Closed"
|
||||
]
|
||||
},
|
||||
"commitClosesAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"closesAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"highStakes": {
|
||||
"type": "boolean",
|
||||
"description": "Fixed when the motion opens and never changed after: a high-stakes sealed motion needs a fresh sign-in at commit (Bifrost PR #37, D2)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionList": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motions"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.motion_list.v0"
|
||||
},
|
||||
"motions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/motionListItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionReadRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motionId"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.motion_read_request.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"receipt": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motionId",
|
||||
"voterUserAccountId",
|
||||
"commitment",
|
||||
"revision",
|
||||
"committedAtUtc",
|
||||
"revealed"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.receipt.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"voterUserAccountId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"commitment": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"committedAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"revealed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"id",
|
||||
"title",
|
||||
"summary",
|
||||
"round",
|
||||
"priorMotionId",
|
||||
"ballotSchema",
|
||||
"phase",
|
||||
"candidates",
|
||||
"commitClosesAtUtc",
|
||||
"closesAtUtc",
|
||||
"highStakes",
|
||||
"yourVoterId",
|
||||
"yourCommitment",
|
||||
"announcement"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.motion.v0"
|
||||
},
|
||||
"id": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"round": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"priorMotionId": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ballotSchema": {
|
||||
"const": "gamecult.sealed_ballot.v0"
|
||||
},
|
||||
"phase": {
|
||||
"enum": [
|
||||
"Commit",
|
||||
"Reveal",
|
||||
"Closed"
|
||||
]
|
||||
},
|
||||
"candidates": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"$ref": "#/$defs/trimmedLine"
|
||||
}
|
||||
},
|
||||
"commitClosesAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"closesAtUtc": {
|
||||
"$ref": "#/$defs/instant"
|
||||
},
|
||||
"highStakes": {
|
||||
"type": "boolean",
|
||||
"description": "Fixed when the motion opens and never changed after: a high-stakes sealed motion needs a fresh sign-in at commit (Bifrost PR #37, D2)."
|
||||
},
|
||||
"yourVoterId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"yourCommitment": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/receipt"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"announcement": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"commitCast": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motionId",
|
||||
"verb",
|
||||
"commitment",
|
||||
"ballot"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.cast.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"verb": {
|
||||
"const": "commit"
|
||||
},
|
||||
"commitment": {
|
||||
"$ref": "#/$defs/hex64"
|
||||
},
|
||||
"ballot": {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
"revealCast": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"motionId",
|
||||
"verb",
|
||||
"commitment",
|
||||
"ballot"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.cast.v0"
|
||||
},
|
||||
"motionId": {
|
||||
"$ref": "#/$defs/uuid"
|
||||
},
|
||||
"verb": {
|
||||
"const": "reveal"
|
||||
},
|
||||
"commitment": {
|
||||
"type": "null"
|
||||
},
|
||||
"ballot": {
|
||||
"$ref": "#/$defs/ballot"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cast": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/commitCast"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/revealCast"
|
||||
}
|
||||
]
|
||||
},
|
||||
"refusal": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"code",
|
||||
"error",
|
||||
"retryAfterSeconds"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": "gamecult.vote.refusal.v0"
|
||||
},
|
||||
"code": {
|
||||
"enum": [
|
||||
"unauthenticated",
|
||||
"forbidden",
|
||||
"reauthentication_required",
|
||||
"not_found",
|
||||
"wrong_phase",
|
||||
"contended",
|
||||
"invalid",
|
||||
"commitment_mismatch",
|
||||
"payload_too_large",
|
||||
"rate_limited",
|
||||
"held"
|
||||
]
|
||||
},
|
||||
"error": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"retryAfterSeconds": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -149,6 +149,488 @@
|
|||
{ "name": "empty_tag_list", "set": { "tags": [] }, "outcome": "admitted" }
|
||||
]
|
||||
},
|
||||
"admittedMessageId": {
|
||||
"semantics": "Decoder-side rule for a `cultnet.write_admitted.v0` frame's `messageId` (Heimdall's ruling, write-receipt.md). Valid only if it contains at least one Unicode letter (\\p{L}) or number (\\p{N}) AND no Default_Ignorable_Code_Point (DerivedCoreProperties). The compare is RAW: the id is echoed byte for byte, so a decoder neither trims nor normalizes. Each case's id is given as `codePoints` (hex, in order), repeated `repeat` times (default 1), so no lone surrogate appears in this file. Every messageId key of a frame must be valid. A runner builds the frame {schemaVersion write_admitted.v0, messageId, operation put, schemaId note.v0, recordKey note:1} with its own encoder and decodes it with the write_admitted decoder; `refuse` means the decoder refuses the frame the way it refuses an empty or ill-typed messageId, `accept` means it decodes and the id comes back identical. A case with `wireHex` carries the whole frame in MessagePack hex, for an id a language string or this file cannot hold (a lone surrogate is not valid UTF-8; the bytes are ED A0 80) or a frame that repeats a key. The rule is on the decoded string, which must be well-formed Unicode: a lone surrogate refuses. A runtime whose MessagePack reader rejects invalid UTF-8 refuses earlier; byte-level strictness is a property of the reader and is not required of a decoder. Scope: write_admitted only; error.v1 is untouched.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "refuse_braille_blank_2800",
|
||||
"codePoints": [
|
||||
"2800"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+2800 is Default_Ignorable and not a letter or number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_hangul_filler_3164",
|
||||
"codePoints": [
|
||||
"3164"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+3164 Default_Ignorable (a letter by category Lo, so DI decides)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_hangul_choseong_filler_115F",
|
||||
"codePoints": [
|
||||
"115F"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+115F Default_Ignorable (category Lo)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_halfwidth_hangul_filler_FFA0",
|
||||
"codePoints": [
|
||||
"FFA0"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+FFA0 Default_Ignorable (category Lo)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_combining_grapheme_joiner_034F",
|
||||
"codePoints": [
|
||||
"034F"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+034F Default_Ignorable"
|
||||
},
|
||||
{
|
||||
"name": "refuse_combining_mark_alone_0300",
|
||||
"codePoints": [
|
||||
"0300"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a combining mark is not a letter or number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_private_use_E000",
|
||||
"codePoints": [
|
||||
"E000"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "private use is neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_unassigned_0378",
|
||||
"codePoints": [
|
||||
"0378"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "unassigned since Unicode 1.1, neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_lone_surrogate_D800",
|
||||
"codePoints": [
|
||||
"D800"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a lone surrogate is not a Unicode scalar value and has no letter or number; on the wire it is the invalid UTF-8 bytes ED A0 80 in a str. A reader that rejects invalid UTF-8 refuses the frame; one that substitutes or keeps the surrogate refuses the id",
|
||||
"wireHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A3EDA080A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"
|
||||
},
|
||||
{
|
||||
"name": "refuse_control_nul_0000",
|
||||
"codePoints": [
|
||||
"0000"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a control character is neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_control_bel_0007",
|
||||
"codePoints": [
|
||||
"0007"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a control character is neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_control_unit_separator_001F",
|
||||
"codePoints": [
|
||||
"001F"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a control character is neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_control_delete_007F",
|
||||
"codePoints": [
|
||||
"007F"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a control character is neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_control_next_line_0085",
|
||||
"codePoints": [
|
||||
"0085"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a control character is neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_empty",
|
||||
"codePoints": [],
|
||||
"expect": "refuse",
|
||||
"why": "empty"
|
||||
},
|
||||
{
|
||||
"name": "refuse_whitespace_only_space_0020",
|
||||
"codePoints": [
|
||||
"0020"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "white space only"
|
||||
},
|
||||
{
|
||||
"name": "refuse_whitespace_only_nbsp_00A0",
|
||||
"codePoints": [
|
||||
"00A0"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "white space only"
|
||||
},
|
||||
{
|
||||
"name": "refuse_whitespace_only_ideographic_space_3000",
|
||||
"codePoints": [
|
||||
"3000"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "white space only"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_zwsp_letter",
|
||||
"codePoints": [
|
||||
"0061",
|
||||
"200B",
|
||||
"0062"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a Default_Ignorable (U+200B) beside letters"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_cgj",
|
||||
"codePoints": [
|
||||
"0061",
|
||||
"034F"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "a Default_Ignorable (U+034F) after a letter"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_soft_hyphen",
|
||||
"codePoints": [
|
||||
"0061",
|
||||
"00AD"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+00AD is Default_Ignorable"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_zwj",
|
||||
"codePoints": [
|
||||
"0061",
|
||||
"200D"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+200D is Default_Ignorable"
|
||||
},
|
||||
{
|
||||
"name": "refuse_digit_mongolian_vowel_separator",
|
||||
"codePoints": [
|
||||
"0031",
|
||||
"180E"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+180E is Default_Ignorable since Unicode 6.3"
|
||||
},
|
||||
{
|
||||
"name": "refuse_digit_word_joiner_digit",
|
||||
"codePoints": [
|
||||
"0031",
|
||||
"2060",
|
||||
"0032"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+2060 is Default_Ignorable"
|
||||
},
|
||||
{
|
||||
"name": "refuse_bom_before_letter",
|
||||
"codePoints": [
|
||||
"FEFF",
|
||||
"0061"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+FEFF is Default_Ignorable; JavaScript trim() would strip it and accept the rest, but the compare is raw"
|
||||
},
|
||||
{
|
||||
"name": "accept_unicode16_gurung_khema_letter_16100",
|
||||
"codePoints": [
|
||||
"16100"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "U+16100 GURUNG KHEMA LETTER A is Lo and new in Unicode 16.0 (unassigned in 15.1); the rule is pinned to 16.0 in every runtime"
|
||||
},
|
||||
{
|
||||
"name": "accept_unicode16_garay_digit_zero_10D40",
|
||||
"codePoints": [
|
||||
"10D40"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "U+10D40 GARAY DIGIT ZERO is Nd and new in Unicode 16.0"
|
||||
},
|
||||
{
|
||||
"name": "accept_unicode15_1_cjk_ext_i_2EBF0",
|
||||
"codePoints": [
|
||||
"2EBF0"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "U+2EBF0, first of CJK Unified Ideographs Extension I, is Lo and new in Unicode 15.1"
|
||||
},
|
||||
{
|
||||
"name": "accept_last_cjk_ext_i_2EE5D",
|
||||
"codePoints": [
|
||||
"2EE5D"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "U+2EE5D is the last assigned code point of Extension I (Lo)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_after_cjk_ext_i_2EE5E",
|
||||
"codePoints": [
|
||||
"2EE5E"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+2EE5E is unassigned in Unicode 16.0, so neither a letter nor a number"
|
||||
},
|
||||
{
|
||||
"name": "refuse_unassigned_in_16_0_10D90",
|
||||
"codePoints": [
|
||||
"10D90"
|
||||
],
|
||||
"expect": "refuse",
|
||||
"why": "U+10D90 follows the Garay block and is unassigned in Unicode 16.0"
|
||||
},
|
||||
{
|
||||
"name": "refuse_duplicate_message_id_second_is_default_ignorable",
|
||||
"codePoints": ["0061"],
|
||||
"expect": "refuse",
|
||||
"why": "the frame carries messageId twice, \"a\" and then U+200B; every messageId key must be valid, and a reader that keeps the last one sees U+200B",
|
||||
"wireHex": "86AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31A96D6573736167654964A3E2808B"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_lone_surrogate_D800",
|
||||
"codePoints": ["0061"],
|
||||
"expect": "refuse",
|
||||
"why": "\"a\" followed by a lone surrogate: the id as decoded is not well-formed Unicode. A reader that rejects invalid UTF-8 refuses the frame earlier; one that substitutes or keeps the surrogate must still refuse the id. Byte-level strictness is a property of the reader, not required of the decoder",
|
||||
"wireHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A461EDA080A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"
|
||||
},
|
||||
{
|
||||
"name": "accept_astral_mathematical_bold_capital_a_1D400",
|
||||
"codePoints": ["1D400"],
|
||||
"expect": "accept",
|
||||
"why": "an astral letter (Lu)"
|
||||
},
|
||||
{
|
||||
"name": "accept_astral_cjk_ext_b_20000",
|
||||
"codePoints": ["20000"],
|
||||
"expect": "accept",
|
||||
"why": "an astral letter (Lo)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_061C",
|
||||
"codePoints": ["0061", "061C"],
|
||||
"expect": "refuse",
|
||||
"why": "U+061C is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_17B4",
|
||||
"codePoints": ["0061", "17B4"],
|
||||
"expect": "refuse",
|
||||
"why": "U+17B4 is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_17B5",
|
||||
"codePoints": ["0061", "17B5"],
|
||||
"expect": "refuse",
|
||||
"why": "U+17B5 is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_202A",
|
||||
"codePoints": ["0061", "202A"],
|
||||
"expect": "refuse",
|
||||
"why": "U+202A is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_202E",
|
||||
"codePoints": ["0061", "202E"],
|
||||
"expect": "refuse",
|
||||
"why": "U+202E is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_FE00",
|
||||
"codePoints": ["0061", "FE00"],
|
||||
"expect": "refuse",
|
||||
"why": "U+FE00 is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_FE0F",
|
||||
"codePoints": ["0061", "FE0F"],
|
||||
"expect": "refuse",
|
||||
"why": "U+FE0F is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_FFF0",
|
||||
"codePoints": ["0061", "FFF0"],
|
||||
"expect": "refuse",
|
||||
"why": "U+FFF0 is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_FFF8",
|
||||
"codePoints": ["0061", "FFF8"],
|
||||
"expect": "refuse",
|
||||
"why": "U+FFF8 is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_1BCA0",
|
||||
"codePoints": ["0061", "1BCA0"],
|
||||
"expect": "refuse",
|
||||
"why": "U+1BCA0 is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_1BCA3",
|
||||
"codePoints": ["0061", "1BCA3"],
|
||||
"expect": "refuse",
|
||||
"why": "U+1BCA3 is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_1D173",
|
||||
"codePoints": ["0061", "1D173"],
|
||||
"expect": "refuse",
|
||||
"why": "U+1D173 is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_1D17A",
|
||||
"codePoints": ["0061", "1D17A"],
|
||||
"expect": "refuse",
|
||||
"why": "U+1D17A is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_E0000",
|
||||
"codePoints": ["0061", "E0000"],
|
||||
"expect": "refuse",
|
||||
"why": "U+E0000 is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "refuse_letter_then_default_ignorable_E0FFF",
|
||||
"codePoints": ["0061", "E0FFF"],
|
||||
"expect": "refuse",
|
||||
"why": "U+E0FFF is Default_Ignorable (an end of one of its ranges)"
|
||||
},
|
||||
{
|
||||
"name": "accept_abc",
|
||||
"codePoints": [
|
||||
"0061",
|
||||
"0062",
|
||||
"0063"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "letters"
|
||||
},
|
||||
{
|
||||
"name": "accept_digit_1",
|
||||
"codePoints": [
|
||||
"0031"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "a number"
|
||||
},
|
||||
{
|
||||
"name": "accept_padded_a_raw_compare",
|
||||
"codePoints": [
|
||||
"0020",
|
||||
"0061",
|
||||
"0020"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "raw compare: the id is echoed byte for byte, never trimmed, so the padding stays and the letter makes it valid"
|
||||
},
|
||||
{
|
||||
"name": "accept_e_acute_precomposed",
|
||||
"codePoints": [
|
||||
"00E9"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "a letter"
|
||||
},
|
||||
{
|
||||
"name": "accept_arabic_indic_digit_three",
|
||||
"codePoints": [
|
||||
"0663"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "category Nd, a number"
|
||||
},
|
||||
{
|
||||
"name": "accept_cjk_zhong",
|
||||
"codePoints": [
|
||||
"4E2D"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "a letter (Lo, not Default_Ignorable)"
|
||||
},
|
||||
{
|
||||
"name": "accept_letter_then_combining_acute",
|
||||
"codePoints": [
|
||||
"0065",
|
||||
"0301"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "a combining mark after a letter is allowed"
|
||||
},
|
||||
{
|
||||
"name": "accept_letter_nul_letter",
|
||||
"codePoints": [
|
||||
"0061",
|
||||
"0000",
|
||||
"0062"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "U+0000 is not Default_Ignorable and a letter is present, so the rule accepts; raw compare keeps the NUL"
|
||||
},
|
||||
{
|
||||
"name": "accept_256_ascii_letters",
|
||||
"codePoints": [
|
||||
"0061"
|
||||
],
|
||||
"expect": "accept",
|
||||
"why": "256 bytes is the size limit and is allowed",
|
||||
"repeat": 256
|
||||
}
|
||||
]
|
||||
},
|
||||
"admittedMessageIdDuplicates": {
|
||||
"semantics": "A frame with more than one messageId key is malformed. A decoder that can see the duplicate (C#, Rust: raw MessagePack reader or ordered pairs) refuses it, whatever the values (`seesDuplicate`). A decoder that collapses the map (Python, TypeScript, Kotlin) judges the value it ends up with, the last key, and refuses exactly when that value is invalid (`lastKeyWins`); where it accepts it echoes the last key's id (`lastKeyWinsEcho`). Neither behaviour ever echoes an invalid id. Each runtime declares which policy it has and its runner asserts that policy's column, so a change of a decoder's key policy fails a named test. The first key is ZWSP (U+200B, Default_Ignorable) in `invalid_then_valid`; the second key is the last entry of the map. Frames are MessagePack hex.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "invalid_then_valid",
|
||||
"wireHex": "86AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A3E2808BA96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31A96D6573736167654964A161",
|
||||
"seesDuplicate": "refuse",
|
||||
"lastKeyWins": "accept",
|
||||
"lastKeyWinsEcho": "a"
|
||||
},
|
||||
{
|
||||
"name": "valid_then_invalid",
|
||||
"wireHex": "86AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A161A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31A96D6573736167654964A3E2808B",
|
||||
"seesDuplicate": "refuse",
|
||||
"lastKeyWins": "refuse"
|
||||
},
|
||||
{
|
||||
"name": "valid_then_valid",
|
||||
"wireHex": "86AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A161A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31A96D6573736167654964A162",
|
||||
"seesDuplicate": "refuse",
|
||||
"lastKeyWins": "accept",
|
||||
"lastKeyWinsEcho": "b"
|
||||
}
|
||||
]
|
||||
},
|
||||
"schemaInvalid": [
|
||||
{
|
||||
"name": "write_admitted_with_an_error_key",
|
||||
|
|
@ -1182,7 +1664,9 @@
|
|||
"wire": 7,
|
||||
"schemaInvalid": 7,
|
||||
"contentHashCases": 4,
|
||||
"scenarioCases": 25
|
||||
"scenarioCases": 25,
|
||||
"admittedMessageIdCases": 59,
|
||||
"admittedMessageIdDuplicateCases": 3
|
||||
},
|
||||
"contentHash": {
|
||||
"definition": "sha256(msgpack([operation, schemaId, recordKey, payload])), payload a msgpack bin for a put and nil for a delete; hex lowercase",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ A put or delete today has two answers on the wire: nothing (admitted, or the ser
|
|||
|
||||
**`cultnet.write_admitted.v0`**: `{ schemaVersion, messageId, operation: "put"|"delete", schemaId, recordKey }`, every key written, none nil. Sent once a put or delete is admitted. The id echoes the write's `messageId` byte for byte; `operation`, `schemaId`, `recordKey` echo the write.
|
||||
|
||||
**A `write_admitted` `messageId` is valid only if it contains at least one Unicode letter (`\p{L}`) or number (`\p{N}`) and no Default_Ignorable_Code_Point (DerivedCoreProperties).** The compare is raw: the id is echoed byte for byte, so a decoder never trims or normalizes it (`" a "` is valid; U+2800 is not, and neither is `a` followed by U+200B). Unlike the Required fields rule, this is not a White_Space test. The letters, numbers and Default_Ignorable code points are those of **Unicode 16.0** in every runtime, taken from tables embedded in each runtime and generated by `scripts/gen-unicode-messageid-tables.py` from `DerivedGeneralCategory.txt` (Lu Ll Lt Lm Lo Nd Nl No) and `DerivedCoreProperties.txt`; no runtime asks its platform's Unicode data, so a runtime's own Unicode version cannot change a result. Bumping the version is one coordinated change: regenerate the five tables and update the vectors that pin the version in the same commit. The rule is on the decoded string, which must be well-formed Unicode: a lone surrogate refuses. A runtime whose MessagePack reader rejects invalid UTF-8 refuses the frame earlier; byte-level strictness is a property of the reader and is not required of a decoder. A frame with more than one `messageId` key is malformed: a decoder that can see the duplicate (the C# and Rust decoders) refuses it; a decoder that collapses the map (Python, TypeScript, Kotlin) judges the value it ends up with, the last key, and refuses if that value is invalid, so no decoder echoes an invalid id (vectors `admittedMessageIdDuplicates` declare each policy and every runner asserts its own). A decoder refuses an invalid id the way it refuses an empty or ill-typed one; the rule is for `write_admitted` only and `error.v1` is unchanged.
|
||||
|
||||
**Where a received `messageId` decides, and what that relies on.** Beyond echoing it, a received `messageId` is used in exactly two places. On a server, the remembered-write key `(actor, messageId)` with the content hash, the per-session limit and the replay of rule 5 are taken from the id of the write frame the server itself parsed. On a client, the write tracker attributes a `write_admitted` or `error.v1` to a write by equality with the id of a write that client itself sent (a frame naming an id not in flight settles nothing), and attributes an id-less error by the count of unanswered writes, not by any id. Both rely on the receiver being the only parser of that frame: a decoder's `messageId` on a frame that carries more than one `messageId` key is not trusted for any authorization, dedupe, replay or ordering decision, and the map-collapsing runtimes (Python, TypeScript, Kotlin, and C#'s typed deserializer on write frames) cannot see the duplicate. If a frame is ever parsed by two runtimes in a chain (a relay, proxy or forwarder that re-reads it), or a consumer needs `messageId` for authorization, dedupe, replay or ordering beyond echo and equality with a self-sent id, all runtimes must first refuse a duplicate `messageId` key (a spec change plus byte-level duplicate detection in the runtimes that decode to a map) before that use lands. Today Rust refuses a repeated key on write frames and on `write_admitted` (serde's `duplicate field messageId`, and `refuse_duplicate_keys` for the raw-list frames such as `document_put_raw`), though its `readable_message_id` names the first key when it names an unreadable frame; C# does not on write frames (a duplicate-key write is deduped on its last id, while the id it names in a refusal for an unreadable frame is read from the first key), and the Python server (`cultmesh_py/server.py`) only echoes the id while the TypeScript and Kotlin code matches it by equality with a request it sent.
|
||||
|
||||
**`cultnet.error.v1`**: `cultnet.error.v0` plus a required `messageId` (string or nil). The code enum is v0's plus `limit_reached` (see Remembered-write limits); `cultnet.error.v1.schema.json` is the authority for the closed set. Sent in place of `error.v0` to a receipt-capable connection. `messageId` is the id of the request the error answers; nil only when the frame was unreadable or carried no id.
|
||||
|
||||
Why a new error version and not an extra key on v0: the v0 schema is closed (`additionalProperties: false`) and the TypeScript decoder (Ajv over the closed schemas) rejects unknown keys and unknown `schemaVersion`s, and `parseCultNetMessage` throws on an unknown version; Rust's decoder errors on an unknown version too. Unknown-key tolerance of the Rust, Python and Kotlin error decoders was not checked, so none is assumed, so a surprise key or frame breaks an old client's whole receive path. Old clients are therefore never sent either new frame. This reverses the "old clients ignore it" assumption, which is false for today's decoders.
|
||||
|
|
@ -38,7 +42,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir
|
|||
- **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session.
|
||||
- **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A put or delete that is present but names nothing is not "missing": the write is refused `message_invalid` exactly as a frame that does not read, with `details.field` `"message"`, the fixed unreadable text and the write's id (vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`), and is never applied; a server must not hand it to its store. Names nothing means, on a put or delete, a `schemaId` or `recordKey` that is blank; on a put's document, also a `storedAt` that is blank, a `payload` of zero bytes (a MessagePack value is at least one byte, so an empty payload is no document), a `sourceRuntimeId`, `sourceAgentId` or `sourceRole` that is present and blank, and a `tags` entry that is blank. **Blank** means empty or made only of Unicode White_Space code points (the PropList property) and nothing else, in every runtime, whatever its platform's own whitespace test says. The set is exactly U+0009 to U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000 to U+200A, U+2028, U+2029, U+202F, U+205F and U+3000. U+200B, U+200C, U+200D, U+2060, U+FEFF, U+180E and U+001C to U+001F are not White_Space (some platforms' whitespace tests accept U+001C to U+001F or U+180E, for example Java's `Character.isWhitespace` and older .NET, so a runtime must check the list, not its platform test): a string of them names something. The vectors list both sets (`whiteSpaceCodePoints`, `notWhiteSpaceCodePoints`) and a runtime checks every code point in every covered field. Absent or nil source ids and tags, and an empty `tags` list, are valid. The rule holds on every connection, receipt-capable or not (a legacy connection is refused with the `error.v0` of the same code, details and text); the vectors' `blankFields` section lists each case.
|
||||
- **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: the message could not be read` (a frame that does not decode, or a write that names nothing; the text is the same for both and never carries a decoder's own wording), `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`.
|
||||
- **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once.
|
||||
- **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once. The server refuses a sequenced write whenever it can read the write's id: from the delivered frame, and from a superseded packet that carries its whole frame. A write sent as several fragments whose frame is superseded is dropped whole and may go unanswered; the client's unconfirmed outcome covers it. A repeat of a packet the server already took, or a packet too far ahead to be taken, draws no further answer (rule 1).
|
||||
- Past the session (or a server's longer retention, if it keeps one) a resend is a new write.
|
||||
6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server relays nothing: no receipt, no error, no synthesized refusal. The client's own rule below then reports the write as unconfirmed. A forwarder never turns an absent or ambiguous primary answer into an admission.
|
||||
7. **Looks alike.** An admitted and a refused write differ in `schemaVersion`; a receipt has no `error`, an `error.v1` has no admission. A transport ack is neither.
|
||||
|
|
@ -65,7 +69,7 @@ Those clients complete *successfully* when the window ends with no refusal. Unde
|
|||
|
||||
## Vectors
|
||||
|
||||
`write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `blankFields` (server input cases for the Required fields rule: which document fields make a put unreadable, which are accepted), `scenarios` (event replays with the outcome each write must settle as; the clock is abstract; each case names its window constant). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7.
|
||||
`write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `blankFields` (server input cases for the Required fields rule: which document fields make a put unreadable, which are accepted), `admittedMessageId` (the `write_admitted` id rule above: code-point lists, each marked refuse or accept; a lone surrogate, and a frame that repeats the key, are carried as whole frames in `wireHex`, because a lone surrogate is not valid UTF-8 and only some runtimes' strings can hold it; `write-admitted-message-id-ranges.json` lists every range of the embedded tables, and each runtime probes the first and last point of each range and the points either side), `scenarios` (event replays with the outcome each write must settle as; the clock is abstract; each case names its window constant). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7.
|
||||
|
||||
## Conformance status
|
||||
|
||||
|
|
@ -78,6 +82,8 @@ Those clients complete *successfully* when the window ends with no refusal. Unde
|
|||
|
||||
Advertising is per server class, not per transport. A class advertises both frames in its hello only if it implements the receipt rules end to end; the C# RUDP schema server stays "no" until it answers writes with receipts.
|
||||
|
||||
Cross-runtime check: `packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts` (`npm run test:interop` in `packages/cultmesh-ts`) runs the TypeScript client (`WriteTracker`, the decoder, the one-shot writer) against the real Rust RUDP document server and the real `CultNetDatabaseServer` over loopback, one scenario set against both. Known gap it records as a todo: the Rust RUDP document server refuses every delete (`write_not_authorized: the server does not take deletes`), where this spec has an admitted delete receipted like a put.
|
||||
|
||||
## Conformance follow-ups (not in this PR)
|
||||
|
||||
C# server and client, Rust, TypeScript, Python, Kotlin: emit and parse the two frames; hello capability; tri-state client outcome; replace the #55 to #58 window-success; re-capture the wire vectors in each runtime. Server write paths include the shard write forwarder (rule 6) and the Odin catalog.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ Data for the per-consumer sweep in docs/document-variants-cut.md, C2. Every obje
|
|||
|
||||
Method: a static scan of `F:\Projects\<consumer>` C# files for types marked `[CultDocument(`, walked through keyed members, base classes, dictionary values and `[Union]` subtypes. Read-only; no consumer was edited. Worktree copies (`Aetheria-catalog`, `CultLib-*`, and so on) are excluded. Struct elements are values and need no id. Non-C# consumers carry the id as an ordinary member and are not listed. The scan is text-based: a document registered without a literal `[CultDocument(` attribute, or a type whose members lack an integer `[Key]`, is missed, so the sweep pass should confirm each repo by registering its types once.
|
||||
|
||||
A type that already has a readable string id keeps it and adds a trailing keyed string member marked `[CultElementId(nameof(ExistingId))]`; see the Element ids section of `src/GameCult.Caching/README.md`.
|
||||
|
||||
Lines: `Type file:line (via Owner.Member)`, paths relative to the consumer root.
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -27,3 +27,11 @@ Install the Unity package from the immutable release tag:
|
|||
```text
|
||||
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0
|
||||
```
|
||||
|
||||
## Rolling back across the store format
|
||||
|
||||
A consumer that upgrades and writes a store holding an element id leaves a
|
||||
`cultcache.store.v3` single-file store (`v5` directory store). Refusal by name depends on the store kind and the pin. A **directory** store (`v5`) is refused by every Unity pin from `cultlib-unity-v1.0.57` and `caching-unity-v1.2.0` on (the earliest tags of each line whose `DirectoryMessagePackBackingStore` reads only the `v4` stamp: "only ... is readable"). A **single-file** store (`v3`) is refused only from `cultlib-unity-v2.0.0` and `caching-unity-v2.0.0`: `cultlib-unity-v1.0.61` and `caching-unity-v1.4.0` read the header string and never compare it. The NuGet line (`cultlib-v1.0.32`, the last `cultlib-v` tag) refuses neither: its directory store writes `cultcache.store.v1.directory` and never checks the stamp on read. Rolling back a single-file store to any of those pins reads it silently, drops the ids, and can rewrite the elements without them. That is the dangerous case. There is
|
||||
no general carry-back; restore from a copy taken before upgrading, and upgrade
|
||||
readers before writers. Detail: the Store format and rollback section of
|
||||
`src/GameCult.Caching/README.md`.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Cross-runtime parity has three layers:
|
|||
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
|
||||
Python and Rust read it and keep the header, and the C# runtime decides the mark from content, and these runtimes never drop it. A C# reader from `cultlib-unity-v2.0.0` on refuses a v3 single-file store it cannot read (earlier pins do not)
|
||||
(vectors: `tests/vectors/document-variants-c2a`).
|
||||
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
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ using MessagePack;
|
|||
public sealed class Item
|
||||
{
|
||||
[Key(0)] [CultName] public string Name = string.Empty;
|
||||
[Key(1)] [CultIndex] public string Category = string.Empty;
|
||||
[Key(1)] public string Category = string.Empty;
|
||||
[Key(2)] public int Value;
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +94,6 @@ const itemDocument = defineDocumentType({
|
|||
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);
|
||||
|
||||
|
|
@ -168,8 +167,8 @@ fn first_call() -> anyhow::Result<()> {
|
|||
|
||||
## 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
|
||||
A document is a definition with an encoder and decoder; `name=` registers the name lookup, `indexes=` unique lookups and
|
||||
`roles=` many-row selection aliases. 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
|
||||
|
|
@ -195,7 +194,6 @@ item_doc = define_document_type(
|
|||
encode=lambda item: asdict(item),
|
||||
decode=lambda raw: Item(**raw),
|
||||
name="name",
|
||||
indexes={"category": "category"},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ Recovered from the legacy "Database Tools" window (Aetheria `d3db1730`,
|
|||
**The attribute is not `[CultIndex]`.** `[CultIndex]` means unique (operator, 2026-09-30;
|
||||
`CultDocumentContracts.cs:34`). Grouping is `CultInspectorGroupByAttribute`, in
|
||||
`CultInspectorAttributes.cs`, read only by `CultInspectorModel`. It changes no schema id,
|
||||
catalog entry, index, or store byte. The index cut's deletion of grouping-only `[CultIndex]`
|
||||
declarations (CultMesh, Geometry) adds no `[CultInspectorGroupBy]` in their place: those are
|
||||
service stores, not Studio-authored catalogs.
|
||||
catalog entry, index, or store byte. The index cut converts the grouping and selection `[CultIndex]`
|
||||
declarations (CultMesh, Geometry) to `[CultRole]` rather than deleting them, and adds no
|
||||
`[CultInspectorGroupBy]` in their place: those are service stores, not Studio-authored catalogs.
|
||||
|
||||
### Invariants
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@
|
|||
|
||||
### 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.
|
||||
- `indexes` are unique, as C# `[CultIndex]`: a write, load or `register_index` that leaves two records of a type holding one non-blank value under one alias is refused (`CultCacheError`) naming both keys and the alias, and writes nothing. Index text is the C# canonical text (`True`/`False`, integers as digits, floats as the exact expansion, blank values never indexed), so an empty or whitespace string is no longer a value. A new `roles` map names the many-row selection aliases (never indexed for lookup, same catalog `index_alias`). Classify every `indexes` key per the README before updating. The verdict is the contract, the text is informational.
|
||||
|
||||
- A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading.
|
||||
- A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `push_all` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ store.
|
|||
- `register_document_type(...)`
|
||||
- `register_registry(...)`
|
||||
- `register_name_lookup(...)`
|
||||
- `register_index(...)`
|
||||
- `register_index(...)` (a unique index)
|
||||
- `add_backing_store(...)`
|
||||
- `add_generic_store(...)`
|
||||
- `pull_all_backing_stores()`
|
||||
|
|
@ -95,6 +95,33 @@ store.
|
|||
- `delete_global(...)`
|
||||
- `snapshot()`
|
||||
|
||||
## Indexes And Roles
|
||||
|
||||
`indexes=` are unique lookups (variants:ruling:unique-indexes; C# `[CultIndex]`): within one document type and one alias
|
||||
no two records hold one non-blank value, judged on the result of every write (a batch is judged whole, so a swap inside
|
||||
one `put_envelopes` lands) and of every load and `register_index`. A refusal is a `CultCacheError`,
|
||||
`Records <k1> and <k2> (<schema name>) both hold unique index '<alias>'; an index value names one record.`, keys in
|
||||
ordinal order, never the value; it writes nothing, and a refused load or registration installs nothing. The contract is
|
||||
the verdict; the text is informational. Blank values (absent, empty, or only C# `White_Space` characters) are never
|
||||
indexed and never conflict. Two types may share an alias and a value. Values compare as the C# canonical text: strings as
|
||||
is (never trimmed), `True`/`False`, integers as digits, a non-integral float as its exact decimal expansion, NaN and
|
||||
infinities no value.
|
||||
|
||||
`roles=` name selection aliases (C# `[CultRole]`): many records may share a value, nothing is indexed or refused,
|
||||
`get_by_index` on a role answers `None`, and the member's catalog `index_alias` is the same either way, so catalog bytes
|
||||
do not move. An alias is declared in one of the two. `CultMeshDatabase.watch_by_index` reads `indexes` only.
|
||||
|
||||
Migrating an existing `indexes` key, per key, before bumping:
|
||||
|
||||
1. Nothing looks records up by the key (`get_by_index`, `get_key_by_index`, `node.get_by_index`, `watch_by_index`): move it to `roles`.
|
||||
2. Something looks it up: it stays unique only when two live records can never hold one non-blank value (the record key
|
||||
is derived from that value alone, or it is an outside identity: email, sku, external id). Enums, statuses, kinds,
|
||||
categories, parent or foreign ids, shared hashes and timestamps are roles; select them with `get_all` plus a filter.
|
||||
3. Open each real `.cc` once with the new definitions. A refusal names both keys and the alias and writes nothing.
|
||||
|
||||
Misclassifying: a role declared unique makes a store that holds a shared value refuse to open until the key is
|
||||
reclassified; a unique key declared a role answers no lookups and stops refusing duplicates.
|
||||
|
||||
## Persistence Model
|
||||
|
||||
Backing stores persist envelopes, not caller domain objects. The cache decodes payloads through registered document definitions and rejects unknown type discriminators. This keeps Python's dynamic runtime from turning persistence into an open polymorphic sewer with a cheerful docstring.
|
||||
|
|
@ -112,6 +139,10 @@ used by the TypeScript, Rust, and C# runtimes:
|
|||
|
||||
Large corpora should use a sharded store or a real database. A single snapshot file is a scalpel, not a forklift.
|
||||
|
||||
## Store format and rollback
|
||||
|
||||
A store whose elements hold an id is marked `cultcache.store.v3` by the C# runtime. This runtime reads and writes a v3 single-file store, keeps the header it read and carries the ids through a rewrite; it never drops the mark, so it has no carry-back. A cultcache-py from before element ids refuses a v3 store by name, so rolling back to it after a store has been written as v3 is one-way: restore from a copy taken before upgrading, and upgrade every reader before any writer. (The C# line is different: CultLib 1.x does not refuse; see the GameCult.Caching README.)
|
||||
|
||||
## DatabaseEntry Slot Contract
|
||||
|
||||
For cross-runtime cache entries, use `define_database_entry_type(...)` instead
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import math
|
||||
import threading
|
||||
from decimal import Decimal
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, Callable, Generic, TypeVar
|
||||
from typing import Any, Callable, Generic, Iterable, TypeVar
|
||||
|
||||
from .backing_store import BackingStore, CultCacheEnvelope
|
||||
from .documents import DocumentDefinition, extract_value
|
||||
|
|
@ -52,6 +54,52 @@ class CultCacheError(RuntimeError):
|
|||
pass
|
||||
|
||||
|
||||
# Blank is no value: empty or only C# White_Space characters (the 25 chars string.IsNullOrWhiteSpace is true for).
|
||||
# An explicit set, not str.isspace(): Python also counts U+001C-001F, which C# does not, and omits nothing C# counts.
|
||||
_BLANK_INDEX_CHARS = frozenset(
|
||||
chr(code)
|
||||
for code in (
|
||||
*range(0x09, 0x0E),
|
||||
0x20,
|
||||
0x85,
|
||||
0xA0,
|
||||
0x1680,
|
||||
*range(0x2000, 0x200B),
|
||||
0x2028,
|
||||
0x2029,
|
||||
0x202F,
|
||||
0x205F,
|
||||
0x3000,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _utf16_order(text: str) -> bytes:
|
||||
"""Sorts as C# StringComparer.Ordinal does: by UTF-16 code unit, not code point."""
|
||||
return text.encode("utf-16-be", "surrogatepass")
|
||||
|
||||
|
||||
def index_text(value: Any) -> str | None:
|
||||
"""The canonical text of an index value, as C# IndexValue and RenderCanonicalNumber, or None when it names no
|
||||
value: a string is as is (never trimmed), a boolean is `True`/`False`, an integer is its digits, a non-integral
|
||||
finite float is its exact decimal expansion, NaN and infinities and blank strings are no value."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool): # before int: bool is an int subclass
|
||||
return "True" if value else "False"
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value):
|
||||
return None
|
||||
if value.is_integer():
|
||||
return str(int(value)) # -0.0 is 0
|
||||
text = format(Decimal(value), "f") # Decimal(float) is exact
|
||||
return text.rstrip("0").rstrip(".") if "." in text else text
|
||||
text = value if isinstance(value, str) else str(value)
|
||||
return None if all(char in _BLANK_INDEX_CHARS for char in text) else text
|
||||
|
||||
|
||||
@dataclass
|
||||
class _State:
|
||||
documents: dict[str, DocumentDefinition[Any]] = field(default_factory=dict)
|
||||
|
|
@ -186,6 +234,8 @@ class CultCache:
|
|||
@_mutating
|
||||
def register_index(self, document: DocumentDefinition[Any], index: str, extractor: str | Any) -> None:
|
||||
self._assert_registered(document)
|
||||
if index in document.roles:
|
||||
raise CultCacheError(f"Index '{index}' of {document.type} is declared as a role; a role is never a unique index.")
|
||||
index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()}
|
||||
index_extractors.setdefault(document.type, {})[index] = extractor
|
||||
self._install_lookups(self._state.name_extractors, index_extractors)
|
||||
|
|
@ -195,7 +245,11 @@ class CultCache:
|
|||
) -> None:
|
||||
"""Derives every lookup under the candidate extractors, then installs extractors and lookups
|
||||
together. An extractor that raises on a held value installs nothing."""
|
||||
names, indexes, lookup_keys = self._derive_lookups(self._state.values, name_extractors, index_extractors)
|
||||
names, indexes, lookup_keys = self._derive_lookups(
|
||||
[(type, key, value) for type, values in self._state.values.items() for key, value in values.items()],
|
||||
name_extractors,
|
||||
index_extractors,
|
||||
)
|
||||
(
|
||||
self._state.name_extractors,
|
||||
self._state.index_extractors,
|
||||
|
|
@ -263,6 +317,7 @@ class CultCache:
|
|||
structures, running every check and every user extractor; the cache's state is replaced
|
||||
only once that build finishes, so a refused load admits nothing."""
|
||||
loaded_values: dict[str, dict[str, Any]] = {}
|
||||
read_order: dict[tuple[str, str], Any] = {} # (type, key) -> value, in the order the stores yielded them
|
||||
loaded_envelopes: dict[str, dict[str, CultCacheEnvelope]] = {}
|
||||
loaded_legacy_global_keys: dict[str, str] = {}
|
||||
routes, generic = self._state.stores_by_type, self._state.generic_store
|
||||
|
|
@ -301,9 +356,10 @@ class CultCache:
|
|||
envelope = self._stamped(document, envelope)
|
||||
value = document.decode_payload(envelope.payload)
|
||||
loaded_values.setdefault(envelope.type, {})[envelope.key] = value
|
||||
read_order[(envelope.type, envelope.key)] = value
|
||||
loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope
|
||||
loaded_names, loaded_indexes, loaded_lookup_keys = self._derive_lookups(
|
||||
loaded_values, self._state.name_extractors, self._state.index_extractors
|
||||
[(type, key, value) for (type, key), value in read_order.items()], self._state.name_extractors, self._state.index_extractors
|
||||
)
|
||||
# Plain attribute assignments: nothing fallible runs once the swap begins.
|
||||
(
|
||||
|
|
@ -432,12 +488,20 @@ class CultCache:
|
|||
"""Validates every record (home and name and index extractors included) before the store is
|
||||
touched; once the store accepts, applying to the cache has no fallible step."""
|
||||
type = document.type
|
||||
# A batch naming a key twice lands the last record for it, as C# CultCacheBatch stages one operation per key. The
|
||||
# judge, the store and the apply all read this one list.
|
||||
records = list({envelope.key: (envelope, value) for envelope, value in records}.values())
|
||||
name_extractor = self._state.name_extractors.get(type)
|
||||
index_extractors = self._state.index_extractors.get(type, {})
|
||||
admitted = [
|
||||
(envelope, value, self._lookup_keys_for(value, name_extractor, index_extractors))
|
||||
for envelope, value in records
|
||||
]
|
||||
self._refuse_index_sharing(
|
||||
[(type, envelope.key, keys) for envelope, _, keys in admitted],
|
||||
self._state.indexes,
|
||||
{(type, envelope.key) for envelope, _ in records},
|
||||
)
|
||||
store = self._store_for_type(type)
|
||||
legacy_key = self._state.legacy_global_keys.get(type)
|
||||
if store is not None:
|
||||
|
|
@ -552,36 +616,69 @@ class CultCache:
|
|||
name = None if name_extractor is None else extract_value(value, name_extractor)
|
||||
indexes: dict[str, str] = {}
|
||||
for index, extractor in index_extractors.items():
|
||||
index_value = extract_value(value, extractor)
|
||||
index_value = index_text(extract_value(value, extractor))
|
||||
if index_value is not None:
|
||||
indexes[index] = str(index_value)
|
||||
indexes[index] = index_value
|
||||
return _LookupKeys(None if name is None else str(name), indexes)
|
||||
|
||||
def _derive_lookups(
|
||||
self,
|
||||
values_by_type: dict[str, dict[str, Any]],
|
||||
records: list[tuple[str, str, Any]],
|
||||
name_extractors: dict[str, str | Any],
|
||||
index_extractors: dict[str, dict[str, str | Any]],
|
||||
) -> tuple[_Names, _Indexes, _LookupKeysByType]:
|
||||
"""Builds name and index lookups for the given values in fresh dicts, touching no cache state."""
|
||||
"""Builds name and index lookups for the given (type, key, value) records, judged in the order given, in fresh dicts, touching no cache state."""
|
||||
names: _Names = {}
|
||||
indexes: _Indexes = {}
|
||||
lookup_keys: _LookupKeysByType = {}
|
||||
for type, values in values_by_type.items():
|
||||
name_extractor = name_extractors.get(type)
|
||||
type_index_extractors = index_extractors.get(type, {})
|
||||
for key, value in values.items():
|
||||
keys = self._lookup_keys_for(value, name_extractor, type_index_extractors)
|
||||
lookup_keys.setdefault(type, {})[key] = keys
|
||||
landing: list[tuple[str, str, _LookupKeys]] = []
|
||||
for type, key, value in records:
|
||||
keys = self._lookup_keys_for(value, name_extractors.get(type), index_extractors.get(type, {}))
|
||||
lookup_keys.setdefault(type, {})[key] = keys
|
||||
landing.append((type, key, keys))
|
||||
# Judged whole, over every type at once and on the file alone (never against what the cache held): a held value can
|
||||
# have been mutated in place since it was admitted, and another process may have moved a value between records.
|
||||
self._refuse_index_sharing(landing, {}, ())
|
||||
for type in lookup_keys:
|
||||
for key, keys in lookup_keys.get(type, {}).items():
|
||||
if keys.name is not None:
|
||||
names[(type, keys.name)] = key
|
||||
for index, index_value in keys.indexes.items():
|
||||
indexes[(type, index, index_value)] = key
|
||||
return names, indexes, lookup_keys
|
||||
|
||||
def _refuse_index_sharing(
|
||||
self,
|
||||
landing: list[tuple[str, str, _LookupKeys]],
|
||||
held: dict[tuple[str, str, str], str],
|
||||
replaced: Iterable[tuple[str, str]],
|
||||
) -> None:
|
||||
"""The one index rule (variants:ruling:unique-indexes), in the order of C# RefuseIndexSharing: within one type and one
|
||||
unique alias, no two records hold one non-blank value, judged on the result. The holders of a value are the landing
|
||||
keys projecting it (in landing order) plus the held key unless this admission replaces it; two distinct holders
|
||||
refuse, naming the two smallest keys (UTF-16 code-unit order) and the alias, never the value. Aliases are judged in
|
||||
UTF-16 order, values in the order they landed."""
|
||||
released = set(replaced)
|
||||
landed: dict[tuple[str, str, str], list[str]] = {}
|
||||
for type, key, keys in landing:
|
||||
for index, index_value in keys.indexes.items():
|
||||
landed.setdefault((type, index, index_value), []).append(key)
|
||||
for (type, index, index_value), keys in sorted(landed.items(), key=lambda item: _utf16_order(item[0][1])):
|
||||
held_key = held.get((type, index, index_value))
|
||||
if held_key is not None and (type, held_key) not in released:
|
||||
keys = [*keys, held_key]
|
||||
distinct = sorted(dict.fromkeys(keys), key=_utf16_order)
|
||||
if len(distinct) > 1:
|
||||
document = self._state.documents.get(type)
|
||||
schema_name = (document.schema_name if document is not None else None) or type
|
||||
raise CultCacheError(
|
||||
f"Records {distinct[0]} and {distinct[1]} ({schema_name}) both hold unique index '{index}'; "
|
||||
"an index value names one record."
|
||||
)
|
||||
|
||||
def _remove_lookups(self, type: str, key: str) -> None:
|
||||
"""Removes the lookups the held record produced at admission, handing a name or index value to
|
||||
another held record that produced it too. Reads stored lookup keys only; runs no extractor."""
|
||||
"""Removes the lookups the held record produced at admission, handing a name to another held
|
||||
record that produced it too (an index value has one holder). Reads stored lookup keys only; runs no extractor."""
|
||||
held = self._state.lookup_keys.get(type, {})
|
||||
keys = held.get(key)
|
||||
if keys is None:
|
||||
|
|
@ -596,9 +693,6 @@ class CultCache:
|
|||
if self._state.indexes.get(lookup_key) != key:
|
||||
continue
|
||||
del self._state.indexes[lookup_key]
|
||||
for candidate_key, candidate in held.items():
|
||||
if candidate_key != key and candidate.indexes.get(index) == index_value:
|
||||
self._state.indexes[lookup_key] = candidate_key
|
||||
|
||||
def _assert_registered(self, document: DocumentDefinition[Any]) -> None:
|
||||
if self._state.documents.get(document.type) is not document:
|
||||
|
|
|
|||
|
|
@ -157,6 +157,11 @@ class DocumentDefinition(Generic[T]):
|
|||
global_document: bool = False
|
||||
name: str | Extractor[T] | None = None
|
||||
indexes: dict[str, str | Extractor[T]] = field(default_factory=dict)
|
||||
"""Unique lookups (C# `[CultIndex]`): no two records of the type hold one non-blank value, judged at every commit and
|
||||
load. `get_by_index` answers the one holder."""
|
||||
roles: dict[str, str | Extractor[T]] = field(default_factory=dict)
|
||||
"""Selection aliases (C# `[CultRole]`): many records may share a value. Never indexed for lookup, never refuse; the
|
||||
member's catalog `index_alias` is the same either way."""
|
||||
payload_encoder: Callable[[Any], bytes] = _json_payload_encoder
|
||||
payload_decoder: Callable[[bytes], Any] = _json_payload_decoder
|
||||
schema_id: str | None = None
|
||||
|
|
@ -190,6 +195,14 @@ class DocumentDefinition(Generic[T]):
|
|||
self.validate(value)
|
||||
return value
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Judged here so every constructor passes it: the factories, the dataclass constructor and dataclasses.replace.
|
||||
for alias in self.roles:
|
||||
if alias in self.indexes:
|
||||
raise ValueError(
|
||||
f"Document type {self.type} declares '{alias}' as both an index and a role; an index already names its role."
|
||||
)
|
||||
|
||||
def catalog_entry(self) -> CultCacheSchemaCatalogEntry:
|
||||
if self._catalog_entry is not None:
|
||||
return self._catalog_entry
|
||||
|
|
@ -241,6 +254,7 @@ def define_document_type(
|
|||
global_document: bool = False,
|
||||
name: str | Extractor[T] | None = None,
|
||||
indexes: dict[str, str | Extractor[T]] | None = None,
|
||||
roles: dict[str, str | Extractor[T]] | None = None,
|
||||
payload_encoder: Callable[[Any], bytes] = _json_payload_encoder,
|
||||
payload_decoder: Callable[[bytes], Any] = _json_payload_decoder,
|
||||
schema_id: str | None = None,
|
||||
|
|
@ -259,6 +273,7 @@ def define_document_type(
|
|||
global_document=global_document,
|
||||
name=name,
|
||||
indexes=indexes or {},
|
||||
roles=roles or {},
|
||||
payload_encoder=payload_encoder,
|
||||
payload_decoder=payload_decoder,
|
||||
schema_id=schema_id,
|
||||
|
|
@ -280,6 +295,7 @@ def define_database_entry_type(
|
|||
global_document: bool = False,
|
||||
name: str | Extractor[T] | None = None,
|
||||
indexes: dict[str, str | Extractor[T]] | None = None,
|
||||
roles: dict[str, str | Extractor[T]] | None = None,
|
||||
schema_id: str | None = None,
|
||||
schema_name: str | None = None,
|
||||
schema_version: str | None = None,
|
||||
|
|
@ -296,7 +312,7 @@ def define_database_entry_type(
|
|||
|
||||
normalized = _normalize_fields(type, fields)
|
||||
member_name_lookup = name if isinstance(name, str) else None
|
||||
index_lookup = indexes or {}
|
||||
index_lookup = {**(roles or {}), **(indexes or {})}
|
||||
|
||||
def members() -> tuple[CultCacheSchemaCatalogMember, ...]:
|
||||
return tuple(
|
||||
|
|
@ -323,6 +339,7 @@ def define_database_entry_type(
|
|||
global_document=global_document,
|
||||
name=name,
|
||||
indexes=indexes,
|
||||
roles=roles,
|
||||
payload_encoder=_pack_tree,
|
||||
payload_decoder=_messagepack_payload_decoder,
|
||||
schema_id=schema_id,
|
||||
|
|
|
|||
|
|
@ -103,20 +103,20 @@ class CultCacheTests(unittest.TestCase):
|
|||
self.assertEqual(cache.get_key_by_name(document, "Elixir"), "item:potion")
|
||||
self.assertEqual(cache.get_by_index(document, "category", "Rare").name, "Elixir")
|
||||
|
||||
def test_incremental_lookup_delete_restores_duplicate_owner(self) -> None:
|
||||
def test_incremental_lookup_delete_restores_duplicate_name_owner(self) -> None:
|
||||
document = item_doc()
|
||||
cache = CultCache()
|
||||
cache.register_document_type(document)
|
||||
|
||||
cache.put(document, "item:first", Item(name="Potion", category="Consumable", value=50))
|
||||
cache.put(document, "item:second", Item(name="Potion", category="Consumable", value=60))
|
||||
cache.put(document, "item:second", Item(name="Potion", category="Rare", value=60))
|
||||
self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:second")
|
||||
self.assertEqual(cache.get_key_by_index(document, "category", "Consumable"), "item:second")
|
||||
|
||||
cache.delete(document, "item:second")
|
||||
|
||||
self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:first")
|
||||
self.assertEqual(cache.get_key_by_index(document, "category", "Consumable"), "item:first")
|
||||
self.assertIsNone(cache.get_key_by_index(document, "category", "Rare"))
|
||||
|
||||
def test_database_entry_formatter_uses_slot_indexed_messagepack_array(self) -> None:
|
||||
try:
|
||||
|
|
@ -1121,6 +1121,25 @@ class CultCacheTests(unittest.TestCase):
|
|||
catalog_entry=catalog_entry,
|
||||
)
|
||||
|
||||
def test_pulling_a_synthetic_pre_v1_envelope_array_leaves_the_file_bytes_identical(self) -> None:
|
||||
try:
|
||||
import msgpack # type: ignore
|
||||
except ImportError:
|
||||
self.skipTest("msgpack optional dependency is not installed")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "legacy.cc"
|
||||
legacy = msgpack.packb(
|
||||
[{"key": "note:hello", "type": "note", "payload": {"title": "Hello"}, "storedAt": "2026-10-20T00:00:00Z"}],
|
||||
use_bin_type=True,
|
||||
)
|
||||
path.write_bytes(legacy)
|
||||
|
||||
envelopes = SingleFileMessagePackBackingStore(path).pull_all()
|
||||
|
||||
self.assertEqual([e.key for e in envelopes], ["note:hello"])
|
||||
self.assertEqual(path.read_bytes(), legacy)
|
||||
self.assertEqual(sorted(p.name for p in Path(tmp).iterdir()), ["legacy.cc"])
|
||||
|
||||
def test_legacy_key_global_loads_without_writing_and_first_write_leaves_one_global(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
settings = define_document_type("settings", global_document=True)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ item_doc = define_document_type(
|
|||
encode=lambda item: asdict(item),
|
||||
decode=lambda raw: Item(**raw),
|
||||
name="name",
|
||||
indexes={"category": "category"},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
610
packages/cultcache-py/tests/test_unique_index.py
Normal file
610
packages/cultcache-py/tests/test_unique_index.py
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import asdict, dataclass
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import msgpack
|
||||
|
||||
from cultcache_py import (
|
||||
CultCache,
|
||||
CultCacheEnvelope,
|
||||
SingleFileMessagePackBackingStore,
|
||||
define_database_entry_type,
|
||||
define_document_type,
|
||||
leaves as t,
|
||||
database_entry_field,
|
||||
)
|
||||
from cultcache_py.cache import CultCacheError, index_text
|
||||
|
||||
# variants:ruling:unique-indexes. `indexes` are unique within one type, `roles` are selection aliases. The vectors in
|
||||
# tests/vectors/unique-index are written by the C# reference; the rest of the behaviour is pinned here.
|
||||
VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "unique-index"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
sku: object = None
|
||||
serial: object = None
|
||||
group: object = None
|
||||
|
||||
|
||||
def refusal(key_a: str, key_b: str, schema_name: str, alias: str) -> str:
|
||||
return f"Records {key_a} and {key_b} ({schema_name}) both hold unique index '{alias}'; an index value names one record."
|
||||
|
||||
|
||||
def item_doc(type: str = "item", *, indexes=None, roles=None):
|
||||
return define_document_type(
|
||||
type,
|
||||
encode=asdict,
|
||||
decode=lambda raw: Item(**raw),
|
||||
indexes={"sku": "sku", "serial": "serial"} if indexes is None else indexes,
|
||||
roles={"group": "group"} if roles is None else roles,
|
||||
)
|
||||
|
||||
|
||||
def envelope(document, key: str, item: Item) -> CultCacheEnvelope:
|
||||
return CultCacheEnvelope.create(key=key, type=document.type, payload=document.encode_payload(item))
|
||||
|
||||
|
||||
def cache_of(*documents, store=None) -> CultCache:
|
||||
builder = CultCache.builder()
|
||||
for document in documents:
|
||||
builder.register_document_type(document)
|
||||
if store is not None:
|
||||
builder.add_generic_store(store)
|
||||
return builder.build()
|
||||
|
||||
|
||||
class RecordingStore:
|
||||
"""A store that counts what reaches it; the judge must refuse before any store call."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.pushed: list[str] = []
|
||||
|
||||
def pull_all(self):
|
||||
return []
|
||||
|
||||
def push(self, envelope) -> None:
|
||||
self.pushed.append(envelope.key)
|
||||
|
||||
def push_all(self, envelopes) -> None:
|
||||
self.pushed.extend(envelope.key for envelope in envelopes)
|
||||
|
||||
def delete(self, type, key) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class IndexTextTests(unittest.TestCase):
|
||||
def test_canonical_text(self) -> None:
|
||||
cases = [
|
||||
("S-1", "S-1"),
|
||||
(" a ", " a "),
|
||||
("", ""),
|
||||
("\x1f", "\x1f"),
|
||||
("\x1c", "\x1c"),
|
||||
("\x1d", "\x1d"),
|
||||
("\x1e", "\x1e"),
|
||||
("", ""),
|
||||
("", ""),
|
||||
(True, "True"),
|
||||
(False, "False"),
|
||||
(7, "7"),
|
||||
(-7, "-7"),
|
||||
(9223372036854775807, "9223372036854775807"),
|
||||
(18446744073709551615, "18446744073709551615"),
|
||||
(0.0, "0"),
|
||||
(-0.0, "0"),
|
||||
(2.0, "2"),
|
||||
(-3.0, "-3"),
|
||||
(1e21, "1000000000000000000000"),
|
||||
(0.1, "0.1000000000000000055511151231257827021181583404541015625"),
|
||||
(-0.5, "-0.5"),
|
||||
(2.5e-07, "0.00000024999999999999998868702795647156467140348468092270195484161376953125"),
|
||||
]
|
||||
for value, text in cases:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(index_text(value), text)
|
||||
self.assertIsInstance(index_text(value), str)
|
||||
|
||||
def test_no_value(self) -> None:
|
||||
blank = ["", " ", "\t", "\n", "\x0b", "\x0c", "\r", "\x85", "\xa0", " ", "
", "
", " ",
|
||||
" ", " ", " \x85 ", *map(chr, range(0x2000, 0x200B))]
|
||||
for value in [None, float("nan"), float("inf"), float("-inf"), *blank]:
|
||||
with self.subTest(value=repr(value)):
|
||||
self.assertIsNone(index_text(value))
|
||||
|
||||
def test_blank_set_is_exactly_the_csharp_set(self) -> None:
|
||||
csharp = {*range(0x09, 0x0E), 0x20, 0x85, 0xA0, 0x1680, *range(0x2000, 0x200B), 0x2028, 0x2029, 0x202F, 0x205F, 0x3000}
|
||||
self.assertEqual(len(csharp), 25)
|
||||
got = {code for code in range(0x110000) if 0xD800 > code or code > 0xDFFF if index_text(chr(code)) is None}
|
||||
self.assertEqual(got, csharp)
|
||||
# Python's isspace is the wrong test: it also counts U+001C-001F.
|
||||
self.assertTrue("\x1f".isspace())
|
||||
self.assertEqual(index_text("\x1f"), "\x1f")
|
||||
|
||||
def test_floats_render_exactly_not_through_str(self) -> None:
|
||||
for value in (0.1, 1e-7, 123456789.123456789, 5e-324, 1.7976931348623157e308):
|
||||
with self.subTest(value=value):
|
||||
text = index_text(value)
|
||||
self.assertNotIn("e", text.lower())
|
||||
self.assertEqual(Decimal(text), Decimal(value))
|
||||
self.assertFalse(text.endswith("0") and "." in text)
|
||||
|
||||
def test_bool_is_not_an_integer(self) -> None:
|
||||
self.assertEqual(index_text(True), "True")
|
||||
self.assertNotEqual(index_text(True), "1")
|
||||
|
||||
|
||||
class UniqueIndexTests(unittest.TestCase):
|
||||
def test_a_write_of_a_held_value_at_another_key_is_refused_by_every_write_path(self) -> None:
|
||||
for alias in ("sku", "serial"): # two unique indexes: each is judged
|
||||
other = "serial" if alias == "sku" else "sku"
|
||||
paths = {
|
||||
"put": lambda cache, d, k, i: cache.put(d, k, i),
|
||||
"put_envelope": lambda cache, d, k, i: cache.put_envelope(d, envelope(d, k, i)),
|
||||
"put_envelopes": lambda cache, d, k, i: cache.put_envelopes(d, [envelope(d, k, i)]),
|
||||
"update": lambda cache, d, k, i: cache.update(d, k, lambda _held: i),
|
||||
}
|
||||
for name, write in paths.items():
|
||||
with self.subTest(alias=alias, path=name):
|
||||
document = item_doc()
|
||||
store = RecordingStore()
|
||||
cache = cache_of(document, store=store)
|
||||
cache.put(document, "item:b", Item(sku="CANARY-b", serial="canary-b"))
|
||||
cache.put(document, "item:a", Item(sku="CANARY-a", serial="canary-a"))
|
||||
before = cache.snapshot()
|
||||
pushed = list(store.pushed)
|
||||
taken = "CANARY-b" if alias == "sku" else "canary-b"
|
||||
landing = Item(**{alias: taken, other: "fresh"})
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
write(cache, document, "item:a", landing)
|
||||
self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", alias))
|
||||
self.assertNotIn("anary", str(caught.exception))
|
||||
self.assertEqual(cache.snapshot(), before)
|
||||
self.assertEqual(store.pushed, pushed)
|
||||
self.assertEqual(cache.get_key_by_index(document, alias, taken), "item:b")
|
||||
self.assertIsNone(cache.get_key_by_index(document, other, "fresh"))
|
||||
|
||||
def test_a_record_rewriting_its_own_key_keeps_its_value(self) -> None:
|
||||
document = item_doc()
|
||||
cache = cache_of(document)
|
||||
cache.put(document, "item:a", Item(sku="S", serial="1", group="g"))
|
||||
cache.put(document, "item:a", Item(sku="S", serial="2", group="h"))
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:a")
|
||||
self.assertIsNone(cache.get_key_by_index(document, "serial", "1"))
|
||||
self.assertEqual(cache.get_key_by_index(document, "serial", "2"), "item:a")
|
||||
|
||||
def test_the_refusal_names_the_schema_not_the_type(self) -> None:
|
||||
document = define_document_type(
|
||||
"t.item", indexes={"sku": "sku"}, schema_name="schema.Item", schema_id="schema.Item.v1"
|
||||
)
|
||||
cache = cache_of(document)
|
||||
cache.put(document, "k:1", {"sku": "S"})
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.put(document, "k:2", {"sku": "S"})
|
||||
self.assertEqual(str(caught.exception), refusal("k:1", "k:2", "schema.Item", "sku"))
|
||||
|
||||
def test_the_two_smallest_keys_are_named_in_ordinal_order(self) -> None:
|
||||
document = item_doc()
|
||||
cache = cache_of(document)
|
||||
cache.put(document, "item:m", Item(sku="S"))
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.put_envelopes(document, [envelope(document, "item:z", Item(sku="S")), envelope(document, "item:A", Item(sku="S"))])
|
||||
self.assertEqual(str(caught.exception), refusal("item:A", "item:m", "item", "sku"))
|
||||
|
||||
def test_put_envelopes_holding_one_value_twice_is_refused_whole(self) -> None:
|
||||
document = item_doc()
|
||||
store = RecordingStore()
|
||||
cache = cache_of(document, store=store)
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.put_envelopes(
|
||||
document,
|
||||
[
|
||||
envelope(document, "item:b", Item(sku="S")),
|
||||
envelope(document, "item:c", Item(sku="T")),
|
||||
envelope(document, "item:a", Item(sku="S")),
|
||||
],
|
||||
)
|
||||
self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "sku"))
|
||||
self.assertEqual(cache.snapshot(), {})
|
||||
self.assertEqual(store.pushed, [])
|
||||
self.assertIsNone(cache.get_key_by_index(document, "sku", "T"))
|
||||
|
||||
def test_verdict_does_not_depend_on_batch_order(self) -> None:
|
||||
document = item_doc()
|
||||
rows = [("item:a", Item(sku="S", serial="1")), ("item:b", Item(sku="T", serial="1")), ("item:c", Item(sku="U"))]
|
||||
for order in itertools.permutations(rows):
|
||||
with self.subTest(order=[key for key, _ in order]):
|
||||
cache = cache_of(document)
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.put_envelopes(document, [envelope(document, key, item) for key, item in order])
|
||||
self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "serial"))
|
||||
self.assertEqual(cache.snapshot(), {})
|
||||
|
||||
def test_put_envelopes_swapping_two_records_values_lands(self) -> None:
|
||||
document = item_doc()
|
||||
cache = cache_of(document)
|
||||
cache.put(document, "item:a", Item(sku="S", serial="1"))
|
||||
cache.put(document, "item:b", Item(sku="T", serial="2"))
|
||||
cache.put_envelopes(
|
||||
document,
|
||||
[envelope(document, "item:a", Item(sku="T", serial="2")), envelope(document, "item:b", Item(sku="S", serial="1"))],
|
||||
)
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b")
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "T"), "item:a")
|
||||
self.assertEqual(cache.get_key_by_index(document, "serial", "1"), "item:b")
|
||||
self.assertEqual(cache.get_key_by_index(document, "serial", "2"), "item:a")
|
||||
|
||||
def test_a_batch_may_take_a_value_the_batch_releases(self) -> None:
|
||||
document = item_doc()
|
||||
cache = cache_of(document)
|
||||
cache.put(document, "item:a", Item(sku="S"))
|
||||
cache.put_envelopes(document, [envelope(document, "item:a", Item(sku="T")), envelope(document, "item:b", Item(sku="S"))])
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b")
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "T"), "item:a")
|
||||
|
||||
def test_delete_frees_the_value(self) -> None:
|
||||
document = item_doc()
|
||||
cache = cache_of(document)
|
||||
cache.put(document, "item:a", Item(sku="S"))
|
||||
cache.delete(document, "item:a")
|
||||
self.assertIsNone(cache.get_key_by_index(document, "sku", "S"))
|
||||
cache.put(document, "item:b", Item(sku="S"))
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b")
|
||||
|
||||
def test_blank_values_never_conflict(self) -> None:
|
||||
document = item_doc()
|
||||
cache = cache_of(document)
|
||||
blanks = ["", " ", "\x85", " ", None]
|
||||
for number, blank in enumerate(blanks):
|
||||
cache.put(document, f"item:blank{number}", Item(sku=blank, serial=blank))
|
||||
cache.put_envelopes(document, [envelope(document, "item:more", Item(sku=" ")), envelope(document, "item:more2", Item(sku=""))])
|
||||
for blank in ("", " ", "\x85"):
|
||||
self.assertIsNone(cache.get_key_by_index(document, "sku", blank))
|
||||
# U+001F and U+FEFF are values.
|
||||
cache.put(document, "item:us", Item(sku="\x1f"))
|
||||
cache.put(document, "item:bom", Item(sku=""))
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "\x1f"), "item:us")
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", ""), "item:bom")
|
||||
with self.assertRaises(CultCacheError):
|
||||
cache.put(document, "item:us2", Item(sku="\x1f"))
|
||||
|
||||
def test_values_compare_as_the_canonical_text(self) -> None:
|
||||
document = item_doc()
|
||||
cache = cache_of(document)
|
||||
cache.put(document, "item:a", Item(sku=1))
|
||||
for same in (1, 1.0, "1"):
|
||||
with self.subTest(same=same), self.assertRaises(CultCacheError):
|
||||
cache.put(document, "item:b", Item(sku=same))
|
||||
cache.put(document, "item:true", Item(sku=True))
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "True"), "item:true")
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "1"), "item:a")
|
||||
cache.put(document, "item:f", Item(sku=0.1))
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "0.1000000000000000055511151231257827021181583404541015625"), "item:f")
|
||||
|
||||
def test_two_types_may_share_an_alias_and_a_value(self) -> None:
|
||||
one, two = item_doc("one"), item_doc("two")
|
||||
cache = cache_of(one, two)
|
||||
cache.put(one, "k:1", Item(sku="S", serial="1"))
|
||||
cache.put(two, "k:2", Item(sku="S", serial="1"))
|
||||
self.assertEqual(cache.get_key_by_index(one, "sku", "S"), "k:1")
|
||||
self.assertEqual(cache.get_key_by_index(two, "sku", "S"), "k:2")
|
||||
cache.delete(one, "k:1")
|
||||
self.assertEqual(cache.get_key_by_index(two, "sku", "S"), "k:2")
|
||||
|
||||
def test_roles_may_share_values_and_are_not_looked_up(self) -> None:
|
||||
document = item_doc()
|
||||
cache = cache_of(document)
|
||||
cache.put(document, "item:a", Item(sku="1", group="g"))
|
||||
cache.put(document, "item:b", Item(sku="2", group="g"))
|
||||
self.assertIsNone(cache.get_key_by_index(document, "group", "g"))
|
||||
self.assertIsNone(cache.get_by_index(document, "group", "g"))
|
||||
|
||||
def test_an_alias_in_both_indexes_and_roles_is_refused(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "both an index and a role"):
|
||||
define_document_type("bad", indexes={"a": "a"}, roles={"a": "a"})
|
||||
with self.assertRaisesRegex(ValueError, "both an index and a role"):
|
||||
define_database_entry_type("bad", [("a", 0)], indexes={"a": "a"}, roles={"a": "a"})
|
||||
define_document_type("fine", indexes={"a": "a"}, roles={"b": "a"})
|
||||
|
||||
def test_register_index_over_held_duplicates_installs_nothing(self) -> None:
|
||||
document = item_doc(indexes={}, roles={})
|
||||
cache = cache_of(document)
|
||||
cache.put(document, "item:b", Item(sku="S", serial="1"))
|
||||
cache.put(document, "item:a", Item(sku="S", serial="2"))
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.register_index(document, "sku", "sku")
|
||||
self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "sku"))
|
||||
self.assertIsNone(cache.get_key_by_index(document, "sku", "S"))
|
||||
cache.register_index(document, "serial", "serial") # an installed alias is not poisoned by the refused one
|
||||
self.assertEqual(cache.get_key_by_index(document, "serial", "2"), "item:a")
|
||||
# Nothing was installed, so a write sharing the refused alias's value is still admitted.
|
||||
cache.put(document, "item:c", Item(sku="S", serial="3"))
|
||||
|
||||
def test_registration_names_an_installed_alias_a_caller_mutated_into_a_collision(self) -> None:
|
||||
document = item_doc(indexes={"sku": "sku"}, roles={})
|
||||
cache = cache_of(document)
|
||||
cache.put(document, "item:x", Item(sku="1", serial="s1"))
|
||||
cache.put(document, "item:y", Item(sku="2", serial="s2"))
|
||||
cache.get_required(document, "item:y").sku = "1" # the cache hands out its held value
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.register_index(document, "serial", "serial")
|
||||
self.assertEqual(str(caught.exception), refusal("item:x", "item:y", "item", "sku"))
|
||||
|
||||
def test_derive_names_the_ordinal_first_alias_then_value(self) -> None:
|
||||
document = item_doc(indexes={}, roles={})
|
||||
cache = cache_of(document)
|
||||
cache.put(document, "item:1", Item(sku="a", serial="p", group="x"))
|
||||
cache.put(document, "item:2", Item(sku="a", serial="p", group="x"))
|
||||
cache.put(document, "item:3", Item(sku="b", serial="q", group="y"))
|
||||
cache.put(document, "item:4", Item(sku="b", serial="q", group="y"))
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.register_index(document, "serial", "serial")
|
||||
self.assertEqual(str(caught.exception), refusal("item:1", "item:2", "item", "serial"))
|
||||
|
||||
def test_a_document_in_two_unique_indexes_loads_and_registers_through_every_path(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "items.cc"
|
||||
document = item_doc()
|
||||
cache = cache_of(document, store=SingleFileMessagePackBackingStore(path))
|
||||
cache.put(document, "item:a", Item(sku="S", serial="1"))
|
||||
cache.put(document, "item:b", Item(sku="T", serial="2"))
|
||||
# a store written by a looser definition, then opened by this one
|
||||
loose = item_doc(indexes={}, roles={})
|
||||
writer = cache_of(loose, store=SingleFileMessagePackBackingStore(path))
|
||||
writer.pull_all_backing_stores()
|
||||
writer.put(loose, "item:c", Item(sku="U", serial="2"))
|
||||
before = path.read_bytes()
|
||||
reader = cache_of(document, store=SingleFileMessagePackBackingStore(path))
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
reader.pull_all_backing_stores()
|
||||
self.assertEqual(str(caught.exception), refusal("item:b", "item:c", "item", "serial"))
|
||||
self.assertEqual(path.read_bytes(), before)
|
||||
self.assertEqual(reader.snapshot(), {})
|
||||
|
||||
def test_a_refused_load_keeps_the_held_state(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "items.cc"
|
||||
loose = item_doc(indexes={}, roles={})
|
||||
writer = cache_of(loose, store=SingleFileMessagePackBackingStore(path))
|
||||
writer.put(loose, "item:a", Item(sku="S"))
|
||||
writer.put(loose, "item:b", Item(sku="S"))
|
||||
document = item_doc()
|
||||
cache = cache_of(document, store=SingleFileMessagePackBackingStore(path))
|
||||
with self.assertRaises(CultCacheError):
|
||||
cache.pull_all_backing_stores()
|
||||
self.assertIsNone(cache.get_key_by_index(document, "sku", "S"))
|
||||
self.assertEqual(cache.get_all(document), [])
|
||||
|
||||
|
||||
class SimulationFactTests(unittest.TestCase):
|
||||
def test_simulation_fact_catalog_entry_is_unchanged_by_indexes_to_roles(self) -> None:
|
||||
import re
|
||||
|
||||
from cultnet_py.cultmesh_contracts import SIMULATION_FACT_DOCUMENT_TYPE, simulation_fact_document as document
|
||||
|
||||
aliases = {"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"}
|
||||
self.assertEqual(document.roles, aliases)
|
||||
self.assertEqual(document.indexes, {})
|
||||
entry = document.catalog_entry()
|
||||
snake = lambda name: re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower() # noqa: E731
|
||||
twin = define_database_entry_type(
|
||||
SIMULATION_FACT_DOCUMENT_TYPE,
|
||||
[(snake(m.member_name), m.slot) for m in entry.members],
|
||||
name="fact_id",
|
||||
indexes=aliases,
|
||||
schema_name=entry.schema_name,
|
||||
schema_version=entry.schema_version,
|
||||
)
|
||||
self.assertEqual(entry.canonical_schema_json, twin.catalog_entry().canonical_schema_json)
|
||||
self.assertEqual(entry.content_hash, twin.catalog_entry().content_hash)
|
||||
self.assertEqual(entry.members, twin.catalog_entry().members)
|
||||
self.assertEqual({m.index_alias for m in entry.members if m.index_alias}, set(aliases))
|
||||
|
||||
def test_facts_sharing_every_role_value_are_all_admitted(self) -> None:
|
||||
from cultnet_py.cultmesh_contracts import CultMeshSimulationFact, simulation_fact_document as document
|
||||
|
||||
cache = cache_of(document)
|
||||
for number in range(3):
|
||||
fact = CultMeshSimulationFact.from_candidate(
|
||||
{"shardId": "s", "shardEpoch": 1, "frame": 4, "subjectId": "x", "claimKind": "k", "claimHash": f"h{number}",
|
||||
"witnessCount": 1, "supportWeight": 1.0, "totalWeight": 1.0, "confidence": 1.0}
|
||||
)
|
||||
cache.put(document, f"simulation:{number}", fact)
|
||||
self.assertEqual(len(cache.get_all(document)), 3)
|
||||
self.assertIsNone(cache.get_key_by_index(document, "shard", "s"))
|
||||
|
||||
|
||||
class VectorTests(unittest.TestCase):
|
||||
def test_values_rows(self) -> None:
|
||||
document = item_doc(indexes={"sku": "sku"}, roles={})
|
||||
for label, kind, value, text in msgpack.unpackb((VECTORS / "values.msgpack").read_bytes(), raw=False):
|
||||
with self.subTest(label=label):
|
||||
self.assertEqual(index_text(value), text)
|
||||
cache = cache_of(document)
|
||||
cache.put(document, "item:1", Item(sku=value))
|
||||
if text is None:
|
||||
cache.put(document, "item:2", Item(sku=value)) # blank: two records commit
|
||||
self.assertEqual(len(cache.get_all(document)), 2)
|
||||
else:
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", text), "item:1")
|
||||
with self.assertRaises(CultCacheError):
|
||||
cache.put(document, "item:2", Item(sku=value))
|
||||
|
||||
def test_store_verdicts(self) -> None:
|
||||
unit = define_database_entry_type
|
||||
outcomes = msgpack.unpackb((VECTORS / "outcomes.msgpack").read_bytes(), raw=False)
|
||||
self.assertEqual(len(outcomes), 3)
|
||||
for name, alias, verdict, key_a, key_b in outcomes:
|
||||
for roles_first in (False, True): # the verdict does not depend on declaration order
|
||||
with self.subTest(file=name, alias=alias, roles_first=roles_first), tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / name
|
||||
shutil.copyfile(VECTORS / name, path)
|
||||
before = path.read_bytes()
|
||||
fields = [database_entry_field("sku", 0, codec=t.string), database_entry_field("category", 1, codec=t.string)]
|
||||
declared = {"indexes": {alias: "sku"} if alias else {}, "roles": {} if alias else {"Sku": "sku"}}
|
||||
if roles_first:
|
||||
declared = dict(reversed(list(declared.items())))
|
||||
document = unit(
|
||||
"tests.unique_item",
|
||||
fields,
|
||||
schema_id="sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882",
|
||||
schema_name="tests.unique_item",
|
||||
**declared,
|
||||
)
|
||||
cache = cache_of(document, store=SingleFileMessagePackBackingStore(path))
|
||||
if verdict == "open":
|
||||
cache.pull_all_backing_stores()
|
||||
self.assertGreaterEqual(len(cache.get_all(document)), 2)
|
||||
else:
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.pull_all_backing_stores()
|
||||
self.assertEqual(str(caught.exception), refusal(key_a, key_b, "tests.unique_item", alias))
|
||||
self.assertNotIn("S-1", str(caught.exception))
|
||||
self.assertEqual(cache.snapshot(), {})
|
||||
self.assertEqual(path.read_bytes(), before)
|
||||
|
||||
|
||||
class ListStore(RecordingStore):
|
||||
"""A store whose contents the test swaps between loads: another process rewrote the file."""
|
||||
|
||||
def __init__(self, envelopes=()) -> None:
|
||||
super().__init__()
|
||||
self.envelopes = list(envelopes)
|
||||
|
||||
def pull_all(self):
|
||||
return list(self.envelopes)
|
||||
|
||||
|
||||
class BatchAndOrderTests(unittest.TestCase):
|
||||
def test_a_batch_naming_a_key_twice_lands_the_last_record_and_keeps_every_held_lookup(self) -> None:
|
||||
# C# CultCacheBatch stages one operation per key, the last one named.
|
||||
document = item_doc()
|
||||
store = RecordingStore()
|
||||
cache = cache_of(document, store=store)
|
||||
cache.put(document, "item:h", Item(sku="S"))
|
||||
store.pushed.clear()
|
||||
cache.put_envelopes(
|
||||
document, [envelope(document, "item:k", Item(sku="S")), envelope(document, "item:k", Item(sku="T"))]
|
||||
)
|
||||
self.assertEqual(store.pushed, ["item:k"])
|
||||
self.assertEqual(cache.get_required(document, "item:k").sku, "T")
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:h")
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "T"), "item:k")
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.put(document, "item:z", Item(sku="S"))
|
||||
self.assertEqual(str(caught.exception), refusal("item:h", "item:z", "item", "sku"))
|
||||
|
||||
def test_a_batch_naming_a_key_twice_is_judged_on_the_record_that_lands(self) -> None:
|
||||
document = item_doc()
|
||||
cache = cache_of(document)
|
||||
cache.put(document, "item:h", Item(sku="S"))
|
||||
# the first record for k would collide with h; only the last lands, so the batch is admitted
|
||||
cache.put_envelopes(
|
||||
document, [envelope(document, "item:k", Item(sku="S")), envelope(document, "item:k", Item(sku="T"))]
|
||||
)
|
||||
# and the reverse order lands the colliding record, which is refused
|
||||
with self.assertRaises(CultCacheError):
|
||||
cache.put_envelopes(
|
||||
document, [envelope(document, "item:j", Item(sku="U")), envelope(document, "item:j", Item(sku="S"))]
|
||||
)
|
||||
self.assertNotIn("item:j", cache.snapshot().get("item", {}))
|
||||
|
||||
def test_values_are_named_in_landing_order(self) -> None:
|
||||
document = item_doc()
|
||||
cache = cache_of(document)
|
||||
rows = [("item:a", "B"), ("item:b", "B"), ("item:x", "A"), ("item:y", "A")]
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.put_envelopes(document, [envelope(document, key, Item(sku=sku)) for key, sku in rows])
|
||||
self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "sku"))
|
||||
|
||||
def test_keys_sort_by_utf16_code_unit_not_code_point(self) -> None:
|
||||
document = item_doc()
|
||||
cache = cache_of(document)
|
||||
astral, bmp = "k\U0001F600", "k\uff01" # code points: bmp < astral; UTF-16 units: D83D < FF01
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.put_envelopes(document, [envelope(document, bmp, Item(sku="S")), envelope(document, astral, Item(sku="S"))])
|
||||
self.assertEqual(str(caught.exception), refusal(astral, bmp, "item", "sku"))
|
||||
|
||||
def test_a_load_judges_every_type_at_once_aliases_in_ordinal_order(self) -> None:
|
||||
first = item_doc("first", indexes={"zeta": "sku"}, roles={})
|
||||
second = item_doc("second", indexes={"alpha": "sku"}, roles={})
|
||||
loose_first, loose_second = item_doc("first", indexes={}, roles={}), item_doc("second", indexes={}, roles={})
|
||||
store = ListStore(
|
||||
[envelope(loose_first, "f:1", Item(sku="S")), envelope(loose_first, "f:2", Item(sku="S"))]
|
||||
+ [envelope(loose_second, "s:1", Item(sku="S")), envelope(loose_second, "s:2", Item(sku="S"))]
|
||||
)
|
||||
cache = cache_of(first, second, store=store)
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.pull_all_backing_stores()
|
||||
self.assertEqual(str(caught.exception), refusal("s:1", "s:2", "second", "alpha"))
|
||||
|
||||
def test_aliases_sort_by_utf16_code_unit_not_code_point(self) -> None:
|
||||
astral, bmp = "k\U0001F600", "k\uff01" # code points: bmp < astral; UTF-16 units: D83D < FF01
|
||||
document = item_doc(indexes={bmp: "sku", astral: "serial"}, roles={})
|
||||
cache = cache_of(document)
|
||||
rows = [Item(sku="S", serial="T"), Item(sku="S", serial="T")]
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.put_envelopes(document, [envelope(document, f"item:{n}", row) for n, row in enumerate(rows)])
|
||||
self.assertEqual(str(caught.exception), refusal("item:0", "item:1", "item", astral))
|
||||
|
||||
def test_a_load_judges_records_in_the_order_the_file_yields_them(self) -> None:
|
||||
alpha, beta = item_doc("alpha", indexes={"k": "sku"}, roles={}), item_doc("beta", indexes={"k": "sku"}, roles={})
|
||||
loose_alpha, loose_beta = item_doc("alpha", indexes={}, roles={}), item_doc("beta", indexes={}, roles={})
|
||||
store = ListStore(
|
||||
[
|
||||
envelope(loose_alpha, "a:0", Item(sku="Q")),
|
||||
envelope(loose_beta, "b:1", Item(sku="S")),
|
||||
envelope(loose_beta, "b:2", Item(sku="S")),
|
||||
envelope(loose_alpha, "a:1", Item(sku="P")),
|
||||
envelope(loose_alpha, "a:2", Item(sku="P")),
|
||||
]
|
||||
)
|
||||
cache = cache_of(alpha, beta, store=store)
|
||||
with self.assertRaises(CultCacheError) as caught:
|
||||
cache.pull_all_backing_stores()
|
||||
self.assertEqual(str(caught.exception), refusal("b:1", "b:2", "beta", "k"))
|
||||
|
||||
def test_register_index_refuses_an_alias_the_definition_declares_as_a_role(self) -> None:
|
||||
document = item_doc()
|
||||
cache = cache_of(document)
|
||||
with self.assertRaises(CultCacheError):
|
||||
cache.register_index(document, "group", "group")
|
||||
cache.put_envelopes(
|
||||
document, [envelope(document, "item:1", Item(group="guide")), envelope(document, "item:2", Item(group="guide"))]
|
||||
)
|
||||
|
||||
|
||||
class LoadFreshnessTests(unittest.TestCase):
|
||||
def test_a_load_is_judged_on_the_file_alone_not_against_the_held_map(self) -> None:
|
||||
document = item_doc()
|
||||
store = ListStore([envelope(document, "item:b", Item(sku="S")), envelope(document, "item:c", Item(sku="T"))])
|
||||
cache = cache_of(document, store=store)
|
||||
cache.pull_all_backing_stores()
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b")
|
||||
# another process moved S from b to a, and b to T's old place
|
||||
store.envelopes = [envelope(document, "item:a", Item(sku="S")), envelope(document, "item:b", Item(sku="U")), store.envelopes[1]]
|
||||
cache.pull_all_backing_stores()
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:a")
|
||||
self.assertEqual(cache.get_key_by_index(document, "sku", "U"), "item:b")
|
||||
|
||||
|
||||
class DefinitionTests(unittest.TestCase):
|
||||
def test_every_constructor_refuses_an_alias_in_both_indexes_and_roles(self) -> None:
|
||||
from dataclasses import replace
|
||||
|
||||
from cultcache_py import DocumentDefinition
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "both an index and a role"):
|
||||
DocumentDefinition(type="item", indexes={"sku": "sku"}, roles={"sku": "sku"})
|
||||
with self.assertRaisesRegex(ValueError, "both an index and a role"):
|
||||
replace(item_doc(roles={}), roles={"sku": "sku"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -8,12 +8,16 @@ only in git history.
|
|||
|
||||
### Breaking
|
||||
|
||||
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name.
|
||||
- A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading.
|
||||
- A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. This reverses 0.3.1, where `push_all` replaced a file with no readable stamp (healing it); the stamp rule now has one owner, the reader every write asks under the lock, and it accepts v1 and v3 (0.3.1 refused v3).
|
||||
- `#[cultcache(index)]` means unique, as in C# `RefuseIndexSharing`: within one type and one alias no two records hold one non-blank value. A put, batch, push or load that would make two records hold a value is refused, naming both keys and the alias, and a store that already holds a duplicate does not open. A grouping index is no longer expressible; a consumer that relied on shared values must drop the `index` and look up by another way.
|
||||
- `bool` index and lookup text renders `True` / `False`, as C# does, where it rendered `true` / `false`.
|
||||
- `DatabaseEntry::UNIQUE_INDEXES` replaces `cult_indexes` as the one index declaration and changes type (its members are the unique aliases); a hand implementation overriding `cult_indexes` no longer compiles.
|
||||
- `register_entry_type` over a type that already holds records re-judges them under the new declaration (unique indexes and the global rule) and refuses, changing nothing, when they violate it. A declaration with an index also refuses when a held record cannot be decoded as the new type; a declaration without one does not read the records, so it does not.
|
||||
|
||||
### 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`).
|
||||
- Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name find the one with the smallest key (an index value is unique, below). A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`).
|
||||
- `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::<SchemaConflictError>()`.
|
||||
|
||||
## [0.3.1]
|
||||
|
|
|
|||
|
|
@ -394,6 +394,10 @@ fn outcome(error: &anyhow::Error) {
|
|||
}
|
||||
```
|
||||
|
||||
## Store format and rollback
|
||||
|
||||
A store whose elements hold an id is marked `cultcache.store.v3` by the C# runtime. This runtime reads and writes a v3 single-file store, keeps the header it read and carries the ids through a rewrite; it never drops the mark, so it has no carry-back. A cultcache-rs from before element ids refuses a v3 store by name, so rolling back to it after a store has been written as v3 is one-way: restore from a copy taken before upgrading, and upgrade every reader before any writer. (The C# line is different: CultLib 1.x does not refuse; see the GameCult.Caching README.)
|
||||
|
||||
## Near-Term Ergonomic Improvements
|
||||
|
||||
1. **Derive macro**
|
||||
|
|
@ -412,7 +416,9 @@ fn outcome(error: &anyhow::Error) {
|
|||
|
||||
3. **Name and index lookups**
|
||||
- `cache.get_by_name::<T>("Potion")`
|
||||
- `cache.get_by_index::<T>("faction", "Lucent")`
|
||||
- `cache.get_by_index::<T>("callsign", "Lucent-7")`: `#[cultcache(index)]` is unique. Within one type no two
|
||||
records hold one non-blank value of the alias; a put, batch or load that would make two is refused, naming both
|
||||
keys and the alias, and writes nothing. A grouping (faction, kind, status) is not an index: filter `get_all`.
|
||||
- generated extractors instead of reflection strings
|
||||
|
||||
4. **Schema/version metadata**
|
||||
|
|
|
|||
|
|
@ -69,23 +69,16 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
|
|||
}
|
||||
}
|
||||
});
|
||||
let index_members = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident)));
|
||||
let index_pushes = index_members.map(|(alias, member)| {
|
||||
let index_members: Vec<_> = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident))).collect();
|
||||
let index_rows = index_members.iter().map(|(alias, member)| {
|
||||
quote! {
|
||||
if let ::std::option::Option::Some(value) = ::cultcache_rs::CultLookupValue::lookup_value(&self.#member) {
|
||||
held.push((#alias, value));
|
||||
}
|
||||
(#alias, (|record: &Self| ::cultcache_rs::CultLookupValue::lookup_value(&record.#member))
|
||||
as fn(&Self) -> ::std::option::Option<::std::string::String>)
|
||||
}
|
||||
});
|
||||
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
|
||||
}
|
||||
}
|
||||
let index_rows: Vec<_> = index_rows.collect();
|
||||
let unique_indexes = (!index_rows.is_empty()).then(|| {
|
||||
quote! { const UNIQUE_INDEXES: &'static [::cultcache_rs::CultIndexMember<Self>] = &[#(#index_rows),*]; }
|
||||
});
|
||||
|
||||
Ok(quote! {
|
||||
|
|
@ -93,8 +86,8 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
|
|||
const TYPE: &'static str = #entry_type;
|
||||
const SCHEMA_NAME: &'static str = #schema_name;
|
||||
const GLOBAL: bool = #global;
|
||||
#unique_indexes
|
||||
#name_fn
|
||||
#indexes_fn
|
||||
}
|
||||
|
||||
#codec
|
||||
|
|
@ -443,7 +436,8 @@ struct CultCacheSlot {
|
|||
bytes: bool,
|
||||
/// `#[cultcache(name)]`: the member `get_by_name` finds the record by.
|
||||
name: bool,
|
||||
/// `#[cultcache(index)]` or `#[cultcache(index = "alias")]`: the alias `get_by_index` finds the record by.
|
||||
/// `#[cultcache(index)]` or `#[cultcache(index = "alias")]`: a unique lookup. Within one type no two records hold one
|
||||
/// non-blank value of the alias; `get_by_index` finds the record by it.
|
||||
index: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -941,15 +935,15 @@ mod tests {
|
|||
);
|
||||
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}");
|
||||
assert!(tokens.contains("(\"code\","), "{tokens}");
|
||||
assert!(tokens.contains("(\"sku\","), "{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}");
|
||||
assert!(!tokens.contains("fncult_name") && !tokens.contains("UNIQUE_INDEXES"), "{tokens}");
|
||||
}
|
||||
|
||||
// ResolveIndexAlias: a blank alias is the member's name.
|
||||
|
|
@ -958,7 +952,7 @@ mod tests {
|
|||
let tokens = entry_tokens(
|
||||
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = " ")] code: String }"#,
|
||||
);
|
||||
assert!(tokens.contains("(\"code\",value)"), "{tokens}");
|
||||
assert!(tokens.contains("(\"code\","), "{tokens}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -168,12 +168,14 @@ pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static {
|
|||
fn cult_name(&self) -> Option<String> {
|
||||
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()
|
||||
}
|
||||
/// The `#[cultcache(index)]` members, the one declaration: each is an alias with the reader of its value. Each is
|
||||
/// unique: within one type no two records hold one non-blank value of it, and `get_by_index` finds the record by it.
|
||||
const UNIQUE_INDEXES: &'static [CultIndexMember<Self>] = &[];
|
||||
}
|
||||
|
||||
/// An index alias and how to read its value from a record; `None` is no value.
|
||||
pub type CultIndexMember<T> = (&'static str, fn(&T) -> Option<String>);
|
||||
|
||||
/// 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 {
|
||||
|
|
@ -192,6 +194,13 @@ impl<T: CultLookupValue> CultLookupValue for Option<T> {
|
|||
}
|
||||
}
|
||||
|
||||
/// C# renders a boolean `True` or `False`; the index text is shared across runtimes.
|
||||
impl CultLookupValue for bool {
|
||||
fn lookup_value(&self) -> Option<String> {
|
||||
Some(if *self { "True" } else { "False" }.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! lookup_value_by_display {
|
||||
($($ty:ty),* $(,)?) => {$(
|
||||
impl CultLookupValue for $ty {
|
||||
|
|
@ -202,7 +211,7 @@ macro_rules! lookup_value_by_display {
|
|||
)*};
|
||||
}
|
||||
|
||||
lookup_value_by_display!(bool, char, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize);
|
||||
lookup_value_by_display!(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)]
|
||||
|
|
@ -2719,9 +2728,46 @@ pub struct CultCache {
|
|||
globals: BTreeSet<String>,
|
||||
schema_name_definitions: BTreeMap<String, String>,
|
||||
entries: BTreeMap<(String, String), CultCacheEnvelope>,
|
||||
/// Derived from `entries` by the uniqueness judge: (type, alias, value) -> the one key holding that value. It
|
||||
/// decides nothing; `get_by_index` reads it.
|
||||
index_holders: IndexHolders,
|
||||
/// Per registered type with `UNIQUE_INDEXES`: decodes a payload as that type and returns its non-blank index
|
||||
/// values. Registration data, so the judge decodes only indexed types.
|
||||
projectors: BTreeMap<String, IndexProjector>,
|
||||
stores: Vec<CultCacheStoreRegistration>,
|
||||
}
|
||||
|
||||
type IndexProjector = fn(&[u8]) -> Result<Vec<(&'static str, String)>>;
|
||||
type IndexSlot = (String, String, String);
|
||||
type IndexHolders = BTreeMap<IndexSlot, String>;
|
||||
|
||||
fn project_indexes<T: DatabaseEntry>(payload: &[u8]) -> Result<Vec<(&'static str, String)>> {
|
||||
let document = decode_payload::<T>(payload)?;
|
||||
Ok(T::UNIQUE_INDEXES
|
||||
.iter()
|
||||
.filter_map(|(alias, read)| read(&document).map(|value| (*alias, value)))
|
||||
.filter(|(_, value)| !value.trim().is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// What an admission does to the holder map: slots it releases (still held by the key that held them) and slots it takes.
|
||||
#[derive(Default)]
|
||||
struct IndexChange {
|
||||
released: Vec<(IndexSlot, String)>,
|
||||
taken: Vec<(IndexSlot, String)>,
|
||||
}
|
||||
|
||||
impl IndexChange {
|
||||
fn apply(self, holders: &mut IndexHolders) {
|
||||
for (slot, key) in self.released {
|
||||
if holders.get(&slot) == Some(&key) {
|
||||
holders.remove(&slot);
|
||||
}
|
||||
}
|
||||
holders.extend(self.taken);
|
||||
}
|
||||
}
|
||||
|
||||
impl CultCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
|
|
@ -2729,6 +2775,8 @@ impl CultCache {
|
|||
globals: BTreeSet::new(),
|
||||
schema_name_definitions: BTreeMap::new(),
|
||||
entries: BTreeMap::new(),
|
||||
index_holders: BTreeMap::new(),
|
||||
projectors: BTreeMap::new(),
|
||||
stores: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -2756,12 +2804,40 @@ impl CultCache {
|
|||
existing_type
|
||||
));
|
||||
}
|
||||
self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME);
|
||||
// The declaration decides what the held records hold (index projector, global flag), so a changed declaration
|
||||
// re-judges them; a held violation (or a record the new type cannot decode) refuses the registration and
|
||||
// changes nothing.
|
||||
let previous = self.projectors.get(T::TYPE).copied();
|
||||
let was_global = self.globals.contains(T::TYPE);
|
||||
if T::UNIQUE_INDEXES.is_empty() {
|
||||
self.projectors.remove(T::TYPE);
|
||||
} else {
|
||||
self.projectors.insert(T::TYPE.to_string(), project_indexes::<T>);
|
||||
}
|
||||
if T::GLOBAL {
|
||||
self.globals.insert(T::TYPE.to_string());
|
||||
} else {
|
||||
self.globals.remove(T::TYPE);
|
||||
}
|
||||
let held_type = self.entries.range((T::TYPE.to_string(), String::new())..).next().is_some_and(|((held, _), _)| held == T::TYPE);
|
||||
if held_type {
|
||||
match self.refuse_second_globals(&self.entries).and_then(|()| self.judge_image(&self.entries)) {
|
||||
Ok(holders) => self.index_holders = holders,
|
||||
Err(refusal) => {
|
||||
match previous {
|
||||
Some(project) => self.projectors.insert(T::TYPE.to_string(), project),
|
||||
None => self.projectors.remove(T::TYPE),
|
||||
};
|
||||
if was_global {
|
||||
self.globals.insert(T::TYPE.to_string());
|
||||
} else {
|
||||
self.globals.remove(T::TYPE);
|
||||
}
|
||||
return Err(refusal);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME);
|
||||
self.schema_name_definitions
|
||||
.insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string());
|
||||
Ok(())
|
||||
|
|
@ -2869,7 +2945,9 @@ impl CultCache {
|
|||
}
|
||||
}
|
||||
self.refuse_second_globals(&loaded)?;
|
||||
let holders = self.judge_image(&loaded)?;
|
||||
self.entries = loaded;
|
||||
self.index_holders = holders;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -2973,20 +3051,18 @@ impl CultCache {
|
|||
self.find_record::<T>(|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.
|
||||
/// The record whose `#[cultcache(index)]` member `alias` holds `value`: at most one, since an index is unique. A
|
||||
/// blank value is not indexed, so it finds nothing; neither does an alias `T` does not declare.
|
||||
pub fn get_by_index<T: DatabaseEntry>(&self, alias: &str, value: &str) -> Result<Option<T>> {
|
||||
self.require_entry_type::<T>()?;
|
||||
if value.trim().is_empty() {
|
||||
if value.trim().is_empty() || !T::UNIQUE_INDEXES.iter().any(|(declared, _)| *declared == alias) {
|
||||
return Ok(None);
|
||||
}
|
||||
self.find_record::<T>(|document| {
|
||||
document
|
||||
.cult_indexes()
|
||||
.iter()
|
||||
.any(|(held_alias, held_value)| *held_alias == alias && held_value == value)
|
||||
})
|
||||
let slot = (T::TYPE.to_string(), alias.to_string(), value.to_string());
|
||||
match self.index_holders.get(&slot) {
|
||||
Some(key) => self.get::<T>(key),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first record of `T`, in key order, that `matches`.
|
||||
|
|
@ -3029,10 +3105,12 @@ impl CultCache {
|
|||
pub fn put<T: DatabaseEntry>(&mut self, key: impl Into<String>, value: &T) -> Result<T> {
|
||||
let (entry, parsed) = self.prepare_entry(key, value)?;
|
||||
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
|
||||
let change = self.judge_unique(&[&entry])?;
|
||||
if let Some(home) = self.home_index(T::TYPE)? {
|
||||
self.stores[home].store.push(&entry)?;
|
||||
}
|
||||
self.entries.insert(entry_id(&entry), entry);
|
||||
change.apply(&mut self.index_holders);
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
|
|
@ -3141,6 +3219,7 @@ impl CultCache {
|
|||
next.insert(entry_id(entry), entry.clone());
|
||||
}
|
||||
self.refuse_second_globals(&next)?;
|
||||
let change = self.judge_unique(&staged.iter().collect::<Vec<_>>())?;
|
||||
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.
|
||||
|
|
@ -3154,16 +3233,19 @@ impl CultCache {
|
|||
.push_all(&snapshot, PushAllOptions::default())?;
|
||||
}
|
||||
self.entries = next;
|
||||
change.apply(&mut self.index_holders);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn put_envelope<T: DatabaseEntry>(&mut self, entry: CultCacheEnvelope) -> Result<T> {
|
||||
let parsed = self.validate_envelope::<T>(&entry)?;
|
||||
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
|
||||
let change = self.judge_unique(&[&entry])?;
|
||||
if let Some(home) = self.home_index(T::TYPE)? {
|
||||
self.stores[home].store.push(&entry)?;
|
||||
}
|
||||
self.entries.insert(entry_id(&entry), entry);
|
||||
change.apply(&mut self.index_holders);
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
|
|
@ -3193,10 +3275,12 @@ impl CultCache {
|
|||
}
|
||||
require_payload_depth(&entry)?;
|
||||
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
|
||||
let change = self.judge_unique(&[&entry])?;
|
||||
if let Some(home) = self.home_index(&entry.r#type)? {
|
||||
self.stores[home].store.push(&entry)?;
|
||||
}
|
||||
self.entries.insert(entry_id(&entry), entry);
|
||||
change.apply(&mut self.index_holders);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -3247,10 +3331,12 @@ impl CultCache {
|
|||
let Some(entry) = self.entries.get(&id).cloned() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let released = self.projected_slots(&entry)?;
|
||||
if let Some(home) = self.home_index(T::TYPE)? {
|
||||
self.stores[home].store.delete(&entry)?;
|
||||
}
|
||||
self.entries.remove(&id);
|
||||
IndexChange { released, taken: Vec::new() }.apply(&mut self.index_holders);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
|
|
@ -3279,10 +3365,86 @@ impl CultCache {
|
|||
loaded.insert(entry_id(&entry), entry);
|
||||
}
|
||||
self.refuse_second_globals(&loaded)?;
|
||||
let holders = self.judge_image(&loaded)?;
|
||||
self.entries = loaded;
|
||||
self.index_holders = holders;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The slots an envelope holds: its type's non-blank index values, each held by its key.
|
||||
fn projected_slots(&self, entry: &CultCacheEnvelope) -> Result<Vec<(IndexSlot, String)>> {
|
||||
let Some(project) = self.projectors.get(&entry.r#type) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let held = project(&entry.payload).with_context(|| {
|
||||
format!("failed to project the unique indexes of CultCache entry {:?} at key {:?}", entry.r#type, entry.key)
|
||||
})?;
|
||||
Ok(held
|
||||
.into_iter()
|
||||
.map(|(alias, value)| ((entry.r#type.clone(), alias.to_string(), value), entry.key.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The one index rule (C# `RefuseIndexSharing`), judged on the result of an admission: within one type and one
|
||||
/// unique alias, no two records hold one non-blank value. The holders of a value are the held key (unless this
|
||||
/// admission rewrites it) plus every landing key projecting it; two distinct holders refuse, naming both keys
|
||||
/// (ordinal) and the alias, never the value. A key landing twice lands as its last envelope.
|
||||
fn judge_unique(&self, landing: &[&CultCacheEnvelope]) -> Result<IndexChange> {
|
||||
self.judge_against(&self.index_holders, landing, true)
|
||||
}
|
||||
|
||||
/// Judges a whole image against an empty holder map and returns the map it installs.
|
||||
fn judge_image(&self, image: &BTreeMap<(String, String), CultCacheEnvelope>) -> Result<IndexHolders> {
|
||||
let landing: Vec<_> = image.values().collect();
|
||||
let mut holders = IndexHolders::new();
|
||||
self.judge_against(&holders, &landing, false)?.apply(&mut holders);
|
||||
Ok(holders)
|
||||
}
|
||||
|
||||
fn judge_against(&self, held: &IndexHolders, landing: &[&CultCacheEnvelope], replacing: bool) -> Result<IndexChange> {
|
||||
let mut last: BTreeMap<(&str, &str), &CultCacheEnvelope> = BTreeMap::new();
|
||||
for entry in landing {
|
||||
last.insert((entry.r#type.as_str(), entry.key.as_str()), entry);
|
||||
}
|
||||
let mut change = IndexChange::default();
|
||||
let mut groups: Vec<(IndexSlot, BTreeSet<String>)> = Vec::new();
|
||||
let mut at: BTreeMap<IndexSlot, usize> = BTreeMap::new();
|
||||
for entry in last.values() {
|
||||
if !self.projectors.contains_key(&entry.r#type) {
|
||||
continue;
|
||||
}
|
||||
if replacing && let Some(old) = self.entries.get(&entry_id(entry)) {
|
||||
change.released.extend(self.projected_slots(old)?);
|
||||
}
|
||||
for (slot, key) in self.projected_slots(entry)? {
|
||||
let index = *at.entry(slot.clone()).or_insert_with(|| {
|
||||
groups.push((slot, BTreeSet::new()));
|
||||
groups.len() - 1
|
||||
});
|
||||
groups[index].1.insert(key);
|
||||
}
|
||||
}
|
||||
groups.sort_by(|left, right| left.0.1.cmp(&right.0.1));
|
||||
for (slot, mut holders) in groups {
|
||||
if let Some(held_key) = held.get(&slot)
|
||||
&& !last.contains_key(&(slot.0.as_str(), held_key.as_str()))
|
||||
{
|
||||
holders.insert(held_key.clone());
|
||||
}
|
||||
let mut ordered = holders.iter();
|
||||
if let (Some(first), Some(second)) = (ordered.next(), ordered.next()) {
|
||||
return Err(anyhow!(
|
||||
"Records {first} and {second} ({}) both hold unique index '{}'; an index value names one record.",
|
||||
self.definitions.get(&slot.0).copied().unwrap_or(&slot.0),
|
||||
slot.1
|
||||
));
|
||||
}
|
||||
let key = holders.into_iter().next().expect("a group holds at least one key");
|
||||
change.taken.push((slot, key));
|
||||
}
|
||||
Ok(change)
|
||||
}
|
||||
|
||||
fn refuse_second_global(
|
||||
&self,
|
||||
entries: &BTreeMap<(String, String), CultCacheEnvelope>,
|
||||
|
|
@ -6136,6 +6298,27 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulling_a_synthetic_pre_v1_envelope_array_leaves_the_file_bytes_identical() -> Result<()> {
|
||||
// SYNTHETIC pre-v1 vector: no real file of this shape is known; the Rust envelope payload is binary.
|
||||
let temp = tempfile::tempdir()?;
|
||||
let path = temp.path().join("legacy.cc");
|
||||
let legacy = rmp_serde::to_vec_named(&vec![CultCacheEnvelope {
|
||||
key: "note:hello".to_string(),
|
||||
r#type: "note".to_string(),
|
||||
payload: vec![0x90],
|
||||
stored_at: "2026-10-20T00:00:00Z".to_string(),
|
||||
schema_id: None,
|
||||
}])?;
|
||||
fs::write(&path, &legacy)?;
|
||||
|
||||
let envelopes = SingleFileMessagePackBackingStore::new(&path).pull_all()?;
|
||||
|
||||
assert_eq!(envelopes.iter().map(|e| e.key.as_str()).collect::<Vec<_>>(), ["note:hello"]);
|
||||
assert_eq!(fs::read(&path)?, legacy);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_store_is_named_once_its_header_string_is_complete() -> Result<()> {
|
||||
let vector = fs::read(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use cultcache_rs::{
|
|||
struct Item {
|
||||
#[cultcache(key = 0, name)]
|
||||
name: String,
|
||||
#[cultcache(key = 1, index)]
|
||||
#[cultcache(key = 1)]
|
||||
category: String,
|
||||
#[cultcache(key = 2, index = "sku")]
|
||||
code: Option<String>,
|
||||
|
|
@ -30,7 +30,7 @@ struct Gadget {
|
|||
#[cultcache(key = 0, name)]
|
||||
name: String,
|
||||
#[cultcache(key = 1, index)]
|
||||
category: String,
|
||||
serial: String,
|
||||
}
|
||||
|
||||
/// Sorts after `Item` and has its exact shape: a lookup that ran past the end of its own type would answer from it.
|
||||
|
|
@ -39,7 +39,7 @@ struct Gadget {
|
|||
struct Zed {
|
||||
#[cultcache(key = 0, name)]
|
||||
name: String,
|
||||
#[cultcache(key = 1, index)]
|
||||
#[cultcache(key = 1)]
|
||||
category: String,
|
||||
#[cultcache(key = 2, index = "sku")]
|
||||
code: Option<String>,
|
||||
|
|
@ -48,7 +48,7 @@ struct Zed {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "settings", global)]
|
||||
#[cultcache(type = "settings", schema = "tests.settings", global)]
|
||||
struct Settings {
|
||||
#[cultcache(key = 0)]
|
||||
value: String,
|
||||
|
|
@ -71,7 +71,7 @@ struct Numbered {
|
|||
#[cultcache(key = 0, name)]
|
||||
id: u32,
|
||||
#[cultcache(key = 1, index)]
|
||||
owner: u64,
|
||||
badge: u64,
|
||||
}
|
||||
|
||||
fn cache() -> CultCache {
|
||||
|
|
@ -110,7 +110,6 @@ fn a_record_is_found_by_name_and_by_index_after_a_reopen() {
|
|||
let reopened = reopened(&path);
|
||||
let by_name = reopened.get_by_name::<Item>("Potion").unwrap().unwrap();
|
||||
assert_eq!(by_name.value, 50);
|
||||
assert_eq!(reopened.get_by_index::<Item>("category", "Consumable").unwrap(), Some(by_name.clone()));
|
||||
assert_eq!(reopened.get_by_index::<Item>("sku", "P-1").unwrap(), Some(by_name));
|
||||
}
|
||||
|
||||
|
|
@ -121,31 +120,32 @@ fn a_missing_name_or_value_is_none_and_an_unregistered_type_is_refused() {
|
|||
|
||||
assert_eq!(cache.get_by_name::<Item>("B").unwrap(), None);
|
||||
assert_eq!(cache.get_by_name::<Item>("a").unwrap(), None, "names compare ordinally");
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "other").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("nosuchalias", "cat").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("sku", "other").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("nosuchalias", "a-1").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "cat").unwrap(), None, "category is a grouping, not an index");
|
||||
assert_eq!(cache.get_by_index::<Item>("code", "a-1").unwrap(), None, "the alias is sku, not the member name");
|
||||
|
||||
let bare = CultCache::new();
|
||||
assert!(bare.get_by_name::<Item>("A").is_err());
|
||||
assert!(bare.get_by_index::<Item>("category", "cat").is_err());
|
||||
assert!(bare.get_by_index::<Item>("sku", "a-1").is_err());
|
||||
assert!(bare.get_global::<Settings>().is_err());
|
||||
}
|
||||
|
||||
#[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::<Item>("category", "cat").unwrap().is_some());
|
||||
cache.put("numbered:1", &Numbered { id: 1, badge: 7 }).unwrap();
|
||||
assert!(cache.get_by_index::<Numbered>("badge", "7").unwrap().is_some());
|
||||
}
|
||||
|
||||
// A blank name is no name; a blank index value is not indexed; a nil member holds no value.
|
||||
#[test]
|
||||
fn blank_and_nil_values_are_not_found() {
|
||||
let mut cache = cache();
|
||||
cache.put("item:blank", &item("", " ", None)).unwrap();
|
||||
cache.put("item:blank", &item("", " ", Some(" "))).unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>("").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("category", " ").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("sku", " ").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("sku", "").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("sku", "").unwrap(), None);
|
||||
|
||||
// Only emptiness voids a name: whitespace is a name, as in C# (Length > 0).
|
||||
|
|
@ -156,68 +156,41 @@ fn blank_and_nil_values_are_not_found() {
|
|||
#[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("item:a", &item("same", "same", Some("same"))).unwrap();
|
||||
cache.put("gadget:a", &Gadget { name: "same".into(), serial: "same".into() }).unwrap();
|
||||
cache.put("zed:a", &Zed { name: "same".into(), category: "same".into(), code: None, value: 0 }).unwrap();
|
||||
|
||||
assert_eq!(cache.get_by_name::<Gadget>("same").unwrap().unwrap().name, "same");
|
||||
cache.delete::<Item>("item:a").unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>("same").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "same").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("sku", "same").unwrap(), None);
|
||||
assert!(cache.get_by_name::<Gadget>("same").unwrap().is_some());
|
||||
assert!(cache.get_by_index::<Gadget>("category", "same").unwrap().is_some());
|
||||
assert!(cache.get_by_index::<Gadget>("serial", "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();
|
||||
cache.put("item:a", &item("old", "cat", Some("c1"))).unwrap();
|
||||
cache.put("item:a", &item("new", "cat", Some("c2"))).unwrap();
|
||||
|
||||
assert_eq!(cache.get_by_name::<Item>("old").unwrap(), None, "a stale name is not found");
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "c1").unwrap(), None, "a stale value is not found");
|
||||
assert_eq!(cache.get_by_index::<Item>("sku", "c1").unwrap(), None, "a stale value is not found");
|
||||
assert!(cache.get_by_name::<Item>("new").unwrap().is_some());
|
||||
assert!(cache.get_by_index::<Item>("category", "c2").unwrap().is_some());
|
||||
assert!(cache.get_by_index::<Item>("sku", "c2").unwrap().is_some());
|
||||
|
||||
cache.delete::<Item>("item:a").unwrap();
|
||||
assert_eq!(cache.get_by_name::<Item>("new").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("category", "c2").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Item>("sku", "c2").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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();
|
||||
let badge = 161_u64;
|
||||
cache.put("numbered:1", &Numbered { id: 42, badge }).unwrap();
|
||||
assert!(cache.get_by_name::<Numbered>("42").unwrap().is_some());
|
||||
assert!(cache.get_by_index::<Numbered>("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::<Item>("category", "x").unwrap().map(|found| found.name);
|
||||
assert_eq!(found(&cache).as_deref(), Some("p1"), "the smallest key, though written second");
|
||||
assert_eq!(reopened(&path).get_by_index::<Item>("category", "x").unwrap().unwrap().name, "p1");
|
||||
|
||||
cache.delete::<Item>("item:p1").unwrap();
|
||||
assert_eq!(found(&cache).as_deref(), Some("p2"), "removing the holder finds the other one");
|
||||
|
||||
cache.put("item:p1", &item("p1", "x", None)).unwrap();
|
||||
assert_eq!(found(&cache).as_deref(), Some("p1"));
|
||||
cache.delete::<Item>("item:p2").unwrap();
|
||||
assert_eq!(found(&cache).as_deref(), Some("p1"), "removing the other changes nothing");
|
||||
cache.delete::<Item>("item:p1").unwrap();
|
||||
assert_eq!(found(&cache), None);
|
||||
}
|
||||
assert_eq!(reopened(&path).get_by_index::<Item>("category", "x").unwrap(), None);
|
||||
assert!(cache.get_by_index::<Numbered>("badge", "161").unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -244,6 +217,71 @@ fn a_global_is_none_until_written_and_survives_a_reopen() {
|
|||
assert_eq!(reopened(&path).get_global::<Settings>().unwrap(), Some(settings("durable authority")));
|
||||
}
|
||||
|
||||
/// `Settings` as an ordinary type: the same type id and schema, no global rule.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "settings", schema = "tests.settings")]
|
||||
struct LooseSettings {
|
||||
#[cultcache(key = 0)]
|
||||
value: String,
|
||||
}
|
||||
|
||||
// Declaring a held type global judges the records it holds: two are already a second, so the registration is
|
||||
// refused and the type stays ordinary.
|
||||
#[test]
|
||||
fn registering_a_type_global_over_two_held_records_is_refused_and_changes_nothing() {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<LooseSettings>().unwrap();
|
||||
cache.put("a", &LooseSettings { value: "one".into() }).unwrap();
|
||||
cache.put("b", &LooseSettings { value: "two".into() }).unwrap();
|
||||
|
||||
let refusal = cache.register_entry_type::<Settings>().unwrap_err();
|
||||
assert!(refusal.downcast_ref::<CultCacheSecondGlobal>().is_some(), "{refusal:#}");
|
||||
|
||||
cache.put("c", &LooseSettings { value: "three".into() }).unwrap();
|
||||
assert_eq!(cache.get_all_with_keys::<LooseSettings>().unwrap().len(), 3, "still ordinary");
|
||||
}
|
||||
|
||||
// One held record may become the global; a second is then refused as a second global.
|
||||
#[test]
|
||||
fn registering_a_type_global_over_one_held_record_is_admitted() {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<LooseSettings>().unwrap();
|
||||
cache.put("a", &LooseSettings { value: "one".into() }).unwrap();
|
||||
cache.register_entry_type::<Settings>().unwrap();
|
||||
let refusal = cache.put("b", &Settings { value: "two".into() }).unwrap_err();
|
||||
assert!(refusal.downcast_ref::<CultCacheSecondGlobal>().is_some(), "{refusal:#}");
|
||||
}
|
||||
|
||||
/// A global with an indexed member, and the same type and schema declaring that member as an integer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "indexed_global", schema = "tests.indexed_global", global)]
|
||||
struct IndexedGlobal {
|
||||
#[cultcache(key = 0, index = "V")]
|
||||
v: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "indexed_global", schema = "tests.indexed_global", global)]
|
||||
struct RetypedGlobal {
|
||||
#[cultcache(key = 0, index = "V")]
|
||||
v: i64,
|
||||
}
|
||||
|
||||
// A refused re-registration over an already-global type leaves it global: the rule keeps refusing a second record.
|
||||
#[test]
|
||||
fn a_refused_reregistration_leaves_a_global_type_global() {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<IndexedGlobal>().unwrap();
|
||||
cache.put("a", &IndexedGlobal { v: "x".into() }).unwrap();
|
||||
|
||||
assert!(cache.register_entry_type::<RetypedGlobal>().is_err(), "the held text cannot be decoded as an integer");
|
||||
|
||||
let refusal = cache.put("b", &IndexedGlobal { v: "y".into() }).unwrap_err();
|
||||
assert!(refusal.downcast_ref::<CultCacheSecondGlobal>().is_some(), "{refusal:#}");
|
||||
assert_eq!(cache.get_global::<IndexedGlobal>().unwrap(), Some(IndexedGlobal { v: "x".into() }));
|
||||
assert_eq!(cache.get_by_index::<IndexedGlobal>("V", "x").unwrap(), cache.get::<IndexedGlobal>("a").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_type_that_is_not_a_global_has_no_global() {
|
||||
let mut cache = cache();
|
||||
|
|
|
|||
669
packages/cultcache-rs/tests/unique_index.rs
Normal file
669
packages/cultcache-rs/tests/unique_index.rs
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
//! `#[cultcache(index)]` is unique (`variants:ruling:unique-indexes`; C# `RefuseIndexSharing`): within one type and one
|
||||
//! alias no two records hold one non-blank value, judged on the result of each admission, on every write path and every
|
||||
//! load. The refusal text is informational; the contract is the verdict, so most pins assert the verdict and the
|
||||
//! shared vectors in `tests/vectors/unique-index` pin the text and the canonical index text.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use cultcache_rs::{
|
||||
CultCache, CultCacheEnvelope, CultCacheSoaTable, DatabaseEntry, SingleFileMessagePackBackingStore,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The vectors' `tests.unique_item`: Sku is unique, Category a grouping.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "unique_item", schema = "tests.unique_item")]
|
||||
struct Unique {
|
||||
#[cultcache(key = 0, index = "Sku")]
|
||||
sku: String,
|
||||
#[cultcache(key = 1)]
|
||||
category: String,
|
||||
}
|
||||
|
||||
/// The same type with no index: a role, which writes the duplicates `Unique` refuses.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "unique_item", schema = "tests.unique_item")]
|
||||
struct Twin {
|
||||
#[cultcache(key = 0)]
|
||||
sku: String,
|
||||
#[cultcache(key = 1)]
|
||||
category: String,
|
||||
}
|
||||
|
||||
/// Two unique indexes declared out of alphabetical order, so no ordering is an accident of declaration.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "multi", schema = "Multi")]
|
||||
struct Multi {
|
||||
#[cultcache(key = 0, index = "B")]
|
||||
b: Option<String>,
|
||||
#[cultcache(key = 1, index = "A")]
|
||||
a: Option<String>,
|
||||
}
|
||||
|
||||
/// Another type sharing the alias `Sku`: types never conflict.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "other", schema = "Other")]
|
||||
struct Other {
|
||||
#[cultcache(key = 0, index = "Sku")]
|
||||
sku: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "plain", schema = "Plain")]
|
||||
struct Plain {
|
||||
#[cultcache(key = 0)]
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "vs", schema = "VS")]
|
||||
struct VString {
|
||||
#[cultcache(key = 0, index = "V")]
|
||||
v: String,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "vi", schema = "VI")]
|
||||
struct VInt {
|
||||
#[cultcache(key = 0, index = "V")]
|
||||
v: i64,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "vu", schema = "VU")]
|
||||
struct VUint {
|
||||
#[cultcache(key = 0, index = "V")]
|
||||
v: u64,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "vb", schema = "VB")]
|
||||
struct VBool {
|
||||
#[cultcache(key = 0, index = "V")]
|
||||
v: bool,
|
||||
}
|
||||
|
||||
fn unique(sku: &str) -> Unique {
|
||||
Unique { sku: sku.into(), category: "c".into() }
|
||||
}
|
||||
|
||||
fn multi(a: Option<&str>, b: Option<&str>) -> Multi {
|
||||
Multi { a: a.map(Into::into), b: b.map(Into::into) }
|
||||
}
|
||||
|
||||
fn refusal(k1: &str, k2: &str, schema: &str, alias: &str) -> String {
|
||||
format!("Records {k1} and {k2} ({schema}) both hold unique index '{alias}'; an index value names one record.")
|
||||
}
|
||||
|
||||
fn cache_at(path: &Path) -> CultCache {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Unique>().unwrap();
|
||||
cache.register_entry_type::<Multi>().unwrap();
|
||||
cache.register_entry_type::<Other>().unwrap();
|
||||
cache.register_entry_type::<Plain>().unwrap();
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
|
||||
cache.pull_all_backing_stores().unwrap();
|
||||
cache
|
||||
}
|
||||
|
||||
fn memory_cache() -> CultCache {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Unique>().unwrap();
|
||||
cache.register_entry_type::<Multi>().unwrap();
|
||||
cache.register_entry_type::<Other>().unwrap();
|
||||
cache
|
||||
}
|
||||
|
||||
fn twin_cache_at(path: &Path) -> CultCache {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Twin>().unwrap();
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
|
||||
cache.pull_all_backing_stores().unwrap();
|
||||
cache
|
||||
}
|
||||
|
||||
fn error_text(result: anyhow::Result<impl std::fmt::Debug>) -> String {
|
||||
format!("{:#}", result.unwrap_err())
|
||||
}
|
||||
|
||||
fn skus(cache: &CultCache) -> Vec<(String, String)> {
|
||||
cache.get_all_with_keys::<Unique>().unwrap().into_iter().map(|(key, value)| (key, value.sku)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_derive_emits_the_unique_aliases() {
|
||||
fn aliases<T: DatabaseEntry>() -> Vec<&'static str> {
|
||||
T::UNIQUE_INDEXES.iter().map(|(alias, _)| *alias).collect()
|
||||
}
|
||||
assert_eq!(aliases::<Unique>(), ["Sku"]);
|
||||
assert_eq!(aliases::<Multi>(), ["B", "A"]);
|
||||
assert!(aliases::<Plain>().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_put_of_a_held_value_at_another_key_is_refused_and_changes_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("u.cc");
|
||||
let mut cache = cache_at(&path);
|
||||
cache.put("item:b", &unique("S-1")).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
|
||||
let text = error_text(cache.put("item:a", &unique("S-1")));
|
||||
assert_eq!(text, refusal("item:a", "item:b", "tests.unique_item", "Sku"));
|
||||
assert!(!text.contains("S-1"), "the refusal never names the value");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the store is untouched");
|
||||
assert_eq!(cache.get::<Unique>("item:a").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "S-1").unwrap().unwrap().sku, "S-1");
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "S-1").unwrap(), cache.get::<Unique>("item:b").unwrap());
|
||||
}
|
||||
|
||||
// The old bug: write b then a sharing a value used to answer whichever key sorted first.
|
||||
#[test]
|
||||
fn the_first_holder_keeps_the_value_whatever_the_key_order() {
|
||||
let mut cache = memory_cache();
|
||||
cache.put("item:z", &unique("S")).unwrap();
|
||||
assert!(cache.put("item:a", &unique("S")).is_err());
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap().unwrap(), unique("S"));
|
||||
assert_eq!(skus(&cache), vec![("item:z".to_string(), "S".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_rewriting_its_own_key_keeps_its_value_and_a_change_releases_the_old_one() {
|
||||
let mut cache = memory_cache();
|
||||
cache.put("k", &unique("S")).unwrap();
|
||||
cache.put("k", &Unique { sku: "S".into(), category: "other".into() }).unwrap();
|
||||
assert!(cache.get_by_index::<Unique>("Sku", "S").unwrap().is_some());
|
||||
|
||||
cache.put("k", &unique("T")).unwrap();
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None, "a stale value is not found");
|
||||
cache.put("j", &unique("S")).unwrap();
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap().unwrap().sku, "S");
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "T").unwrap().unwrap().sku, "T");
|
||||
// Updating through `update` is the same rule.
|
||||
assert!(cache.update::<Unique, _>("k", |_| unique("S")).is_err());
|
||||
assert_eq!(cache.get::<Unique>("k").unwrap().unwrap().sku, "T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_releases_a_value() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("d.cc");
|
||||
let mut cache = cache_at(&path);
|
||||
cache.put("item:a", &unique("S")).unwrap();
|
||||
assert!(cache.put("item:b", &unique("S")).is_err());
|
||||
assert!(cache.delete::<Unique>("item:a").unwrap());
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None);
|
||||
cache.put("item:b", &unique("S")).unwrap();
|
||||
assert!(cache.get_by_index::<Unique>("Sku", "S").unwrap().is_some());
|
||||
assert!(!cache.delete::<Unique>("item:a").unwrap(), "deleting an absent key releases nothing");
|
||||
assert!(cache.get_by_index::<Unique>("Sku", "S").unwrap().is_some());
|
||||
assert!(cache_at(&path).get_by_index::<Unique>("Sku", "S").unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn types_sharing_an_alias_and_a_value_do_not_conflict() {
|
||||
let mut cache = memory_cache();
|
||||
cache.put("a", &unique("S")).unwrap();
|
||||
cache.put("a", &Other { sku: "S".into() }).unwrap();
|
||||
cache.put("b", &Other { sku: "T".into() }).unwrap();
|
||||
assert!(cache.put("b", &Other { sku: "S".into() }).is_err());
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap().unwrap(), unique("S"));
|
||||
assert_eq!(cache.get_by_index::<Other>("Sku", "S").unwrap().unwrap(), Other { sku: "S".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alias_the_type_does_not_declare_finds_nothing() {
|
||||
let mut cache = memory_cache();
|
||||
cache.put("a", &unique("S")).unwrap();
|
||||
assert_eq!(cache.get_by_index::<Unique>("sku", "S").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<Unique>("Category", "c").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_and_none_values_never_conflict() {
|
||||
let mut cache = memory_cache();
|
||||
for (key, sku) in [("a", " "), ("b", ""), ("c", "\u{85}"), ("d", "\t\n\u{a0}\u{3000}")] {
|
||||
cache.put(key, &unique(sku)).unwrap();
|
||||
}
|
||||
cache.put("m1", &multi(None, None)).unwrap();
|
||||
cache.put("m2", &multi(None, Some(" "))).unwrap();
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", " ").unwrap(), None);
|
||||
// U+FEFF, U+001F, U+200B, U+180E are values, never trimmed.
|
||||
cache.put("e", &unique("\u{feff}")).unwrap();
|
||||
assert!(cache.put("f", &unique("\u{feff}")).is_err());
|
||||
cache.put("g", &unique(" S ")).unwrap();
|
||||
cache.put("h", &unique("S")).unwrap();
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", " S ").unwrap().unwrap().sku, " S ");
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap().unwrap().sku, "S");
|
||||
}
|
||||
|
||||
// C# `string.IsNullOrWhiteSpace` over every code unit: exactly these 25 are blank (probed at 7760219b, dotnet 10).
|
||||
#[test]
|
||||
fn the_blank_set_is_exactly_the_csharp_white_space() {
|
||||
let blank: Vec<u32> = (0x9..=0xD)
|
||||
.chain([0x20, 0x85, 0xA0, 0x1680])
|
||||
.chain(0x2000..=0x200A)
|
||||
.chain([0x2028, 0x2029, 0x202F, 0x205F, 0x3000])
|
||||
.collect();
|
||||
assert_eq!(blank.len(), 25);
|
||||
let mut cache = memory_cache();
|
||||
let mut found = Vec::new();
|
||||
for unit in (0..=0xFFFF_u32).filter(|unit| char::from_u32(*unit).is_some()) {
|
||||
let text = char::from_u32(unit).unwrap().to_string();
|
||||
cache.put("a", &unique(&text)).unwrap();
|
||||
let second = cache.put("b", &unique(&text));
|
||||
if second.is_ok() {
|
||||
found.push(unit);
|
||||
cache.delete::<Unique>("b").unwrap();
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", &text).unwrap(), None, "U+{unit:04X} blank is never found");
|
||||
} else {
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", &text).unwrap().unwrap().sku, text);
|
||||
}
|
||||
}
|
||||
assert_eq!(found, blank);
|
||||
}
|
||||
|
||||
fn staged<T: DatabaseEntry>(cache: &CultCache, rows: &[(&str, T)]) -> Vec<CultCacheEnvelope> {
|
||||
rows.iter().map(|(key, value)| cache.prepare_entry(*key, value).unwrap().0).collect()
|
||||
}
|
||||
|
||||
// Two unique indexes, collision on the second-declared, through every write path.
|
||||
#[test]
|
||||
fn every_write_path_refuses_and_changes_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("paths.cc");
|
||||
let mut cache = cache_at(&path);
|
||||
cache.put("m:held", &multi(Some("a1"), Some("b1"))).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let before = cache.snapshot();
|
||||
|
||||
let clash_b = multi(Some("a2"), Some("b1"));
|
||||
let clash_a = multi(Some("a1"), Some("b2"));
|
||||
for (clash, alias) in [(&clash_b, "B"), (&clash_a, "A")] {
|
||||
let pick = |text: &str| {
|
||||
let (first, second) = if "m:new" < "m:held" { ("m:new", "m:held") } else { ("m:held", "m:new") };
|
||||
assert_eq!(text, refusal(first, second, "Multi", alias));
|
||||
};
|
||||
pick(&error_text(cache.put("m:new", clash)));
|
||||
pick(&error_text(cache.update::<Multi, _>("m:new", |_| clash.clone())));
|
||||
let envelope = staged(&cache, &[("m:new", clash.clone())]).remove(0);
|
||||
pick(&error_text(cache.put_envelope::<Multi>(envelope.clone())));
|
||||
pick(&error_text(cache.put_raw_envelope(envelope.clone())));
|
||||
pick(&error_text(cache.put_prepared_batch(vec![envelope])));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(cache.snapshot(), before);
|
||||
assert_eq!(cache.get_by_index::<Multi>("B", "b2").unwrap(), None);
|
||||
}
|
||||
// A clean row through every path lands and is found by both aliases.
|
||||
let fine = multi(Some("a9"), Some("b9"));
|
||||
cache.put("m:1", &fine).unwrap();
|
||||
let envelope = staged(&cache, &[("m:2", multi(Some("a8"), Some("b8")))]).remove(0);
|
||||
cache.put_envelope::<Multi>(envelope).unwrap();
|
||||
let envelope = staged(&cache, &[("m:3", multi(Some("a7"), Some("b7")))]).remove(0);
|
||||
cache.put_raw_envelope(envelope).unwrap();
|
||||
cache.put_prepared_batch(staged(&cache, &[("m:4", multi(Some("a6"), Some("b6")))])).unwrap();
|
||||
for (alias, value, key) in [("A", "a9", 1), ("B", "b8", 2), ("A", "a7", 3), ("B", "b6", 4)] {
|
||||
let found = cache.get_by_index::<Multi>(alias, value).unwrap().unwrap();
|
||||
assert_eq!(Some(found), cache.get::<Multi>(&format!("m:{key}")).unwrap());
|
||||
}
|
||||
// Held values survive a reopen of the store: the load installs the same holders.
|
||||
let mut reopened = cache_at(&path);
|
||||
assert!(reopened.put("m:x", &multi(None, Some("b9"))).is_err());
|
||||
assert!(reopened.put("m:x", &multi(Some("a6"), None)).is_err());
|
||||
assert!(reopened.put("m:x", &multi(Some("zz"), Some("yy"))).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_is_judged_whole() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("batch.cc");
|
||||
let mut cache = cache_at(&path);
|
||||
cache.put("a", &unique("X")).unwrap();
|
||||
cache.put("b", &unique("Y")).unwrap();
|
||||
|
||||
// A swap inside one batch lands.
|
||||
cache.put_prepared_batch(staged(&cache, &[("a", unique("Y")), ("b", unique("X"))])).unwrap();
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "Y").unwrap(), cache.get::<Unique>("a").unwrap());
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "X").unwrap(), cache.get::<Unique>("b").unwrap());
|
||||
assert_eq!(cache_at(&path).get_by_index::<Unique>("Sku", "X").unwrap().unwrap().sku, "X");
|
||||
|
||||
// A batch taking a value a record being replaced just released lands; one holding a value twice is refused whole.
|
||||
cache.put_prepared_batch(staged(&cache, &[("a", unique("Z")), ("c", unique("Y"))])).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let before = cache.snapshot();
|
||||
let text = error_text(cache.put_prepared_batch(staged(&cache, &[("p", unique("N")), ("q", unique("N")), ("r", unique("W"))])));
|
||||
assert_eq!(text, refusal("p", "q", "tests.unique_item", "Sku"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(cache.snapshot(), before);
|
||||
|
||||
// A key staged twice lands as its last envelope.
|
||||
cache.put_prepared_batch(staged(&cache, &[("d", unique("D1")), ("d", unique("D2")), ("e", unique("D1"))])).unwrap();
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "D2").unwrap(), cache.get::<Unique>("d").unwrap());
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "D1").unwrap(), cache.get::<Unique>("e").unwrap());
|
||||
}
|
||||
|
||||
// The verdict and the named pair do not depend on key order, staging order, or alias declaration order.
|
||||
#[test]
|
||||
fn the_refusal_names_the_ordinal_pair_whatever_the_order() {
|
||||
let orders: [[&str; 3]; 6] =
|
||||
[["a", "b", "c"], ["a", "c", "b"], ["b", "a", "c"], ["b", "c", "a"], ["c", "a", "b"], ["c", "b", "a"]];
|
||||
for order in orders {
|
||||
let mut cache = memory_cache();
|
||||
let rows: Vec<_> = order.iter().map(|key| (*key, unique("S"))).collect();
|
||||
let text = error_text(cache.put_prepared_batch(staged(&cache, &rows)));
|
||||
assert_eq!(text, refusal("a", "b", "tests.unique_item", "Sku"), "{order:?}");
|
||||
assert!(skus(&cache).is_empty());
|
||||
}
|
||||
// Both aliases collide in one batch: the alias sorting first is named, though `B` is declared first.
|
||||
for order in [["x", "y"], ["y", "x"]] {
|
||||
let mut cache = memory_cache();
|
||||
let rows: Vec<_> = order.iter().map(|key| (*key, multi(Some("s"), Some("t")))).collect();
|
||||
let text = error_text(cache.put_prepared_batch(staged(&cache, &rows)));
|
||||
assert_eq!(text, refusal("x", "y", "Multi", "A"));
|
||||
}
|
||||
// A held key and a landing key: the pair is ordinal, not held-first.
|
||||
let mut cache = memory_cache();
|
||||
cache.put("m", &unique("S")).unwrap();
|
||||
assert_eq!(error_text(cache.put("z", &unique("S"))), refusal("m", "z", "tests.unique_item", "Sku"));
|
||||
assert_eq!(error_text(cache.put("a", &unique("S"))), refusal("a", "m", "tests.unique_item", "Sku"));
|
||||
// A value that differs only by case or by a surrounding space is another value.
|
||||
cache.put("n", &unique("s")).unwrap();
|
||||
cache.put("o", &unique("S ")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_load_of_a_duplicate_image_is_refused_and_keeps_the_prior_view() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("load.cc");
|
||||
let mut cache = cache_at(&path);
|
||||
cache.put("item:keep", &unique("K")).unwrap();
|
||||
|
||||
let mut twin = twin_cache_at(&path);
|
||||
twin.put("item:b", &Twin { sku: "S".into(), category: "c".into() }).unwrap();
|
||||
twin.put("item:a", &Twin { sku: "S".into(), category: "c".into() }).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
|
||||
let before = cache.snapshot();
|
||||
assert_eq!(error_text(cache.pull_all_backing_stores()), refusal("item:a", "item:b", "tests.unique_item", "Sku"));
|
||||
assert_eq!(cache.snapshot(), before, "a refused pull admits nothing");
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "K").unwrap().unwrap().sku, "K");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
|
||||
// load_soa of the same image through a cache with no store.
|
||||
let table: CultCacheSoaTable = twin.snapshot_soa();
|
||||
let mut soa = memory_cache();
|
||||
soa.put("item:keep", &unique("K")).unwrap();
|
||||
let before = soa.snapshot();
|
||||
assert_eq!(error_text(soa.load_soa(table)), refusal("item:a", "item:b", "tests.unique_item", "Sku"));
|
||||
assert_eq!(soa.snapshot(), before);
|
||||
assert!(soa.get_by_index::<Unique>("Sku", "K").unwrap().is_some());
|
||||
|
||||
// A clean image loads and replaces the holders with the image's.
|
||||
let mut clean = twin_cache_at(&dir.path().join("clean.cc"));
|
||||
clean.put("item:x", &Twin { sku: "X".into(), category: "c".into() }).unwrap();
|
||||
soa.load_soa(clean.snapshot_soa()).unwrap();
|
||||
assert_eq!(soa.get_by_index::<Unique>("Sku", "K").unwrap(), None);
|
||||
assert!(soa.get_by_index::<Unique>("Sku", "X").unwrap().is_some());
|
||||
soa.put("item:y", &unique("K")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multi_index_store_with_a_duplicate_on_the_second_alias_refuses_to_open() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("multi.cc");
|
||||
{
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "multi", schema = "Multi")]
|
||||
struct Role {
|
||||
#[cultcache(key = 0)]
|
||||
b: Option<String>,
|
||||
#[cultcache(key = 1)]
|
||||
a: Option<String>,
|
||||
}
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Role>().unwrap();
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
|
||||
cache.pull_all_backing_stores().unwrap();
|
||||
cache.put("m:1", &Role { b: Some("b".into()), a: Some("a1".into()) }).unwrap();
|
||||
cache.put("m:2", &Role { b: Some("b".into()), a: Some("a2".into()) }).unwrap();
|
||||
}
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Multi>().unwrap();
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
|
||||
assert_eq!(error_text(cache.pull_all_backing_stores()), refusal("m:1", "m:2", "Multi", "B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bool_index_is_found_by_the_csharp_text() {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<VBool>().unwrap();
|
||||
cache.put("t", &VBool { v: true }).unwrap();
|
||||
assert!(cache.put("u", &VBool { v: true }).is_err());
|
||||
cache.put("f", &VBool { v: false }).unwrap();
|
||||
assert_eq!(cache.get_by_index::<VBool>("V", "True").unwrap().unwrap().v, true);
|
||||
assert_eq!(cache.get_by_index::<VBool>("V", "False").unwrap().unwrap().v, false);
|
||||
assert_eq!(cache.get_by_index::<VBool>("V", "true").unwrap(), None);
|
||||
assert_eq!(cache.get_by_index::<VBool>("V", "false").unwrap(), None);
|
||||
}
|
||||
|
||||
// ---- shared vectors, written by the C# reference ----
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Cell {
|
||||
Nil,
|
||||
Bool(bool),
|
||||
Int(i64),
|
||||
Uint(u64),
|
||||
Double(f64),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
fn vectors() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/unique-index")
|
||||
}
|
||||
|
||||
type ValueRow = (String, String, Cell, Option<String>);
|
||||
|
||||
#[test]
|
||||
fn vectors_values_give_the_csharp_index_text() {
|
||||
let bytes = std::fs::read(vectors().join("values.msgpack")).unwrap();
|
||||
let rows: Vec<ValueRow> = rmp_serde::from_slice(&bytes).unwrap();
|
||||
assert!(!rows.is_empty());
|
||||
let mut checked = 0;
|
||||
for (label, kind, value, text) in rows {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<VString>().unwrap();
|
||||
cache.register_entry_type::<VInt>().unwrap();
|
||||
cache.register_entry_type::<VUint>().unwrap();
|
||||
cache.register_entry_type::<VBool>().unwrap();
|
||||
// `double` rows are skipped: CultLookupValue has no float impl, so Rust cannot index a double member.
|
||||
match (kind.as_str(), value) {
|
||||
("string", Cell::Text(held)) => {
|
||||
cache.put("one", &VString { v: held.clone() }).unwrap();
|
||||
match text {
|
||||
Some(text) => {
|
||||
assert_eq!(cache.get_by_index::<VString>("V", &text).unwrap().unwrap().v, held, "{label}");
|
||||
assert!(cache.put("two", &VString { v: held }).is_err(), "{label}");
|
||||
}
|
||||
None => {
|
||||
cache.put("two", &VString { v: held.clone() }).unwrap();
|
||||
assert_eq!(cache.get_by_index::<VString>("V", &held).unwrap(), None, "{label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
("int64", Cell::Int(held)) => {
|
||||
cache.put("one", &VInt { v: held }).unwrap();
|
||||
assert_eq!(cache.get_by_index::<VInt>("V", &text.unwrap()).unwrap().unwrap().v, held, "{label}");
|
||||
assert!(cache.put("two", &VInt { v: held }).is_err(), "{label}");
|
||||
}
|
||||
("uint64", Cell::Uint(held)) => {
|
||||
cache.put("one", &VUint { v: held }).unwrap();
|
||||
assert_eq!(cache.get_by_index::<VUint>("V", &text.unwrap()).unwrap().unwrap().v, held, "{label}");
|
||||
assert!(cache.put("two", &VUint { v: held }).is_err(), "{label}");
|
||||
}
|
||||
("uint64", Cell::Int(held)) => {
|
||||
cache.put("one", &VUint { v: held as u64 }).unwrap();
|
||||
assert_eq!(cache.get_by_index::<VUint>("V", &text.unwrap()).unwrap().unwrap().v, held as u64, "{label}");
|
||||
}
|
||||
("int64", Cell::Uint(held)) => {
|
||||
cache.put("one", &VInt { v: held as i64 }).unwrap();
|
||||
assert_eq!(cache.get_by_index::<VInt>("V", &text.unwrap()).unwrap().unwrap().v, held as i64, "{label}");
|
||||
}
|
||||
("bool", Cell::Bool(held)) => {
|
||||
cache.put("one", &VBool { v: held }).unwrap();
|
||||
assert_eq!(cache.get_by_index::<VBool>("V", &text.unwrap()).unwrap().unwrap().v, held, "{label}");
|
||||
}
|
||||
("double", Cell::Double(_) | Cell::Int(_) | Cell::Uint(_) | Cell::Nil) => continue,
|
||||
(kind, value) => panic!("{label}: unexpected vector row {kind} {value:?}"),
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked > 10, "the vectors were read, not skipped: {checked}");
|
||||
}
|
||||
|
||||
type OutcomeRow = (String, Option<String>, String, Option<String>, Option<String>);
|
||||
|
||||
#[test]
|
||||
fn vectors_stores_hold_their_verdicts() {
|
||||
let bytes = std::fs::read(vectors().join("outcomes.msgpack")).unwrap();
|
||||
let rows: Vec<OutcomeRow> = rmp_serde::from_slice(&bytes).unwrap();
|
||||
assert!(rows.len() >= 3);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for (file, alias, verdict, k1, k2) in rows {
|
||||
let path = dir.path().join(&file);
|
||||
std::fs::copy(vectors().join(&file), &path).unwrap();
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let attach = |cache: &mut CultCache| {
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
|
||||
cache.pull_all_backing_stores()
|
||||
};
|
||||
let mut cache = CultCache::new();
|
||||
let opened = match alias.as_deref() {
|
||||
Some("Sku") => {
|
||||
cache.register_entry_type::<Unique>().unwrap();
|
||||
attach(&mut cache)
|
||||
}
|
||||
None => {
|
||||
cache.register_entry_type::<Twin>().unwrap();
|
||||
attach(&mut cache)
|
||||
}
|
||||
Some(other) => panic!("unexpected vector alias {other}"),
|
||||
};
|
||||
match verdict.as_str() {
|
||||
"open" => opened.unwrap_or_else(|error| panic!("{file} / {alias:?} should open: {error:#}")),
|
||||
"refused" => assert_eq!(
|
||||
error_text(opened),
|
||||
refusal(&k1.unwrap(), &k2.unwrap(), "tests.unique_item", "Sku"),
|
||||
"{file}"
|
||||
),
|
||||
other => panic!("unexpected verdict {other}"),
|
||||
}
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before, "{file} is byte-identical");
|
||||
}
|
||||
}
|
||||
|
||||
// A pull replaces the holder table with the stored image's: a value another writer changed is released, not kept.
|
||||
#[test]
|
||||
fn a_second_pull_replaces_the_holders_with_the_stored_image() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("pull.cc");
|
||||
let mut cache = cache_at(&path);
|
||||
cache.put("a", &unique("S")).unwrap();
|
||||
|
||||
cache_at(&path).put("a", &unique("T")).unwrap();
|
||||
cache.pull_all_backing_stores().unwrap();
|
||||
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None, "the replaced value is no longer held");
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "T").unwrap(), cache.get::<Unique>("a").unwrap());
|
||||
cache.put("b", &unique("S")).unwrap();
|
||||
assert!(cache.put("c", &unique("T")).is_err());
|
||||
}
|
||||
|
||||
// The projector decides what held records hold: declaring an index for a type that already has records derives
|
||||
// their holders, or refuses the registration when they collide.
|
||||
#[test]
|
||||
fn registering_an_index_over_held_records_derives_their_holders() {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Twin>().unwrap();
|
||||
cache.put("a", &Twin { sku: "S".into(), category: "x".into() }).unwrap();
|
||||
cache.put("b", &Twin { sku: "T".into(), category: "x".into() }).unwrap();
|
||||
|
||||
cache.register_entry_type::<Unique>().unwrap();
|
||||
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), cache.get::<Unique>("a").unwrap());
|
||||
assert!(cache.get_by_index::<Unique>("Sku", "S").unwrap().is_some());
|
||||
assert!(cache.put("c", &unique("S")).is_err(), "a held value is refused");
|
||||
cache.put("c", &unique("U")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registering_an_index_over_colliding_held_records_is_refused_and_changes_nothing() {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Twin>().unwrap();
|
||||
cache.put("a", &Twin { sku: "S".into(), category: "x".into() }).unwrap();
|
||||
cache.put("b", &Twin { sku: "S".into(), category: "y".into() }).unwrap();
|
||||
|
||||
let text = error_text(cache.register_entry_type::<Unique>());
|
||||
assert_eq!(text, refusal("a", "b", "tests.unique_item", "Sku"));
|
||||
|
||||
// Still the unindexed declaration: duplicates keep landing, nothing is looked up.
|
||||
cache.put("c", &Twin { sku: "S".into(), category: "z".into() }).unwrap();
|
||||
assert_eq!(cache.get_all_with_keys::<Twin>().unwrap().len(), 3);
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None);
|
||||
}
|
||||
|
||||
/// The same type and schema declaring Sku as an integer: the held text cannot be decoded as one.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "unique_item", schema = "tests.unique_item")]
|
||||
struct Retyped {
|
||||
#[cultcache(key = 0, index = "Sku")]
|
||||
sku: i64,
|
||||
}
|
||||
|
||||
/// A refused re-registration leaves the previous declaration in force: it keeps judging, so a later duplicate is refused.
|
||||
#[test]
|
||||
fn a_refused_reregistration_keeps_the_previous_index_judging() {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Unique>().unwrap();
|
||||
cache.put("a", &unique("S")).unwrap();
|
||||
cache.put("b", &unique("T")).unwrap();
|
||||
|
||||
// A different declaration over the same type (Category is the unique alias) collides on held records.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "unique_item", schema = "tests.unique_item")]
|
||||
struct ByCat {
|
||||
#[cultcache(key = 0)]
|
||||
sku: String,
|
||||
#[cultcache(key = 1, index = "Cat")]
|
||||
category: String,
|
||||
}
|
||||
assert!(cache.register_entry_type::<ByCat>().is_err(), "a and b both hold category c");
|
||||
|
||||
assert!(cache.put("z", &unique("S")).is_err(), "the previous index still judges");
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), cache.get::<Unique>("a").unwrap());
|
||||
cache.put("z", &unique("U")).unwrap();
|
||||
}
|
||||
|
||||
// A held record the new declaration cannot decode refuses the registration; the unindexed declaration stays.
|
||||
#[test]
|
||||
fn registering_an_index_over_an_undecodable_held_record_is_refused_and_changes_nothing() {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Twin>().unwrap();
|
||||
cache.put("a", &Twin { sku: "S".into(), category: "x".into() }).unwrap();
|
||||
|
||||
assert!(cache.register_entry_type::<Retyped>().is_err());
|
||||
|
||||
cache.put("b", &Twin { sku: "S".into(), category: "y".into() }).unwrap();
|
||||
assert_eq!(cache.get_all_with_keys::<Twin>().unwrap().len(), 2, "no index judges the duplicate");
|
||||
}
|
||||
|
||||
// Dropping the declaration releases the holders.
|
||||
#[test]
|
||||
fn registering_without_the_index_releases_the_holders() {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Unique>().unwrap();
|
||||
cache.put("a", &unique("S")).unwrap();
|
||||
cache.register_entry_type::<Twin>().unwrap();
|
||||
assert_eq!(cache.get_by_index::<Unique>("Sku", "S").unwrap(), None, "no declaration, no holders");
|
||||
cache.put("b", &Twin { sku: "S".into(), category: "x".into() }).unwrap();
|
||||
assert!(cache.register_entry_type::<Unique>().is_err(), "the duplicate now written is a held violation");
|
||||
}
|
||||
|
|
@ -3,13 +3,32 @@
|
|||
All notable changes to `@gamecult/cultcache-ts` are recorded here, following
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking
|
||||
|
||||
- A single-file store marked `cultcache.store.v3` (it holds an element id, written by the C# runtime) is read, and a rewrite keeps the mark and the ids; this runtime does not decide when a document carries ids. A runtime from before this change refuses a v3 store by name, so rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading.
|
||||
- `indexes` are unique, as C# `[CultIndex]`: a write or a load that leaves two records of a type holding one non-blank
|
||||
value under one alias is refused naming both keys and the alias, and writes nothing. Index text is the C# canonical
|
||||
text (booleans `True`/`False`, integers as digits, doubles as the exact expansion, blank values never indexed), so
|
||||
an empty string is no longer a value. `registerIndex` over held duplicates installs nothing. A new `roles` map names
|
||||
the many-row selection aliases (never indexed for lookup, same catalog `indexAlias`). The per-key migration rule is
|
||||
in the README; classify every key before the CultLib your tools load is updated. The refusal verdict is the contract, its text is informational.
|
||||
|
||||
### Changed
|
||||
|
||||
- Opening a legacy (pre-v1) store is a pure read: payloads that were not bytes are normalized in memory and the
|
||||
file is left as it was, so a codec that refuses the content cannot lose the old bytes. The next push, delete or
|
||||
flush rewrites the file as v1, merging what the open read.
|
||||
- `SingleFileStoreRead` no longer carries `repairedLegacyPayload`; no consumer used it.
|
||||
|
||||
## [0.15.0]
|
||||
|
||||
0.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 written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name.
|
||||
- A single-file store refuses a zero-byte file as `StoreUnreadableError`; only a path with nothing at it is an empty store. A header this runtime does not read is refused, and the refusal echoes the header only as `cultcache.store.v<digits>`.
|
||||
- A 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.
|
||||
|
|
@ -41,8 +60,6 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
|
|||
- The Windows replace is a POSIX-semantics rename first (it lands while C# and Rust readers
|
||||
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.
|
||||
- Opening a legacy store that needs healing rewrites it under the lock, from a read taken
|
||||
under the lock.
|
||||
|
||||
### Dependencies
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ const payload = encodeDocumentPayload(noteDocument, note); // DocumentPayload
|
|||
|
||||
Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins. The whole cache without the single-file store is `@gamecult/cultcache-ts/browser`, which is also what bundlers resolve for the package root (`browser` condition); bring your own `CacheBackingStore`.
|
||||
|
||||
## Store format and rollback
|
||||
|
||||
A store whose elements hold an id is marked `cultcache.store.v3` by the C# runtime. This runtime reads a v3 store, keeps the header it read and carries the ids through a rewrite; it never drops the mark, so it has no carry-back. A cultcache-ts from before element ids refuses a v3 store by name, so rolling back to it after a store has been written as v3 is one-way: restore from a copy taken before upgrading, and upgrade every reader before any writer. (The C# line is different: CultLib 1.x does not refuse; see the GameCult.Caching README.)
|
||||
|
||||
## `.cc` Inspection
|
||||
|
||||
CultCache files may use the `.cc` extension. The bytes are still the canonical
|
||||
|
|
@ -93,12 +97,16 @@ const itemDocument = defineDocumentType({
|
|||
schemaVersion: "item.v1",
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
sku: z.string(),
|
||||
category: z.string(),
|
||||
value: z.number().int(),
|
||||
}),
|
||||
name: "name",
|
||||
indexes: {
|
||||
category: "category",
|
||||
sku: "sku", // unique: a sku names one item
|
||||
},
|
||||
roles: {
|
||||
category: "category", // a selection alias: many items share a category
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -122,6 +130,7 @@ await cache.pullAllBackingStores();
|
|||
|
||||
await cache.put(itemDocument, "item:potion", {
|
||||
name: "Potion",
|
||||
sku: "POT-001",
|
||||
category: "Consumable",
|
||||
value: 50,
|
||||
});
|
||||
|
|
@ -132,7 +141,7 @@ await cache.putGlobal(settingsDocument, {
|
|||
});
|
||||
|
||||
const potion = cache.getByName(itemDocument, "Potion");
|
||||
const consumable = cache.getByIndex(itemDocument, "category", "Consumable");
|
||||
const bySku = cache.getByIndex(itemDocument, "sku", "POT-001");
|
||||
const settings = cache.getRequiredGlobal(settingsDocument);
|
||||
```
|
||||
|
||||
|
|
@ -194,7 +203,7 @@ A document type has exactly one home store
|
|||
|
||||
## Name, Index, and Global Semantics
|
||||
|
||||
`name` and `indexes` can be declared on the document definition:
|
||||
`name`, `indexes` and `roles` can be declared on the document definition:
|
||||
|
||||
```ts
|
||||
const playerDocument = defineDocumentType({
|
||||
|
|
@ -202,17 +211,70 @@ const playerDocument = defineDocumentType({
|
|||
schema: playerSchema,
|
||||
name: "displayName",
|
||||
indexes: {
|
||||
faction: "faction",
|
||||
email: "email",
|
||||
email: "email", // unique lookup
|
||||
},
|
||||
roles: {
|
||||
faction: "faction", // selection alias, many players share one
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`indexes` are unique (variants:ruling:unique-indexes; C# `[CultIndex]`, Rust `#[cultcache(index)]`). Within one type
|
||||
and one alias no two records hold one non-blank value, judged on the result of every write and of every load; a
|
||||
refusal reads `Records <k1> and <k2> (<schema name>) both hold unique index '<alias>'; an index value names one record.`
|
||||
(keys in ordinal order, never the value), and writes nothing. The contract is the verdict, which stores and writes are
|
||||
refused; the text is informational. On a load TS judges record by record in file order and names the first collision
|
||||
it meets, where C# judges the whole store, so the two runtimes can name different pairs of one store. A blank value (absent, empty, or only whitespace) is
|
||||
never indexed and never conflicts. Two types may share an alias and a value. Index values are compared as the C#
|
||||
reference renders them: strings as is (never trimmed), `True`/`False`, integers as digits, a non-integral number as
|
||||
its exact decimal expansion; NaN and infinities have no value. `roles` name selection and reference aliases (the
|
||||
member's catalog `indexAlias`, so the catalog bytes are the same either way); they imply nothing about uniqueness and
|
||||
are never indexed for lookup, so `getByIndex` on a role answers `undefined`. An alias is declared in one of the two.
|
||||
|
||||
### Migrating an existing `indexes` key
|
||||
|
||||
Apply per key, and do it BEFORE the CultLib checkout your tools load is rebuilt or updated. A consumer that loads
|
||||
CultLib at run time (Bifrost reads `$VOIDBOT_CULTLIB_ROOT`, else the sibling `../CultLib` dist) has no pin to hold: its
|
||||
next start after the checkout moves is the bump. A consumer that pins a version applies this before it bumps the pin.
|
||||
Moving an `indexes` key to `roles` is safe on the old CultLib too when the definition lists `members` explicitly (an old
|
||||
codec definition without `members` derives `indexAlias` from `indexes` only, so a moved key would lose its catalog alias),
|
||||
so the consumer edit lands first:
|
||||
|
||||
1. Nothing looks records up by the key (`getByIndex`, `getKeyByIndex`, `getIdByIndex`; Python `get_by_index`,
|
||||
`get_key_by_index`, `node.get_by_index`, `watch_by_index`; Rust `get_by_index`): it is a role. Move it to `roles`;
|
||||
where a definition lists `members` explicitly, delete it from `indexes` and keep the member's `indexAlias` (the
|
||||
catalog is the same).
|
||||
2. Something looks it up: it stays unique only when two live records can never hold one non-blank value, because the
|
||||
record key is derived from that value alone, or the value is an outside identity (email, sku, external id).
|
||||
Enums, statuses, kinds, categories, parent or foreign ids, shared hashes and timestamps are roles; a lookup on
|
||||
one already answered an arbitrary record, so move it to a selection (`any_of`) or `getAll` plus a filter.
|
||||
3. Before the checkout moves, open each real `.cc` once with the new definitions. A refusal names both keys and the alias and
|
||||
writes nothing.
|
||||
|
||||
Misclassifying costs differently each way. A role declared unique: the second record holding a shared value is
|
||||
refused at commit, and a store already holding one refuses to open, so that store is down until the key is
|
||||
reclassified (loud, names both keys and the alias). A unique key declared a role: lookups by it answer nothing and
|
||||
duplicates stop being refused.
|
||||
|
||||
The Bifrost TS stores (at 781e6ea) call no lookup anywhere, so by rule 1 every key is a role and no key becomes unique.
|
||||
All six definitions list `members` explicitly, each with its `indexAlias`, so the edit is deleting each `indexes` block:
|
||||
the catalog is unchanged and the edit is safe on the CultLib it loads today. It must land before that checkout is
|
||||
updated:
|
||||
|
||||
| Definition | Keys | Verdict |
|
||||
|---|---|---|
|
||||
| `update-request` (`tools/agent-transport.mjs`) | `targetRepoName`, `targetAgentIdentity`, `status` | role: many requests per repo and agent; status is an enum |
|
||||
| `crossing_receipt` (`tools/bifrost-crossing-documents.mjs`) | `commandId`, `crossingKind`, `status` | role: receipts are `crossing_<commandId>_<activityKind>`, several per command; the rest are enums |
|
||||
| release authority (`tools/bifrost-repository-release-authority.mjs`) | `repositoryFullName`, `upstreamRef`, `commitSha`, `status` | role: the authority id is derived from repo, ref and sha, so one sha under two refs is two records |
|
||||
| `topic` (`tools/governance-threads.mjs`) | `jurisdictionRepoName`, `jurisdictionAgentIdentity`, `status` | role: many topics per jurisdiction |
|
||||
| `comment` (`tools/governance-threads.mjs`) | `topicId`, `authorId`, `stance` | role: many comments per topic; stance is an enum |
|
||||
| `advertisement` (`tools/provider-advertisement.mjs`) | `serviceName` | role: one record holds it today, but nothing looks it up |
|
||||
|
||||
You can also register them later if you feel like changing the machine after boot:
|
||||
|
||||
```ts
|
||||
await cache.registerNameLookup(playerDocument, "displayName");
|
||||
await cache.registerIndex(playerDocument, "faction", "faction");
|
||||
await cache.registerIndex(playerDocument, "email", "email");
|
||||
```
|
||||
|
||||
Registration, `addBackingStore`, `pullAllBackingStores` and every write and
|
||||
|
|
@ -357,7 +419,7 @@ That is the TypeScript version of the original CultCache trick:
|
|||
- the top-level MessagePack value is `[formatVersion, schemaCatalog, records]`
|
||||
- each record is `[key, schemaId, storedAt, payload]`
|
||||
- each catalog entry is `[schemaId, schemaName, schemaVersion, contentHash, canonicalSchemaJson, compatibleSchemaIds, members]`
|
||||
- it still reads the older TS/Rust-style envelope array and rewrites repaired legacy payloads as v1 snapshots
|
||||
- it still reads the older TS/Rust-style envelope array (object payloads are normalized in memory; opening never rewrites the file) and replaces it with a v1 snapshot on the next push, delete or flush
|
||||
- it queues local writes in order, then takes the shared `<file>.lock` (`flock` on Unix, `LockFileEx` on Windows, via koffi, loaded on the first write) for each one, so C#, Rust and TypeScript writers exclude each other; the read a write merges from is taken under that lock
|
||||
- it fsyncs the staged file and, on Unix, the directory after the replace; on Windows it replaces with a POSIX-semantics rename and falls back to a write-through `MoveFileExW`, retrying briefly on sharing violations
|
||||
- it persists raw MessagePack payload bytes inside schema-addressed records
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
declaresRole,
|
||||
decodeDocumentPayload,
|
||||
documentPayloadBytes,
|
||||
documentPayloadFromBytes,
|
||||
|
|
@ -224,7 +225,7 @@ export class CultCache {
|
|||
return this.#serial(() => {
|
||||
const registered = this.#requireDefinition(definition);
|
||||
const nameAccessor = this.#compileAccessor(definition.type, "name", accessor);
|
||||
const derived = this.#deriveTypeLookups(definition.type, nameAccessor, registered.indexAccessors);
|
||||
const derived = this.#deriveTypeLookups(definition.type, registered.catalogEntry.schemaName, nameAccessor, registered.indexAccessors);
|
||||
this.#installAccessors(registered, nameAccessor, registered.indexAccessors, derived);
|
||||
return definition;
|
||||
});
|
||||
|
|
@ -240,10 +241,14 @@ export class CultCache {
|
|||
throw new Error(`CultCache index names for type "${definition.type}" must be non-empty.`);
|
||||
}
|
||||
|
||||
if (declaresRole(definition, indexName)) {
|
||||
throw new Error(`Index "${indexName}" of ${definition.type} is declared as a role; a role is never a unique index.`);
|
||||
}
|
||||
|
||||
const registered = this.#requireDefinition(definition);
|
||||
const indexAccessors = new Map(registered.indexAccessors);
|
||||
indexAccessors.set(indexName, this.#compileAccessor(definition.type, `index "${indexName}"`, accessor));
|
||||
const derived = this.#deriveTypeLookups(definition.type, registered.nameAccessor, indexAccessors);
|
||||
const derived = this.#deriveTypeLookups(definition.type, registered.catalogEntry.schemaName, registered.nameAccessor, indexAccessors);
|
||||
this.#installAccessors(registered, registered.nameAccessor, indexAccessors, derived);
|
||||
return definition;
|
||||
});
|
||||
|
|
@ -418,6 +423,7 @@ export class CultCache {
|
|||
return key ? this.get(definition, key) : undefined;
|
||||
}
|
||||
|
||||
// A unique index: one key per non-blank value. A roles alias is never indexed and answers undefined.
|
||||
getKeyByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
indexName: string,
|
||||
|
|
@ -579,7 +585,7 @@ export class CultCache {
|
|||
if (existingBySchemaName && existingBySchemaName !== registered) {
|
||||
throw refuse(existingBySchemaName, registered.catalogEntry, `schema name "${registered.catalogEntry.schemaName}"`);
|
||||
}
|
||||
const derived = this.#deriveTypeLookups(definition.type, nameAccessor, indexAccessors);
|
||||
const derived = this.#deriveTypeLookups(definition.type, registered.catalogEntry.schemaName, nameAccessor, indexAccessors);
|
||||
|
||||
// Every check and accessor has run; install.
|
||||
this.#definitions.set(definition.type, registered);
|
||||
|
|
@ -795,11 +801,13 @@ export class CultCache {
|
|||
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
return value;
|
||||
case "number":
|
||||
return isBlankIndexText(value) ? undefined : value;
|
||||
case "boolean":
|
||||
return value ? "True" : "False";
|
||||
case "bigint":
|
||||
return String(value);
|
||||
return value.toString();
|
||||
case "number":
|
||||
return canonicalNumberText(value);
|
||||
default:
|
||||
throw new Error(`CultCache ${label} accessor for type "${type}" produced a non-scalar value.`);
|
||||
}
|
||||
|
|
@ -894,6 +902,8 @@ export class CultCache {
|
|||
}
|
||||
}
|
||||
|
||||
const lookups = this.#lookupsFor(registered.nameAccessor, registered.indexAccessors, value);
|
||||
this.#refuseIndexSharing(state, entry.type, registered.catalogEntry.schemaName, entry.key, lookups);
|
||||
return {
|
||||
...entry,
|
||||
payload: this.#cloneBytes(entry.payload),
|
||||
|
|
@ -902,10 +912,22 @@ export class CultCache {
|
|||
schemaId: registered.catalogEntry.schemaId,
|
||||
catalogEntry: registered.catalogEntry,
|
||||
value,
|
||||
lookups: this.#lookupsFor(registered.nameAccessor, registered.indexAccessors, value),
|
||||
lookups,
|
||||
};
|
||||
}
|
||||
|
||||
// The one index rule (variants:ruling:unique-indexes): within one type and one unique alias, no two records hold one
|
||||
// non-blank value. The held key at the landing record's own key is its previous self and keeps its value. The message
|
||||
// names both keys, ordinal, and the alias, never the value.
|
||||
#refuseIndexSharing(state: HydratedState, type: string, schemaName: string, key: string, lookups: EntryLookups): void {
|
||||
for (const alias of [...lookups.indexes.keys()].sort(compareOrdinal)) {
|
||||
const held = state.indexToKeyMaps.get(this.#indexId(type, alias))?.get(lookups.indexes.get(alias)!);
|
||||
if (held !== undefined && held !== key) {
|
||||
throw indexSharingRefusal(held, key, schemaName, alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Infallible half of admission: replaces any held record at the same key and installs the admitted one.
|
||||
#installEntry(state: HydratedState, registered: RegisteredDefinition, hydrated: HydratedEntry): void {
|
||||
const entryId = this.#entryId(hydrated.type, hydrated.key);
|
||||
|
|
@ -966,12 +988,14 @@ export class CultCache {
|
|||
// Runs the candidate accessors over every held record of the type into local maps.
|
||||
#deriveTypeLookups(
|
||||
type: string,
|
||||
schemaName: string,
|
||||
nameAccessor: Accessor | undefined,
|
||||
indexAccessors: ReadonlyMap<string, Accessor>,
|
||||
): DerivedTypeLookups {
|
||||
const entryLookups: Array<[HydratedEntry, EntryLookups]> = [];
|
||||
const nameMap = nameAccessor ? new Map<string, string>() : undefined;
|
||||
const indexMaps = new Map<string, Map<string, string>>();
|
||||
const sharing = new Map<string, string[]>();
|
||||
for (const indexName of indexAccessors.keys()) {
|
||||
indexMaps.set(indexName, new Map());
|
||||
}
|
||||
|
|
@ -983,10 +1007,23 @@ export class CultCache {
|
|||
nameMap?.set(lookups.name, entry.key);
|
||||
}
|
||||
for (const [indexName, indexValue] of lookups.indexes) {
|
||||
indexMaps.get(indexName)?.set(indexValue, entry.key);
|
||||
const map = indexMaps.get(indexName)!;
|
||||
const holder = map.get(indexValue);
|
||||
if (holder === undefined) {
|
||||
map.set(indexValue, entry.key);
|
||||
} else {
|
||||
sharing.set(`${indexName}\u0000${indexValue}`, [...(sharing.get(`${indexName}\u0000${indexValue}`) ?? [holder]), entry.key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Judged whole: any value held by two records refuses, naming the two smallest of its keys. Held records already
|
||||
// passed the judge for every alias installed before, so only the alias being registered can be shared here.
|
||||
for (const [id, keys] of sharing) {
|
||||
keys.sort(compareOrdinal);
|
||||
throw indexSharingRefusal(keys[0], keys[1], schemaName, id.slice(0, id.indexOf("\u0000")));
|
||||
}
|
||||
|
||||
return { entryLookups, nameMap, indexMaps };
|
||||
}
|
||||
|
||||
|
|
@ -1057,3 +1094,42 @@ export class CultCache {
|
|||
return documentPayloadFromBytes(new Uint8Array(documentPayloadBytes(payload)));
|
||||
}
|
||||
}
|
||||
|
||||
function compareOrdinal(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
function indexSharingRefusal(keyA: string, keyB: string, schemaName: string, alias: string): Error {
|
||||
const [first, second] = compareOrdinal(keyA, keyB) <= 0 ? [keyA, keyB] : [keyB, keyA];
|
||||
return new Error(`Records ${first} and ${second} (${schemaName}) both hold unique index '${alias}'; an index value names one record.`);
|
||||
}
|
||||
|
||||
// Blank is no value: empty or only C# White_Space characters. Not JavaScript's \s, which counts U+FEFF and omits U+0085.
|
||||
const BLANK_INDEX_TEXT = /^[\t-\r \u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]*$/;
|
||||
|
||||
function isBlankIndexText(value: string): boolean {
|
||||
return BLANK_INDEX_TEXT.test(value);
|
||||
}
|
||||
|
||||
// The canonical text of a number, as C# RenderCanonicalNumber: integers as their digits (-0 is 0), a non-integral finite
|
||||
// double as its exact decimal expansion, NaN and infinities as no value.
|
||||
function canonicalNumberText(value: number): string | undefined {
|
||||
if (!Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
if (Number.isInteger(value)) {
|
||||
return BigInt(value).toString();
|
||||
}
|
||||
|
||||
// value = mantissa * 2^exponent with exponent < 0, so value = mantissa * 5^k / 10^k for k = -exponent.
|
||||
const view = new DataView(new ArrayBuffer(8));
|
||||
view.setFloat64(0, value);
|
||||
const bits = view.getBigUint64(0);
|
||||
const biasedExponent = Number((bits >> 52n) & 0x7ffn);
|
||||
const fraction = bits & 0xfffffffffffffn;
|
||||
const mantissa = biasedExponent === 0 ? fraction : fraction | (1n << 52n);
|
||||
const k = -((biasedExponent === 0 ? 1 : biasedExponent) - 1075);
|
||||
const digits = (mantissa * 5n ** BigInt(k)).toString().padStart(k + 1, "0");
|
||||
const text = `${digits.slice(0, digits.length - k)}.${digits.slice(digits.length - k)}`.replace(/0+$/, "").replace(/\.$/, "");
|
||||
return value < 0 ? `-${text}` : text;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,23 +32,22 @@ export function defineDocumentType<TSchema extends CultCacheSchema>(
|
|||
throw new Error(`CultCache document type "${definition.type}" declares an invalid name accessor.`);
|
||||
}
|
||||
|
||||
if (Array.isArray(definition.indexes)) {
|
||||
const seen = new Set<string>();
|
||||
for (const index of definition.indexes) {
|
||||
if (!index.name || index.name.trim().length === 0) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`);
|
||||
}
|
||||
if (seen.has(index.name)) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares duplicate index "${index.name}".`);
|
||||
}
|
||||
seen.add(index.name);
|
||||
}
|
||||
} else if (definition.indexes) {
|
||||
for (const name of Object.keys(definition.indexes)) {
|
||||
const declared = new Set<string>();
|
||||
for (const [kind, set] of [["index", definition.indexes], ["role", definition.roles]] as const) {
|
||||
const names = new Set<string>();
|
||||
for (const name of Array.isArray(set) ? set.map((entry) => entry.name) : set ? Object.keys(set) : []) {
|
||||
if (!name || name.trim().length === 0) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`);
|
||||
throw new Error(`CultCache document type "${definition.type}" declares ${kind === "index" ? "an index" : "a role"} with an empty name.`);
|
||||
}
|
||||
if (names.has(name)) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares duplicate ${kind} "${name}".`);
|
||||
}
|
||||
if (kind === "role" && declared.has(name)) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares "${name}" as both an index and a role; an index already names its role.`);
|
||||
}
|
||||
names.add(name);
|
||||
}
|
||||
for (const name of names) declared.add(name);
|
||||
}
|
||||
|
||||
if (definition.members) {
|
||||
|
|
@ -132,16 +131,25 @@ export function encodeDocumentPayload(definition: AnyCultCacheDocumentDefinition
|
|||
return documentPayloadFromBytes(bytes);
|
||||
}
|
||||
|
||||
// Whether the definition declares `name` as a role alias (either `roles` form).
|
||||
export function declaresRole(definition: AnyCultCacheDocumentDefinition, name: string): boolean {
|
||||
const roles = definition.roles;
|
||||
return Array.isArray(roles) ? roles.some((entry) => entry.name === name) : roles ? Object.hasOwn(roles, name) : false;
|
||||
}
|
||||
|
||||
// The schema members of a document: the ones it declares, else those its codec derives, else none.
|
||||
export function resolveDocumentMembers(definition: AnyCultCacheDocumentDefinition): readonly CultCacheSchemaCatalogMember[] {
|
||||
if (definition.members || !definition.codec) {
|
||||
return definition.members ?? [];
|
||||
}
|
||||
const codec = definition.codec;
|
||||
const indexAlias = (slotName: string) =>
|
||||
!Array.isArray(definition.indexes) && definition.indexes
|
||||
? Object.entries(definition.indexes).find(([, accessor]) => accessor === slotName)?.[0] ?? null
|
||||
: null;
|
||||
const indexAlias = (slotName: string) => {
|
||||
for (const set of [definition.indexes, definition.roles]) {
|
||||
const alias = !Array.isArray(set) && set ? Object.entries(set).find(([, accessor]) => accessor === slotName)?.[0] : undefined;
|
||||
if (alias !== undefined) return alias;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return codec.members.map((member, index) => ({
|
||||
...member,
|
||||
isName: (definition.name as unknown) === codec.names[index],
|
||||
|
|
|
|||
|
|
@ -47,21 +47,11 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
}
|
||||
|
||||
async pullAll(): Promise<CultCacheEnvelope[]> {
|
||||
// The disk decides the header: a file that is gone or legacy is not marked.
|
||||
this.#format = STORE_FORMAT_VERSION;
|
||||
// A pure read: the disk decides the header, and a legacy file is normalized in memory only. The file is rewritten
|
||||
// as v1 by the next push, delete or flush, which merges what this read returns; opening never replaces bytes the
|
||||
// consumer's codec has not yet accepted.
|
||||
const disk = this.#readDisk();
|
||||
this.#format = disk.format;
|
||||
if (disk.repairedLegacyPayload) {
|
||||
// The heal decides from its own read, taken under the store lock.
|
||||
await this.#enqueue(async () => {
|
||||
const again = this.#readDisk();
|
||||
this.#format = again.format;
|
||||
if (again.repairedLegacyPayload) {
|
||||
await this.#writeAll([], again.envelopes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return disk.envelopes;
|
||||
}
|
||||
|
||||
|
|
@ -158,7 +148,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
export function readSingleFileStore(path: string): SingleFileStoreRead {
|
||||
const data = readStoreBytes(path);
|
||||
if (data === undefined) {
|
||||
return { absent: true, format: STORE_FORMAT_VERSION, envelopes: [], repairedLegacyPayload: false };
|
||||
return { absent: true, format: STORE_FORMAT_VERSION, envelopes: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -395,8 +385,6 @@ export type SingleFileStoreRead = {
|
|||
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<SingleFileStoreRead, "absent"> {
|
||||
|
|
@ -420,7 +408,7 @@ function decodeStoreFile(data: Uint8Array): Omit<SingleFileStoreRead, "absent">
|
|||
storedAt: record.storedAt,
|
||||
};
|
||||
});
|
||||
return { format: snapshot.format, envelopes, repairedLegacyPayload: false };
|
||||
return { format: snapshot.format, envelopes };
|
||||
}
|
||||
|
||||
const legacy = decodeLegacyEnvelopeArray(decoded);
|
||||
|
|
@ -428,19 +416,8 @@ function decodeStoreFile(data: Uint8Array): Omit<SingleFileStoreRead, "absent">
|
|||
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 };
|
||||
const normalized = legacy.map((entry) => ({ ...entry, payload: normalizePayload(entry.payload) }));
|
||||
return { format: STORE_FORMAT_VERSION, envelopes: envelopeArraySchema.parse(normalized).map((entry) => ({ ...entry, payload: documentPayloadFromBytes(entry.payload) })) as CultCacheEnvelope[] };
|
||||
}
|
||||
|
||||
function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined {
|
||||
|
|
|
|||
|
|
@ -59,9 +59,21 @@ export interface CultCacheDocumentDefinition<TSchema extends CultCacheSchema = C
|
|||
members?: readonly CultCacheSchemaCatalogMember[];
|
||||
global?: boolean;
|
||||
name?: CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>;
|
||||
/**
|
||||
* Unique lookup aliases. A non-blank value names one record of the type: a write that would leave two records holding
|
||||
* one value, and a store that already holds two, is refused naming both keys and the alias. Blank values (absent,
|
||||
* empty, or only whitespace) are never indexed. `getByIndex` and `getKeyByIndex` answer from these.
|
||||
*/
|
||||
indexes?:
|
||||
| Readonly<Record<string, CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>>>
|
||||
| readonly CultCacheDocumentIndexDefinition<InferCultCacheSchemaValue<TSchema>>[];
|
||||
/**
|
||||
* Selection and reference aliases: the member's catalog `indexAlias`. A role implies nothing about uniqueness, many
|
||||
* records may share a value, and it is never indexed for lookup. An alias is declared in `indexes` or `roles`, not both.
|
||||
*/
|
||||
roles?:
|
||||
| Readonly<Record<string, CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>>>
|
||||
| readonly CultCacheDocumentIndexDefinition<InferCultCacheSchemaValue<TSchema>>[];
|
||||
formatter?: CultCacheDocumentFormatter<InferCultCacheSchemaValue<TSchema>>;
|
||||
codec?: SlotsCodec<InferCultCacheSchemaValue<TSchema> & object>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -504,46 +504,56 @@ test("SingleFileMessagePackBackingStore refuses a record whose schema the catalo
|
|||
await assert.rejects(() => cache.pullAllBackingStores(), StoreUnreadableError);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was persisted as an object", async () => {
|
||||
const noteDocument = defineDocumentType({
|
||||
type: "note",
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
}),
|
||||
});
|
||||
const legacyNoteType = defineDocumentType({
|
||||
type: "note",
|
||||
schema: z.object({ title: z.string(), body: z.string().optional() }),
|
||||
});
|
||||
|
||||
// SYNTHETIC pre-v1 file (no real one is known): an envelope array with object payloads.
|
||||
async function legacyNoteStore(second: unknown): Promise<{ storePath: string; bytes: Uint8Array }> {
|
||||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "legacy.msgpack");
|
||||
const envelopePayload = {
|
||||
title: "Hello",
|
||||
body: "world",
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
storePath,
|
||||
encode([
|
||||
{
|
||||
key: "note:hello",
|
||||
type: "note",
|
||||
payload: envelopePayload,
|
||||
storedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const bytes = encode([
|
||||
{ key: "note:hello", type: "note", payload: { title: "Hello", body: "world" }, storedAt: "2026-10-20T00:00:00Z" },
|
||||
{ key: "note:other", type: "note", payload: second, storedAt: "2026-10-20T00:00:00Z" },
|
||||
]);
|
||||
await writeFile(storePath, bytes);
|
||||
return { storePath, bytes };
|
||||
}
|
||||
|
||||
test("a codec that refuses a synthetic pre-v1 store's content leaves the file byte-identical", async () => {
|
||||
const { storePath, bytes } = await legacyNoteStore({ title: 42 });
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(noteDocument)
|
||||
.withDocumentType(legacyNoteType)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
|
||||
await assert.rejects(() => cache.pullAllBackingStores());
|
||||
assert.deepEqual([...(await readFile(storePath))], [...bytes]);
|
||||
assert.deepEqual((await readdir(dirname(storePath))).sort(), ["legacy.msgpack"]);
|
||||
});
|
||||
|
||||
test("a synthetic pre-v1 store is read untouched and becomes v1 only when the consumer writes, keeping its other entries", async () => {
|
||||
const { storePath, bytes } = await legacyNoteStore({ title: "Other" });
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(legacyNoteType)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
|
||||
await cache.pullAllBackingStores();
|
||||
assert.deepEqual(cache.getRequired(noteDocument, "note:hello"), envelopePayload);
|
||||
assert.deepEqual(cache.getRequired(legacyNoteType, "note:hello"), { title: "Hello", body: "world" });
|
||||
assert.deepEqual([...(await readFile(storePath))], [...bytes], "reading does not rewrite the file");
|
||||
|
||||
const rewritten = decode(await readFile(storePath)) as unknown[];
|
||||
assert.equal(rewritten[0], "cultcache.store.v1");
|
||||
const records = rewritten[2] as unknown[][];
|
||||
assert.equal(records.length, 1);
|
||||
assert.ok(records[0]?.[3] instanceof Uint8Array);
|
||||
await cache.put(legacyNoteType, "note:new", { title: "New" });
|
||||
assert.equal((decode(await readFile(storePath)) as unknown[])[0], "cultcache.store.v1");
|
||||
|
||||
const reopened = CultCache.builder()
|
||||
.withDocumentType(legacyNoteType)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
await reopened.pullAllBackingStores();
|
||||
assert.deepEqual(reopened.getRequired(legacyNoteType, "note:hello"), { title: "Hello", body: "world" });
|
||||
assert.deepEqual(reopened.getRequired(legacyNoteType, "note:other"), { title: "Other" });
|
||||
assert.deepEqual(reopened.getRequired(legacyNoteType, "note:new"), { title: "New" });
|
||||
});
|
||||
|
||||
test("CultCache rejects a second generic backing store", async () => {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ const itemDocument = defineDocumentType({
|
|||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -471,44 +471,70 @@ test("the store lock is released when the operation throws", async () => {
|
|||
}
|
||||
});
|
||||
|
||||
// SYNTHETIC pre-v1 vector: no real file of this shape is known to exist. A top-level array of {key,type,payload,storedAt}
|
||||
// whose payload is not binary (an object, a Node Buffer JSON, an int array) is what the removed heal-on-open rewrote.
|
||||
const legacyObjectPayload = (): Uint8Array =>
|
||||
encode([{ key: "note:hello", type: "note", payload: { title: "Hello" }, storedAt: "2026-10-20T00:00:00Z" }]);
|
||||
|
||||
test("an open that heals a legacy store waits for the store lock and heals from its own read", async () => {
|
||||
test("opening a synthetic pre-v1 store with object payloads returns them normalized and leaves the file untouched, even while another holder has the lock", async () => {
|
||||
const directory = await scratch();
|
||||
try {
|
||||
const path = join(directory, "legacy.cc");
|
||||
const legacy = legacyObjectPayload();
|
||||
await writeFile(path, legacy);
|
||||
const store = new SingleFileMessagePackBackingStore(path);
|
||||
let healed: Promise<unknown> | undefined;
|
||||
let settled = false;
|
||||
await withStoreWriteLock(path, async () => {
|
||||
healed = store.pullAll().then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await sleepFor(300);
|
||||
assert.equal(settled, false, "the heal must wait for the lock");
|
||||
assert.deepEqual([...(await readFile(path))], [...legacy], "the file keeps its legacy bytes while the lock is held elsewhere");
|
||||
const [lockHeld] = await Promise.all([readdir(directory)]);
|
||||
const envelopes = await within(store.pullAll(), 5000, "an open does not wait for the write lock");
|
||||
assert.deepEqual(envelopes.map((entry) => entry.key), ["note:hello"]);
|
||||
assert.deepEqual(decode(documentPayloadBytes(envelopes[0].payload)), { title: "Hello" });
|
||||
assert.deepEqual([...(await readFile(path))], [...legacy]);
|
||||
assert.deepEqual((await readdir(directory)).sort(), lockHeld.sort(), "an open adds no lock or temp file");
|
||||
});
|
||||
await within(healed!, 5000, "the heal");
|
||||
assert.equal((decode(await readFile(path)) as unknown[])[0], "cultcache.store.v1");
|
||||
assert.deepEqual([...(await readFile(path))], [...legacy]);
|
||||
assert.deepEqual((await readdir(directory)).filter((name) => name !== "legacy.cc" && name !== "legacy.cc.lock"), []);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("push and delete over a legacy store that needs healing finish instead of waiting on their own lock", async () => {
|
||||
test("opening a synthetic pre-v1 store with Buffer-JSON and int-array payloads normalizes them in memory and leaves the file untouched", async () => {
|
||||
const directory = await scratch();
|
||||
try {
|
||||
const path = join(directory, "legacy.cc");
|
||||
const inner = encode({ title: "Hello" });
|
||||
const legacy = encode([
|
||||
{ key: "a", type: "note", payload: { type: "Buffer", data: [...inner] }, storedAt: "2026-10-20T00:00:00Z" },
|
||||
{ key: "b", type: "note", payload: [...inner], storedAt: "2026-10-20T00:00:00Z" },
|
||||
]);
|
||||
await writeFile(path, legacy);
|
||||
const envelopes = await new SingleFileMessagePackBackingStore(path).pullAll();
|
||||
assert.deepEqual(envelopes.map((entry) => entry.key), ["a", "b"]);
|
||||
for (const envelope of envelopes) {
|
||||
assert.deepEqual(decode(documentPayloadBytes(envelope.payload)), { title: "Hello" });
|
||||
}
|
||||
assert.deepEqual([...(await readFile(path))], [...legacy]);
|
||||
assert.deepEqual(await readdir(directory), ["legacy.cc"]);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("push and delete over a synthetic pre-v1 store write v1 and keep the other legacy entries, normalized payloads included", async () => {
|
||||
const directory = await scratch();
|
||||
try {
|
||||
const path = join(directory, "legacy.cc");
|
||||
await writeFile(path, legacyObjectPayload());
|
||||
const store = new SingleFileMessagePackBackingStore(path);
|
||||
await within(store.push(entryOf("a")), 5000, "push");
|
||||
assert.equal((decode(await readFile(path)) as unknown[])[0], "cultcache.store.v1");
|
||||
assert.deepEqual((await store.pullAll()).map((entry) => entry.key).sort(), ["a", "note:hello"]);
|
||||
await writeFile(path, legacyObjectPayload());
|
||||
await within(store.delete(entryOf("a")), 5000, "delete");
|
||||
const keys = (await store.pullAll()).map((entry) => entry.key);
|
||||
assert.deepEqual(keys, ["note:hello"]);
|
||||
assert.equal((decode(await readFile(path)) as unknown[])[0], "cultcache.store.v1");
|
||||
const kept = await store.pullAll();
|
||||
assert.deepEqual(kept.map((entry) => entry.key), ["note:hello"]);
|
||||
assert.deepEqual(decode(documentPayloadBytes(kept[0].payload)), { title: "Hello" });
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -697,7 +723,7 @@ test("a store file of an unrecognized shape is refused, and a store naming a mis
|
|||
}
|
||||
});
|
||||
|
||||
test("opening a store that needs no healing writes nothing: a missing store leaves no lock file, a legacy store with byte payloads keeps its bytes", async () => {
|
||||
test("opening writes nothing: a missing store leaves no lock file, a legacy store with byte payloads keeps its bytes", async () => {
|
||||
const directory = await scratch();
|
||||
try {
|
||||
const absent = join(directory, "nested", "absent.cc");
|
||||
|
|
|
|||
514
packages/cultcache-ts/test/unique-index.test.ts
Normal file
514
packages/cultcache-ts/test/unique-index.test.ts
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { decode } from "@msgpack/msgpack";
|
||||
import { z } from "zod";
|
||||
|
||||
import { codecs } from "../src";
|
||||
import { CultCache } from "../src/cult-cache";
|
||||
import { defineDocumentType, resolveDocumentMembers } from "../src/document";
|
||||
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
|
||||
import type { AnyCultCacheDocumentDefinition } from "../src/types";
|
||||
|
||||
// variants:ruling:unique-indexes. `indexes` are unique within one type, `roles` are selection aliases. The vectors in
|
||||
// tests/vectors/unique-index are written by the C# reference; the rest of the behaviour is pinned here.
|
||||
|
||||
function findVectors(from: string): string {
|
||||
for (let dir = from; ; dir = resolve(dir, "..")) {
|
||||
if (existsSync(join(dir, "tests", "vectors", "unique-index"))) return join(dir, "tests", "vectors", "unique-index");
|
||||
if (resolve(dir, "..") === dir) throw new Error("tests/vectors/unique-index not found above the test.");
|
||||
}
|
||||
}
|
||||
const vectors = findVectors(__dirname);
|
||||
|
||||
const CANARY = "CANARY-uidx-5e1b";
|
||||
|
||||
const skuSchema = z.object({ sku: z.any().optional(), group: z.string().optional() });
|
||||
|
||||
function item(type: string, where: "indexes" | "roles" | "none" = "indexes", schemaName = `tests.${type}`) {
|
||||
return defineDocumentType({
|
||||
type,
|
||||
schemaId: `${schemaName}.v1`,
|
||||
schemaName,
|
||||
schemaVersion: `${schemaName}.v1`,
|
||||
schema: skuSchema,
|
||||
...(where === "none" ? {} : { [where]: { sku: "sku" } }),
|
||||
});
|
||||
}
|
||||
|
||||
// A definition whose payload is never read back, so a value of any scalar kind (a bigint included) can be held and indexed.
|
||||
function scalar(type: string) {
|
||||
const held: unknown[] = [];
|
||||
return defineDocumentType({
|
||||
type,
|
||||
schemaId: `tests.${type}.v1`,
|
||||
schemaName: `tests.${type}`,
|
||||
schemaVersion: `tests.${type}.v1`,
|
||||
schema: skuSchema,
|
||||
formatter: {
|
||||
encode: (value: unknown) => new Uint8Array([held.push(value) - 1]),
|
||||
decode: (payload: Uint8Array) => held[payload[0]] as never,
|
||||
},
|
||||
indexes: { sku: "sku" },
|
||||
});
|
||||
}
|
||||
|
||||
function memory(...definitions: AnyCultCacheDocumentDefinition[]) {
|
||||
let builder = CultCache.builder();
|
||||
for (const definition of definitions) builder = builder.withDocumentType(definition);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
function refusal(keyA: string, keyB: string, schemaName: string, alias: string) {
|
||||
return `Records ${keyA} and ${keyB} (${schemaName}) both hold unique index '${alias}'; an index value names one record.`;
|
||||
}
|
||||
|
||||
test("a put of a held value at another key is refused naming both keys and the alias, and nothing moves", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "uidx-"));
|
||||
try {
|
||||
const file = join(dir, "items.cc");
|
||||
const document = item("item");
|
||||
const cache = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
|
||||
await cache.pullAllBackingStores();
|
||||
await cache.put(document, "zeta", { sku: CANARY });
|
||||
const before = await readFile(file);
|
||||
const snapshot = cache.snapshot();
|
||||
|
||||
await assert.rejects(cache.put(document, "alpha", { sku: CANARY }), (error: Error) => {
|
||||
assert.equal(error.message, refusal("alpha", "zeta", "tests.item", "sku"));
|
||||
assert.ok(!error.message.includes(CANARY), "the value is never echoed");
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual(await readFile(file), before, "the store is byte-identical");
|
||||
assert.deepEqual(cache.snapshot(), snapshot);
|
||||
assert.equal(cache.get(document, "alpha"), undefined);
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", CANARY), "zeta");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rewriting a record's own key with its own value lands", async () => {
|
||||
const document = item("item");
|
||||
const cache = memory(document);
|
||||
await cache.put(document, "a", { sku: "S", group: "one" });
|
||||
await cache.put(document, "a", { sku: "S", group: "two" });
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "S"), "a");
|
||||
assert.equal(cache.get(document, "a")?.group, "two");
|
||||
});
|
||||
|
||||
test("a removed or changed holder releases its value", async () => {
|
||||
const document = item("item");
|
||||
const cache = memory(document);
|
||||
await cache.put(document, "b", { sku: "S" });
|
||||
await assert.rejects(cache.put(document, "a", { sku: "S" }), /Records a and b/u);
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "S"), "b", "the refused write did not take the value");
|
||||
|
||||
await cache.delete(document, "b");
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "S"), undefined);
|
||||
await cache.put(document, "a", { sku: "S" });
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "S"), "a");
|
||||
|
||||
await cache.put(document, "a", { sku: "T" });
|
||||
await cache.put(document, "b", { sku: "S" });
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "S"), "b");
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "T"), "a");
|
||||
});
|
||||
|
||||
test("the keys in a refusal are in ordinal order whichever one lands second", async () => {
|
||||
const document = item("item");
|
||||
const cache = memory(document);
|
||||
await cache.put(document, "m", { sku: "S" });
|
||||
await assert.rejects(cache.put(document, "z", { sku: "S" }), { message: refusal("m", "z", "tests.item", "sku") });
|
||||
await assert.rejects(cache.put(document, "B", { sku: "S" }), { message: refusal("B", "m", "tests.item", "sku") });
|
||||
});
|
||||
|
||||
// The C# White_Space set, and nothing else, is blank. Every UTF-16 code unit is judged, so a rule written for a few
|
||||
// characters (or for JavaScript's \s, which counts U+FEFF and omits U+0085) cannot pass.
|
||||
const CSHARP_WHITE_SPACE = (code: number) =>
|
||||
(code >= 0x09 && code <= 0x0d) || code === 0x20 || code === 0x85 || code === 0xa0 || code === 0x1680 ||
|
||||
(code >= 0x2000 && code <= 0x200a) || code === 0x2028 || code === 0x2029 || code === 0x202f || code === 0x205f || code === 0x3000;
|
||||
|
||||
test("blank is exactly the C# White_Space set: every code unit, alone, repeated and padded", async () => {
|
||||
const document = item("item");
|
||||
const cache = memory(document);
|
||||
const disagreements: string[] = [];
|
||||
for (let code = 0; code <= 0xffff; code++) {
|
||||
if (code >= 0xd800 && code <= 0xdfff) continue; // lone surrogates do not survive MessagePack text
|
||||
const character = String.fromCharCode(code);
|
||||
await cache.put(document, "x", { sku: character });
|
||||
let blank = true;
|
||||
try {
|
||||
await cache.put(document, "y", { sku: character });
|
||||
} catch {
|
||||
blank = false;
|
||||
}
|
||||
if (blank !== CSHARP_WHITE_SPACE(code)) disagreements.push(code.toString(16));
|
||||
await cache.delete(document, "x");
|
||||
await cache.delete(document, "y");
|
||||
}
|
||||
assert.deepEqual(disagreements, []);
|
||||
|
||||
// Not trimmed: a padded value is the value, distinct from its trimmed form.
|
||||
await cache.put(document, "x", { sku: " a " });
|
||||
await cache.put(document, "y", { sku: "a" });
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", " a "), "x");
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "a"), "y");
|
||||
await assert.rejects(cache.put(document, "z", { sku: " a " }), /Records x and z/u);
|
||||
});
|
||||
|
||||
test("blank values are never indexed and never conflict: empty, spaces, absent", async () => {
|
||||
const document = item("item");
|
||||
const cache = memory(document);
|
||||
for (const [key, sku] of [["a", ""], ["b", " "], ["c", "\u0085"], ["d", undefined], ["e", " "]] as const) {
|
||||
await cache.put(document, key, { sku });
|
||||
}
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", ""), undefined);
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", " "), undefined);
|
||||
// U+FEFF and U+001F are values.
|
||||
await cache.put(document, "f", { sku: "" });
|
||||
await assert.rejects(cache.put(document, "g", { sku: "" }), /Records f and g/u);
|
||||
await cache.put(document, "h", { sku: "\u001F" });
|
||||
await assert.rejects(cache.put(document, "i", { sku: "\u001F" }), /Records h and i/u);
|
||||
});
|
||||
|
||||
test("two types may share an alias and a value", async () => {
|
||||
const first = item("first");
|
||||
const second = item("second");
|
||||
const cache = memory(first, second);
|
||||
await cache.put(first, "a", { sku: "S" });
|
||||
await cache.put(second, "b", { sku: "S" });
|
||||
assert.equal(cache.getKeyByIndex(first, "sku", "S"), "a");
|
||||
assert.equal(cache.getKeyByIndex(second, "sku", "S"), "b");
|
||||
});
|
||||
|
||||
test("roles may share values and are not looked up", async () => {
|
||||
const document = item("item", "roles");
|
||||
const cache = memory(document);
|
||||
await cache.put(document, "a", { sku: "S" });
|
||||
await cache.put(document, "b", { sku: "S" });
|
||||
await cache.put(document, "c", { sku: "S" });
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "S"), undefined);
|
||||
assert.equal(cache.getByIndex(document, "sku", "S"), undefined);
|
||||
});
|
||||
|
||||
test("an alias declared in indexes and roles is refused at definition; each map is checked like indexes", () => {
|
||||
const base = { type: "bad", schema: skuSchema, schemaId: "tests.bad.v1", schemaName: "tests.bad", schemaVersion: "tests.bad.v1" };
|
||||
assert.throws(
|
||||
() => defineDocumentType({ ...base, indexes: { sku: "sku" }, roles: { sku: "sku" } }),
|
||||
{ message: 'CultCache document type "bad" declares "sku" as both an index and a role; an index already names its role.' },
|
||||
);
|
||||
assert.throws(() => defineDocumentType({ ...base, roles: { " ": "sku" } }), /declares a role with an empty name/u);
|
||||
assert.throws(
|
||||
() => defineDocumentType({ ...base, roles: [{ name: "r", accessor: "sku" }, { name: "r", accessor: "group" }] }),
|
||||
/declares duplicate role "r"/u,
|
||||
);
|
||||
assert.doesNotThrow(() => defineDocumentType({ ...base, indexes: { sku: "sku" }, roles: { group: "group" } }));
|
||||
});
|
||||
|
||||
test("a codec definition's catalog entry is identical with an alias under indexes or roles", async () => {
|
||||
const codec = codecs.slots<{ sku: string; group: string }>({ 0: ["sku", codecs.string], 1: ["group", codecs.string] }, "T.Item");
|
||||
const make = (where: "indexes" | "roles") =>
|
||||
defineDocumentType({
|
||||
type: "codec_item",
|
||||
schemaId: "tests.codec_item.v1",
|
||||
schemaName: "tests.codec_item",
|
||||
schemaVersion: "tests.codec_item.v1",
|
||||
schema: z.object({ sku: z.string(), group: z.string() }),
|
||||
codec,
|
||||
[where]: { sku: "sku" },
|
||||
});
|
||||
const catalog = async (definition: AnyCultCacheDocumentDefinition) => {
|
||||
const cache = memory(definition);
|
||||
await cache.put(definition, "a", { sku: "S", group: "g" } as never);
|
||||
return cache.snapshot()[0].catalogEntry!;
|
||||
};
|
||||
const members = (definition: AnyCultCacheDocumentDefinition) => resolveDocumentMembers(definition);
|
||||
assert.deepEqual(members(make("indexes")), members(make("roles")));
|
||||
assert.equal(members(make("roles")).find((member) => member.memberName === "Sku")?.indexAlias, "sku");
|
||||
const [unique, role] = [await catalog(make("indexes")), await catalog(make("roles"))];
|
||||
assert.equal(unique.canonicalSchemaJson, role.canonicalSchemaJson);
|
||||
assert.equal(unique.contentHash, role.contentHash);
|
||||
assert.equal(unique.schemaId, role.schemaId);
|
||||
});
|
||||
|
||||
test("vectors values.msgpack: every row's canonical text, blank rows no value", async () => {
|
||||
const rows = decode(await readFile(join(vectors, "values.msgpack")), { useBigInt64: true }) as [string, string, unknown, string | null][];
|
||||
assert.ok(rows.length >= 20);
|
||||
for (const [label, kind, value, text] of rows) {
|
||||
const document = scalar(`value_${label.replaceAll(/[^a-z0-9]/gu, "_")}`);
|
||||
const cache = memory(document);
|
||||
void kind;
|
||||
const normalized = value;
|
||||
await cache.put(document, "one", { sku: normalized });
|
||||
if (text === null) {
|
||||
await cache.put(document, "two", { sku: normalized });
|
||||
assert.equal(cache.get(document, "two") !== undefined, true, `${label}: a blank value commits twice`);
|
||||
for (const probe of ["", " ", "NaN", "0", "Infinity", "-Infinity", "null", "undefined"]) {
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", probe), undefined, `${label}: found by '${probe}'`);
|
||||
}
|
||||
} else {
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", text), "one", `${label}: found by its text`);
|
||||
await assert.rejects(cache.put(document, "two", { sku: normalized }), /Records one and two/u, label);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// An independent oracle for the double rule: the text must denote exactly the double, so N / 10^d == mantissa * 2^exponent
|
||||
// in exact integer arithmetic, with no trailing zero and no bare point. A rule written to a few spellings cannot pass.
|
||||
function exactlyDenotes(text: string, value: number): boolean {
|
||||
const match = /^(-?)(\d+)(?:\.(\d*[1-9]))?$/u.exec(text);
|
||||
if (!match) return false;
|
||||
const decimals = match[3]?.length ?? 0;
|
||||
const numerator = BigInt(match[2] + (match[3] ?? ""));
|
||||
const view = new DataView(new ArrayBuffer(8));
|
||||
view.setFloat64(0, Math.abs(value));
|
||||
const bits = view.getBigUint64(0);
|
||||
const biased = Number((bits >> 52n) & 0x7ffn);
|
||||
const mantissa = biased === 0 ? bits & 0xfffffffffffffn : (bits & 0xfffffffffffffn) | (1n << 52n);
|
||||
const exponent = (biased === 0 ? 1 : biased) - 1075;
|
||||
const lhs = numerator * (exponent >= 0 ? 1n : 2n ** BigInt(-exponent));
|
||||
const rhs = mantissa * (exponent >= 0 ? 2n ** BigInt(exponent) : 1n) * 10n ** BigInt(decimals);
|
||||
return (match[1] === "-") === (value < 0) && lhs === rhs;
|
||||
}
|
||||
|
||||
test("a number's index text is its exact value: integers as digits, doubles as the exact expansion, NaN and infinities none", async () => {
|
||||
const document = scalar("number");
|
||||
const cache = memory(document);
|
||||
const probes = [0.1, -0.1, 2.5e-7, 1.5, -123.456, 5e-324, 2.2250738585072014e-308, 1e-10, 123456789.123456789, Number.MIN_VALUE * 3,
|
||||
...Array.from({ length: 200 }, (_, i) => Math.sin(i + 1) * 10 ** ((i % 40) - 20)), 1e21 + 2 ** 20 + 0.5 * 0];
|
||||
// Round trip through lookups: the exact expansion of each probe is found, and it denotes the probe exactly.
|
||||
for (const value of probes) {
|
||||
if (Number.isInteger(value)) continue;
|
||||
await cache.put(document, "k", { sku: value });
|
||||
const candidates = candidateTexts(value);
|
||||
const found = candidates.filter((candidate) => cache.getKeyByIndex(document, "sku", candidate) === "k");
|
||||
assert.equal(found.length, 1, `${value}: exactly one spelling is its index text`);
|
||||
assert.ok(exactlyDenotes(found[0], value), `${value}: ${found[0]} denotes it exactly`);
|
||||
}
|
||||
|
||||
await cache.put(document, "k", { sku: 1e21 });
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "1000000000000000000000"), "k");
|
||||
await cache.put(document, "k", { sku: -0 });
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "0"), "k");
|
||||
await cache.put(document, "k", { sku: 12n });
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "12"), "k");
|
||||
await cache.put(document, "k", { sku: true });
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "True"), "k");
|
||||
await cache.put(document, "k", { sku: false });
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "False"), "k");
|
||||
for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
|
||||
await cache.put(document, "p", { sku: value });
|
||||
await cache.put(document, "q", { sku: value });
|
||||
}
|
||||
});
|
||||
|
||||
// Spellings a lookup could be probed with: the exact decimal expansion computed by an arbitrary-precision route.
|
||||
function candidateTexts(value: number): string[] {
|
||||
const view = new DataView(new ArrayBuffer(8));
|
||||
view.setFloat64(0, Math.abs(value));
|
||||
const bits = view.getBigUint64(0);
|
||||
const biased = Number((bits >> 52n) & 0x7ffn);
|
||||
const mantissa = biased === 0 ? bits & 0xfffffffffffffn : (bits & 0xfffffffffffffn) | (1n << 52n);
|
||||
const k = 1075 - (biased === 0 ? 1 : biased);
|
||||
const scaled = (mantissa * 5n ** BigInt(k)).toString().padStart(k + 1, "0");
|
||||
const point = scaled.length - k;
|
||||
const text = `${scaled.slice(0, point)}.${scaled.slice(point)}`.replace(/0+$/u, "").replace(/\.$/u, "");
|
||||
return [(value < 0 ? "-" : "") + text, String(value), value.toString()].filter((spelling, i, all) => all.indexOf(spelling) === i);
|
||||
}
|
||||
|
||||
function twoDefinitions(schemaId: string, where: "indexes" | "roles") {
|
||||
return defineDocumentType({
|
||||
type: "unique_item",
|
||||
schemaId,
|
||||
schemaName: "tests.unique_item",
|
||||
schemaVersion: "tests.unique_item.v1",
|
||||
schema: z.object({ sku: z.string(), category: z.string() }),
|
||||
codec: codecs.slots<{ sku: string; category: string }>({ 0: ["sku", codecs.string], 1: ["category", codecs.string] }, "UqVecItem"),
|
||||
[where]: { Sku: "sku" },
|
||||
});
|
||||
}
|
||||
|
||||
test("vectors duplicate-store.cc and blank-store.cc hold their verdicts", async () => {
|
||||
const outcomes = decode(await readFile(join(vectors, "outcomes.msgpack"))) as [string, string | null, string, string | null, string | null][];
|
||||
assert.equal(outcomes.length, 3);
|
||||
const dir = await mkdtemp(join(tmpdir(), "uidx-vec-"));
|
||||
try {
|
||||
for (const [name, alias, outcome, key1, key2] of outcomes) {
|
||||
const file = join(dir, `${outcomes.indexOf(outcomes.find((row) => row[0] === name && row[1] === alias)!)}-${name}`);
|
||||
await copyFile(join(vectors, name), file);
|
||||
const before = await readFile(file);
|
||||
const [envelope] = await new SingleFileMessagePackBackingStore(file).pullAll();
|
||||
const document = twoDefinitions(envelope.schemaId!, alias === null ? "roles" : "indexes");
|
||||
const cache = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
|
||||
const stateBefore = cache.snapshot();
|
||||
if (outcome === "open") {
|
||||
await cache.pullAllBackingStores();
|
||||
assert.equal(cache.snapshot().length, name === "blank-store.cc" ? 2 : 4);
|
||||
if (alias === null) assert.equal(cache.getKeyByIndex(document, "Sku", "S-1"), undefined);
|
||||
} else {
|
||||
await assert.rejects(cache.pullAllBackingStores(), (error: Error) => {
|
||||
assert.equal(error.message, refusal(key1!, key2!, "tests.unique_item", alias!));
|
||||
assert.ok(!error.message.includes("S-1"));
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual(cache.snapshot(), stateBefore, "a refused load admits nothing");
|
||||
}
|
||||
assert.deepEqual(await readFile(file), before, `${name} is untouched`);
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a refused load leaves the cache's prior state as it was, and a later good load still works", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "uidx-load-"));
|
||||
try {
|
||||
const bad = join(dir, "bad.cc");
|
||||
await copyFile(join(vectors, "duplicate-store.cc"), bad);
|
||||
const [envelope] = await new SingleFileMessagePackBackingStore(bad).pullAll();
|
||||
const document = twoDefinitions(envelope.schemaId!, "indexes");
|
||||
const cache = CultCache.builder().withDocumentType(document).withGenericStore(new SingleFileMessagePackBackingStore(bad)).build();
|
||||
await assert.rejects(cache.pullAllBackingStores(), /Records item:a and item:b/u);
|
||||
assert.equal(cache.get(document, "item:a"), undefined);
|
||||
assert.equal(cache.getKeyByIndex(document, "Sku", "S-1"), undefined);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("registerIndex refuses a name the definition declares as a role", async () => {
|
||||
const document = item("item", "roles");
|
||||
const cache = memory(document);
|
||||
await cache.put(document, "a", { sku: "S", group: "g" });
|
||||
await cache.put(document, "b", { sku: "S", group: "h" });
|
||||
await assert.rejects(cache.registerIndex(document, "sku", "sku"), { message: /declared as a role; a role is never a unique index/u });
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "S"), undefined);
|
||||
await cache.put(document, "c", { sku: "S", group: "i" });
|
||||
});
|
||||
|
||||
test("registerIndex over held duplicates installs nothing", async () => {
|
||||
const document = item("item", "none");
|
||||
const cache = memory(document);
|
||||
await cache.put(document, "a", { sku: "S", group: "g" });
|
||||
await cache.put(document, "b", { sku: "S", group: "h" });
|
||||
await assert.rejects(cache.registerIndex(document, "sku", "sku"), { message: refusal("a", "b", "tests.item", "sku") });
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "S"), undefined, "no map was installed");
|
||||
await cache.put(document, "c", { sku: "S", group: "i" });
|
||||
await cache.registerIndex(document, "group", "group");
|
||||
assert.equal(cache.getKeyByIndex(document, "group", "h"), "b");
|
||||
await assert.rejects(cache.put(document, "d", { sku: "x", group: "h" }), /Records b and d/u);
|
||||
});
|
||||
|
||||
test("registerIndex over distinct held values installs the lookup", async () => {
|
||||
const document = item("item", "none");
|
||||
const cache = memory(document);
|
||||
await cache.put(document, "a", { sku: "S" });
|
||||
await cache.registerIndex(document, "sku", "sku");
|
||||
assert.equal(cache.getKeyByIndex(document, "sku", "S"), "a");
|
||||
});
|
||||
|
||||
// ---- a type with several unique indexes: the second alias is judged, in alias order, in both judges ----
|
||||
|
||||
const pairSchema = z.object({ a: z.string().optional(), b: z.string().optional() });
|
||||
|
||||
function pair(where: { indexes?: Record<string, "a" | "b">; roles?: Record<string, "a" | "b"> }) {
|
||||
return defineDocumentType({
|
||||
type: "pair",
|
||||
schemaId: "tests.pair.v1",
|
||||
schemaName: "tests.pair",
|
||||
schemaVersion: "tests.pair.v1",
|
||||
schema: pairSchema,
|
||||
...where,
|
||||
});
|
||||
}
|
||||
|
||||
// Declared b first, so insertion order is the opposite of alias order.
|
||||
const BOTH_UNIQUE = { indexes: { b: "b", a: "a" } as Record<string, "a" | "b"> };
|
||||
|
||||
test("a collision on the second unique alias is refused, with a value on the first alias that does not collide", async () => {
|
||||
const document = pair(BOTH_UNIQUE);
|
||||
const cache = memory(document);
|
||||
await cache.put(document, "k1", { a: "x1", b: "shared" });
|
||||
await assert.rejects(cache.put(document, "k2", { a: "x2", b: "shared" }), { message: refusal("k1", "k2", "tests.pair", "b") });
|
||||
await assert.rejects(cache.put(document, "k2", { a: "x1", b: "y" }), { message: refusal("k1", "k2", "tests.pair", "a") });
|
||||
assert.equal(cache.get(document, "k2"), undefined);
|
||||
});
|
||||
|
||||
test("when two aliases collide the first alias in ordinal order is named, and its keys are in ordinal order", async () => {
|
||||
const document = pair(BOTH_UNIQUE);
|
||||
const cache = memory(document);
|
||||
await cache.put(document, "m", { a: "same-a", b: "same-b" });
|
||||
await assert.rejects(cache.put(document, "c", { a: "same-a", b: "same-b" }), { message: refusal("c", "m", "tests.pair", "a") });
|
||||
// The colliding holders differ per alias: a is held by m, b by n; alias a is still the one named.
|
||||
await cache.put(document, "n", { a: "other-a", b: "other-b" });
|
||||
await assert.rejects(cache.put(document, "z", { a: "same-a", b: "other-b" }), { message: refusal("m", "z", "tests.pair", "a") });
|
||||
});
|
||||
|
||||
test("a load judges every unique alias: a store collision on the second alias refuses the open", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "uidx-pair-"));
|
||||
try {
|
||||
for (const [name, records, expected] of [
|
||||
["second-only", [["k1", { a: "1", b: "s" }], ["k2", { a: "2", b: "s" }]], refusal("k1", "k2", "tests.pair", "b")],
|
||||
["both-aliases", [["m", { a: "s", b: "s" }], ["c", { a: "s", b: "s" }]], refusal("c", "m", "tests.pair", "a")],
|
||||
] as const) {
|
||||
const file = join(dir, `${name}.cc`);
|
||||
const roles = pair({ roles: { b: "b", a: "a" } });
|
||||
const writer = CultCache.builder().withDocumentType(roles).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
|
||||
for (const [key, value] of records) await writer.put(roles, key, value);
|
||||
const before = await readFile(file);
|
||||
|
||||
const unique = pair(BOTH_UNIQUE);
|
||||
const reader = CultCache.builder().withDocumentType(unique).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
|
||||
await assert.rejects(reader.pullAllBackingStores(), { message: expected }, name);
|
||||
assert.deepEqual(reader.snapshot(), [], "a refused load admits nothing");
|
||||
assert.deepEqual(await readFile(file), before);
|
||||
|
||||
const rolesReader = CultCache.builder().withDocumentType(roles).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
|
||||
await rolesReader.pullAllBackingStores();
|
||||
assert.equal(rolesReader.snapshot().length, 2, `${name}: the same store opens under roles`);
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("registerIndex of a second unique alias judges it, naming the two smallest keys whatever order they were written in", async () => {
|
||||
const document = pair({ indexes: { a: "a" } });
|
||||
const cache = memory(document);
|
||||
await cache.put(document, "z", { a: "1", b: "s" });
|
||||
await cache.put(document, "m", { a: "2", b: "s" });
|
||||
await cache.put(document, "q", { a: "3", b: "s" });
|
||||
await assert.rejects(cache.registerIndex(document, "b", "b"), { message: refusal("m", "q", "tests.pair", "b") });
|
||||
assert.equal(cache.getKeyByIndex(document, "a", "1"), "z", "the alias already installed is intact");
|
||||
assert.equal(cache.getKeyByIndex(document, "b", "s"), undefined);
|
||||
});
|
||||
|
||||
test("verdict parity: the same stores are refused whatever the load order, and the first collision refuses (text is informational)", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "uidx-verdict-"));
|
||||
try {
|
||||
// Three records, one pair colliding; whichever position the pair has in the file, the store is refused and a clean store opens.
|
||||
const roles = pair({ roles: { a: "a" } });
|
||||
for (const [name, values, refused] of [
|
||||
["pair-first", ["s", "s", "t"], true],
|
||||
["pair-last", ["t", "s", "s"], true],
|
||||
["pair-split", ["s", "t", "s"], true],
|
||||
["distinct", ["r", "s", "t"], false],
|
||||
] as const) {
|
||||
const file = join(dir, `${name}.cc`);
|
||||
const writer = CultCache.builder().withDocumentType(roles).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
|
||||
for (const [i, a] of values.entries()) await writer.put(roles, `r${i}`, { a });
|
||||
const unique = pair({ indexes: { a: "a" } });
|
||||
const reader = CultCache.builder().withDocumentType(unique).withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
|
||||
if (refused) await assert.rejects(reader.pullAllBackingStores(), /both hold unique index 'a'/u, name);
|
||||
else await reader.pullAllBackingStores();
|
||||
assert.equal(reader.snapshot().length, refused ? 0 : 3, name);
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -567,8 +567,14 @@ test("a size the runtime cannot allocate is too-large in both exports, not a Ran
|
|||
const count = 2 ** 15;
|
||||
const huge = { ...manifest, sizeBytes: count * int32Max, chunks: Array.from({ length: count }, (_, index) => ({ ...manifest.chunks[0]!, offset: index * int32Max, sizeBytes: int32Max })) };
|
||||
const server = double(() => { throw new Error("no request expected"); });
|
||||
await assert.rejects(fetchVerifiedContent(ENDPOINT, huge, { fetch: server.fetch, maxBytes: size }), codeOf("too-large"));
|
||||
await assert.rejects(fetchVerifiedChunk(ENDPOINT, manifest.chunks[0]!.chunkHash!, size, { fetch: server.fetch, maxBytes: size }), codeOf("too-large"));
|
||||
// a host with overcommit hands out 64 TiB lazily, so the refusal is made certain: the runtime cannot allocate it
|
||||
const native = globalThis.Uint8Array;
|
||||
const refusing = new Proxy(native, { construct: (target, args, receiver) => { if (typeof args[0] === "number" && args[0] > 2 ** 40) throw new RangeError("Array buffer allocation failed"); return Reflect.construct(target, args, receiver); } });
|
||||
globalThis.Uint8Array = refusing;
|
||||
try {
|
||||
await assert.rejects(fetchVerifiedContent(ENDPOINT, huge, { fetch: server.fetch, maxBytes: size }), codeOf("too-large"));
|
||||
await assert.rejects(fetchVerifiedChunk(ENDPOINT, manifest.chunks[0]!.chunkHash!, size, { fetch: server.fetch, maxBytes: size }), codeOf("too-large"));
|
||||
} finally { globalThis.Uint8Array = native; }
|
||||
assert.equal(server.calls.length, 0);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -474,3 +474,14 @@ powershell -ExecutionPolicy Bypass -File .\packages\cultmesh-kotlin\build.ps1
|
|||
The build writes `artifacts/cultmesh-kotlin/cultmesh-kotlin.jar`.
|
||||
It also runs the built-in RUDP packet fixture and localhost UDP socket
|
||||
self-test.
|
||||
|
||||
## Write Receipt
|
||||
|
||||
`WriteReceipt.kt` decodes `cultnet.write_admitted.v0` and `cultnet.error.v1` (and `error.v0`) strictly against their
|
||||
closed schemas (`decodeWriteAdmitted`, `decodeErrorFrame`), computes the dedupe `writeContentHash`, tests a hello with
|
||||
`helloAdvertisesWriteReceipts`, and settles sent writes as admitted, refused or unconfirmed in `WriteTracker`
|
||||
(pure state machine, abstract clock; the caller owns transport and timers). Kotlin has no write client yet, so nothing
|
||||
sends a put and waits on this. `WriteReceiptVectors.kt` replays
|
||||
`contracts/cultnet/interop/write-receipt-vectors.json` (constants, contentHash, wire, schemaInvalid, scenarios; the
|
||||
server-side `dedupe` and `blankFields` sections are not run); `build.ps1` runs it after the self-test, or run
|
||||
`CultMeshKt write-receipt-vectors <path>`.
|
||||
|
|
|
|||
|
|
@ -26,4 +26,7 @@ $sources = Get-ChildItem $src -Recurse -Filter *.kt | Select-Object -ExpandPrope
|
|||
if ($LASTEXITCODE -ne 0) { throw "kotlinc failed with exit code $LASTEXITCODE" }
|
||||
& $java -cp "$jarPath;$kotlinStdlib" org.gamecult.cultmesh.CultMeshKt
|
||||
if ($LASTEXITCODE -ne 0) { throw "cultmesh-kotlin self-test failed with exit code $LASTEXITCODE" }
|
||||
$vectors = Join-Path $repoRoot "contracts\cultnet\interop\write-receipt-vectors.json"
|
||||
& $java -cp "$jarPath;$kotlinStdlib" org.gamecult.cultmesh.CultMeshKt write-receipt-vectors $vectors
|
||||
if ($LASTEXITCODE -ne 0) { throw "write-receipt vector replay failed with exit code $LASTEXITCODE" }
|
||||
Write-Host "Built $jarPath"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import java.net.Socket
|
|||
import java.net.SocketTimeoutException
|
||||
import java.net.URI
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.SecureRandom
|
||||
import java.security.MessageDigest
|
||||
|
|
@ -1100,9 +1101,9 @@ data class CultNetRawDocumentRecord(
|
|||
"payloadEncoding" to payloadEncoding,
|
||||
"payload" to payload.copyOf(),
|
||||
)
|
||||
if (!sourceRuntimeId.isNullOrBlank()) wire["sourceRuntimeId"] = sourceRuntimeId
|
||||
if (!sourceAgentId.isNullOrBlank()) wire["sourceAgentId"] = sourceAgentId
|
||||
if (!sourceRole.isNullOrBlank()) wire["sourceRole"] = sourceRole
|
||||
if (!isBlankText(sourceRuntimeId)) wire["sourceRuntimeId"] = sourceRuntimeId
|
||||
if (!isBlankText(sourceAgentId)) wire["sourceAgentId"] = sourceAgentId
|
||||
if (!isBlankText(sourceRole)) wire["sourceRole"] = sourceRole
|
||||
if (tags.isNotEmpty()) wire["tags"] = tags
|
||||
return wire
|
||||
}
|
||||
|
|
@ -2364,6 +2365,9 @@ fun parseCultNetMessage(payload: ByteArray): CultNetMessage {
|
|||
if (schemaVersion !is String || schemaVersion.isBlank()) {
|
||||
throw IOException("CultNet schema-v0 messages must declare schemaVersion")
|
||||
}
|
||||
// A write_admitted id is echoed byte for byte, so that frame alone is read again with invalid UTF-8 refused; document payload
|
||||
// strings elsewhere keep decoding as before.
|
||||
if (schemaVersion == WRITE_ADMITTED_VERSION) MessagePackReader(payload, strictUtf8 = true).readAny()
|
||||
val body = linkedMapOf<String, Any?>()
|
||||
for ((key, value) in decoded) {
|
||||
if (key !is String) throw IOException("CultNet schema-v0 message keys must be strings")
|
||||
|
|
@ -3413,6 +3417,7 @@ fun main(args: Array<String>) {
|
|||
rudpClientBindsLoopbackOnlyForALoopbackEndpoint()
|
||||
rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames()
|
||||
rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames()
|
||||
rawDocumentRecordDropsOnlyWhiteSpaceSourceIds()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -3424,6 +3429,7 @@ fun main(args: Array<String>) {
|
|||
"rudp-dial-message-once" -> rudpDialSchemaMessageOnce(options)
|
||||
"serve" -> interopServe(options)
|
||||
"dial" -> interopDial(options)
|
||||
"write-receipt-vectors" -> if (!replayWriteReceiptVectors(args.getOrNull(1) ?: error("usage: write-receipt-vectors <vectors.json>"))) kotlin.system.exitProcess(1)
|
||||
else -> error("Unknown mode ${args[0]}")
|
||||
}
|
||||
}
|
||||
|
|
@ -6149,7 +6155,8 @@ private fun requireSchemaResponse(message: CultNetMessage?, expectedSchemaVersio
|
|||
return response
|
||||
}
|
||||
|
||||
class MessagePackReader(payload: ByteArray) {
|
||||
/** [strictUtf8] makes a str whose bytes are not UTF-8 throw instead of becoming U+FFFD; only the write_admitted frame asks for it. */
|
||||
class MessagePackReader(payload: ByteArray, private val strictUtf8: Boolean = false) {
|
||||
private val input = DataInputStream(ByteArrayInputStream(payload))
|
||||
private var pushed = -1
|
||||
|
||||
|
|
@ -6304,7 +6311,10 @@ class MessagePackReader(payload: ByteArray) {
|
|||
code == 0xdb -> input.readInt()
|
||||
else -> throw IOException("expected string")
|
||||
}
|
||||
return String(readPayload(length), StandardCharsets.UTF_8)
|
||||
val bytes = readPayload(length)
|
||||
if (!strictUtf8) return String(bytes, StandardCharsets.UTF_8)
|
||||
return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes)).toString()
|
||||
}
|
||||
|
||||
private fun readBinaryAfterCode(code: Int): ByteArray {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
// generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
|
||||
|
||||
package org.gamecult.cultmesh
|
||||
|
||||
// (low, high) pairs, flattened and sorted.
|
||||
internal val LETTER_OR_NUMBER: IntArray = intArrayOf(
|
||||
0x30, 0x39, 0x41, 0x5A, 0x61, 0x7A, 0xAA, 0xAA, 0xB2, 0xB3, 0xB5, 0xB5, 0xB9, 0xBA, 0xBC, 0xBE, 0xC0, 0xD6, 0xD8,
|
||||
0xF6, 0xF8, 0x2C1, 0x2C6, 0x2D1, 0x2E0, 0x2E4, 0x2EC, 0x2EC, 0x2EE, 0x2EE, 0x370, 0x374, 0x376, 0x377, 0x37A,
|
||||
0x37D, 0x37F, 0x37F, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3F5, 0x3F7, 0x481, 0x48A,
|
||||
0x52F, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x5D0, 0x5EA, 0x5EF, 0x5F2, 0x620, 0x64A, 0x660, 0x669, 0x66E,
|
||||
0x66F, 0x671, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x6EE, 0x6FC, 0x6FF, 0x6FF, 0x710, 0x710, 0x712, 0x72F, 0x74D,
|
||||
0x7A5, 0x7B1, 0x7B1, 0x7C0, 0x7EA, 0x7F4, 0x7F5, 0x7FA, 0x7FA, 0x800, 0x815, 0x81A, 0x81A, 0x824, 0x824, 0x828,
|
||||
0x828, 0x840, 0x858, 0x860, 0x86A, 0x870, 0x887, 0x889, 0x88E, 0x8A0, 0x8C9, 0x904, 0x939, 0x93D, 0x93D, 0x950,
|
||||
0x950, 0x958, 0x961, 0x966, 0x96F, 0x971, 0x980, 0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2,
|
||||
0x9B2, 0x9B6, 0x9B9, 0x9BD, 0x9BD, 0x9CE, 0x9CE, 0x9DC, 0x9DD, 0x9DF, 0x9E1, 0x9E6, 0x9F1, 0x9F4, 0x9F9, 0x9FC,
|
||||
0x9FC, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59,
|
||||
0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA6F, 0xA72, 0xA74, 0xA85, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2,
|
||||
0xAB3, 0xAB5, 0xAB9, 0xABD, 0xABD, 0xAD0, 0xAD0, 0xAE0, 0xAE1, 0xAE6, 0xAEF, 0xAF9, 0xAF9, 0xB05, 0xB0C, 0xB0F,
|
||||
0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB35, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66,
|
||||
0xB6F, 0xB71, 0xB77, 0xB83, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E,
|
||||
0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB9, 0xBD0, 0xBD0, 0xBE6, 0xBF2, 0xC05, 0xC0C, 0xC0E, 0xC10, 0xC12,
|
||||
0xC28, 0xC2A, 0xC39, 0xC3D, 0xC3D, 0xC58, 0xC5A, 0xC5D, 0xC5D, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC78, 0xC7E, 0xC80,
|
||||
0xC80, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBD, 0xCBD, 0xCDD, 0xCDE, 0xCE0,
|
||||
0xCE1, 0xCE6, 0xCEF, 0xCF1, 0xCF2, 0xD04, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD3A, 0xD3D, 0xD3D, 0xD4E, 0xD4E, 0xD54,
|
||||
0xD56, 0xD58, 0xD61, 0xD66, 0xD78, 0xD7A, 0xD7F, 0xD85, 0xD96, 0xD9A, 0xDB1, 0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0,
|
||||
0xDC6, 0xDE6, 0xDEF, 0xE01, 0xE30, 0xE32, 0xE33, 0xE40, 0xE46, 0xE50, 0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE86,
|
||||
0xE8A, 0xE8C, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xED0,
|
||||
0xED9, 0xEDC, 0xEDF, 0xF00, 0xF00, 0xF20, 0xF33, 0xF40, 0xF47, 0xF49, 0xF6C, 0xF88, 0xF8C, 0x1000, 0x102A,
|
||||
0x103F, 0x1049, 0x1050, 0x1055, 0x105A, 0x105D, 0x1061, 0x1061, 0x1065, 0x1066, 0x106E, 0x1070, 0x1075, 0x1081,
|
||||
0x108E, 0x108E, 0x1090, 0x1099, 0x10A0, 0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FC, 0x1248,
|
||||
0x124A, 0x124D, 0x1250, 0x1256, 0x1258, 0x1258, 0x125A, 0x125D, 0x1260, 0x1288, 0x128A, 0x128D, 0x1290, 0x12B0,
|
||||
0x12B2, 0x12B5, 0x12B8, 0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12D6, 0x12D8, 0x1310, 0x1312, 0x1315,
|
||||
0x1318, 0x135A, 0x1369, 0x137C, 0x1380, 0x138F, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1401, 0x166C, 0x166F, 0x167F,
|
||||
0x1681, 0x169A, 0x16A0, 0x16EA, 0x16EE, 0x16F8, 0x1700, 0x1711, 0x171F, 0x1731, 0x1740, 0x1751, 0x1760, 0x176C,
|
||||
0x176E, 0x1770, 0x1780, 0x17B3, 0x17D7, 0x17D7, 0x17DC, 0x17DC, 0x17E0, 0x17E9, 0x17F0, 0x17F9, 0x1810, 0x1819,
|
||||
0x1820, 0x1878, 0x1880, 0x1884, 0x1887, 0x18A8, 0x18AA, 0x18AA, 0x18B0, 0x18F5, 0x1900, 0x191E, 0x1946, 0x196D,
|
||||
0x1970, 0x1974, 0x1980, 0x19AB, 0x19B0, 0x19C9, 0x19D0, 0x19DA, 0x1A00, 0x1A16, 0x1A20, 0x1A54, 0x1A80, 0x1A89,
|
||||
0x1A90, 0x1A99, 0x1AA7, 0x1AA7, 0x1B05, 0x1B33, 0x1B45, 0x1B4C, 0x1B50, 0x1B59, 0x1B83, 0x1BA0, 0x1BAE, 0x1BE5,
|
||||
0x1C00, 0x1C23, 0x1C40, 0x1C49, 0x1C4D, 0x1C7D, 0x1C80, 0x1C8A, 0x1C90, 0x1CBA, 0x1CBD, 0x1CBF, 0x1CE9, 0x1CEC,
|
||||
0x1CEE, 0x1CF3, 0x1CF5, 0x1CF6, 0x1CFA, 0x1CFA, 0x1D00, 0x1DBF, 0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45,
|
||||
0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4,
|
||||
0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC,
|
||||
0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x2070, 0x2071, 0x2074, 0x2079, 0x207F, 0x2089, 0x2090, 0x209C, 0x2102, 0x2102,
|
||||
0x2107, 0x2107, 0x210A, 0x2113, 0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128,
|
||||
0x212A, 0x212D, 0x212F, 0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2150, 0x2189, 0x2460, 0x249B,
|
||||
0x24EA, 0x24FF, 0x2776, 0x2793, 0x2C00, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2CFD, 0x2CFD, 0x2D00, 0x2D25,
|
||||
0x2D27, 0x2D27, 0x2D2D, 0x2D2D, 0x2D30, 0x2D67, 0x2D6F, 0x2D6F, 0x2D80, 0x2D96, 0x2DA0, 0x2DA6, 0x2DA8, 0x2DAE,
|
||||
0x2DB0, 0x2DB6, 0x2DB8, 0x2DBE, 0x2DC0, 0x2DC6, 0x2DC8, 0x2DCE, 0x2DD0, 0x2DD6, 0x2DD8, 0x2DDE, 0x2E2F, 0x2E2F,
|
||||
0x3005, 0x3007, 0x3021, 0x3029, 0x3031, 0x3035, 0x3038, 0x303C, 0x3041, 0x3096, 0x309D, 0x309F, 0x30A1, 0x30FA,
|
||||
0x30FC, 0x30FF, 0x3105, 0x312F, 0x3131, 0x318E, 0x3192, 0x3195, 0x31A0, 0x31BF, 0x31F0, 0x31FF, 0x3220, 0x3229,
|
||||
0x3248, 0x324F, 0x3251, 0x325F, 0x3280, 0x3289, 0x32B1, 0x32BF, 0x3400, 0x4DBF, 0x4E00, 0xA48C, 0xA4D0, 0xA4FD,
|
||||
0xA500, 0xA60C, 0xA610, 0xA62B, 0xA640, 0xA66E, 0xA67F, 0xA69D, 0xA6A0, 0xA6EF, 0xA717, 0xA71F, 0xA722, 0xA788,
|
||||
0xA78B, 0xA7CD, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, 0xA7D5, 0xA7DC, 0xA7F2, 0xA801, 0xA803, 0xA805, 0xA807, 0xA80A,
|
||||
0xA80C, 0xA822, 0xA830, 0xA835, 0xA840, 0xA873, 0xA882, 0xA8B3, 0xA8D0, 0xA8D9, 0xA8F2, 0xA8F7, 0xA8FB, 0xA8FB,
|
||||
0xA8FD, 0xA8FE, 0xA900, 0xA925, 0xA930, 0xA946, 0xA960, 0xA97C, 0xA984, 0xA9B2, 0xA9CF, 0xA9D9, 0xA9E0, 0xA9E4,
|
||||
0xA9E6, 0xA9FE, 0xAA00, 0xAA28, 0xAA40, 0xAA42, 0xAA44, 0xAA4B, 0xAA50, 0xAA59, 0xAA60, 0xAA76, 0xAA7A, 0xAA7A,
|
||||
0xAA7E, 0xAAAF, 0xAAB1, 0xAAB1, 0xAAB5, 0xAAB6, 0xAAB9, 0xAABD, 0xAAC0, 0xAAC0, 0xAAC2, 0xAAC2, 0xAADB, 0xAADD,
|
||||
0xAAE0, 0xAAEA, 0xAAF2, 0xAAF4, 0xAB01, 0xAB06, 0xAB09, 0xAB0E, 0xAB11, 0xAB16, 0xAB20, 0xAB26, 0xAB28, 0xAB2E,
|
||||
0xAB30, 0xAB5A, 0xAB5C, 0xAB69, 0xAB70, 0xABE2, 0xABF0, 0xABF9, 0xAC00, 0xD7A3, 0xD7B0, 0xD7C6, 0xD7CB, 0xD7FB,
|
||||
0xF900, 0xFA6D, 0xFA70, 0xFAD9, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB1D, 0xFB1F, 0xFB28, 0xFB2A, 0xFB36,
|
||||
0xFB38, 0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3D, 0xFD50, 0xFD8F,
|
||||
0xFD92, 0xFDC7, 0xFDF0, 0xFDFB, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFF10, 0xFF19, 0xFF21, 0xFF3A, 0xFF41, 0xFF5A,
|
||||
0xFF66, 0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0x10000, 0x1000B, 0x1000D,
|
||||
0x10026, 0x10028, 0x1003A, 0x1003C, 0x1003D, 0x1003F, 0x1004D, 0x10050, 0x1005D, 0x10080, 0x100FA, 0x10107,
|
||||
0x10133, 0x10140, 0x10178, 0x1018A, 0x1018B, 0x10280, 0x1029C, 0x102A0, 0x102D0, 0x102E1, 0x102FB, 0x10300,
|
||||
0x10323, 0x1032D, 0x1034A, 0x10350, 0x10375, 0x10380, 0x1039D, 0x103A0, 0x103C3, 0x103C8, 0x103CF, 0x103D1,
|
||||
0x103D5, 0x10400, 0x1049D, 0x104A0, 0x104A9, 0x104B0, 0x104D3, 0x104D8, 0x104FB, 0x10500, 0x10527, 0x10530,
|
||||
0x10563, 0x10570, 0x1057A, 0x1057C, 0x1058A, 0x1058C, 0x10592, 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3,
|
||||
0x105B1, 0x105B3, 0x105B9, 0x105BB, 0x105BC, 0x105C0, 0x105F3, 0x10600, 0x10736, 0x10740, 0x10755, 0x10760,
|
||||
0x10767, 0x10780, 0x10785, 0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10800, 0x10805, 0x10808, 0x10808, 0x1080A,
|
||||
0x10835, 0x10837, 0x10838, 0x1083C, 0x1083C, 0x1083F, 0x10855, 0x10858, 0x10876, 0x10879, 0x1089E, 0x108A7,
|
||||
0x108AF, 0x108E0, 0x108F2, 0x108F4, 0x108F5, 0x108FB, 0x1091B, 0x10920, 0x10939, 0x10980, 0x109B7, 0x109BC,
|
||||
0x109CF, 0x109D2, 0x10A00, 0x10A10, 0x10A13, 0x10A15, 0x10A17, 0x10A19, 0x10A35, 0x10A40, 0x10A48, 0x10A60,
|
||||
0x10A7E, 0x10A80, 0x10A9F, 0x10AC0, 0x10AC7, 0x10AC9, 0x10AE4, 0x10AEB, 0x10AEF, 0x10B00, 0x10B35, 0x10B40,
|
||||
0x10B55, 0x10B58, 0x10B72, 0x10B78, 0x10B91, 0x10BA9, 0x10BAF, 0x10C00, 0x10C48, 0x10C80, 0x10CB2, 0x10CC0,
|
||||
0x10CF2, 0x10CFA, 0x10D23, 0x10D30, 0x10D39, 0x10D40, 0x10D65, 0x10D6F, 0x10D85, 0x10E60, 0x10E7E, 0x10E80,
|
||||
0x10EA9, 0x10EB0, 0x10EB1, 0x10EC2, 0x10EC4, 0x10F00, 0x10F27, 0x10F30, 0x10F45, 0x10F51, 0x10F54, 0x10F70,
|
||||
0x10F81, 0x10FB0, 0x10FCB, 0x10FE0, 0x10FF6, 0x11003, 0x11037, 0x11052, 0x1106F, 0x11071, 0x11072, 0x11075,
|
||||
0x11075, 0x11083, 0x110AF, 0x110D0, 0x110E8, 0x110F0, 0x110F9, 0x11103, 0x11126, 0x11136, 0x1113F, 0x11144,
|
||||
0x11144, 0x11147, 0x11147, 0x11150, 0x11172, 0x11176, 0x11176, 0x11183, 0x111B2, 0x111C1, 0x111C4, 0x111D0,
|
||||
0x111DA, 0x111DC, 0x111DC, 0x111E1, 0x111F4, 0x11200, 0x11211, 0x11213, 0x1122B, 0x1123F, 0x11240, 0x11280,
|
||||
0x11286, 0x11288, 0x11288, 0x1128A, 0x1128D, 0x1128F, 0x1129D, 0x1129F, 0x112A8, 0x112B0, 0x112DE, 0x112F0,
|
||||
0x112F9, 0x11305, 0x1130C, 0x1130F, 0x11310, 0x11313, 0x11328, 0x1132A, 0x11330, 0x11332, 0x11333, 0x11335,
|
||||
0x11339, 0x1133D, 0x1133D, 0x11350, 0x11350, 0x1135D, 0x11361, 0x11380, 0x11389, 0x1138B, 0x1138B, 0x1138E,
|
||||
0x1138E, 0x11390, 0x113B5, 0x113B7, 0x113B7, 0x113D1, 0x113D1, 0x113D3, 0x113D3, 0x11400, 0x11434, 0x11447,
|
||||
0x1144A, 0x11450, 0x11459, 0x1145F, 0x11461, 0x11480, 0x114AF, 0x114C4, 0x114C5, 0x114C7, 0x114C7, 0x114D0,
|
||||
0x114D9, 0x11580, 0x115AE, 0x115D8, 0x115DB, 0x11600, 0x1162F, 0x11644, 0x11644, 0x11650, 0x11659, 0x11680,
|
||||
0x116AA, 0x116B8, 0x116B8, 0x116C0, 0x116C9, 0x116D0, 0x116E3, 0x11700, 0x1171A, 0x11730, 0x1173B, 0x11740,
|
||||
0x11746, 0x11800, 0x1182B, 0x118A0, 0x118F2, 0x118FF, 0x11906, 0x11909, 0x11909, 0x1190C, 0x11913, 0x11915,
|
||||
0x11916, 0x11918, 0x1192F, 0x1193F, 0x1193F, 0x11941, 0x11941, 0x11950, 0x11959, 0x119A0, 0x119A7, 0x119AA,
|
||||
0x119D0, 0x119E1, 0x119E1, 0x119E3, 0x119E3, 0x11A00, 0x11A00, 0x11A0B, 0x11A32, 0x11A3A, 0x11A3A, 0x11A50,
|
||||
0x11A50, 0x11A5C, 0x11A89, 0x11A9D, 0x11A9D, 0x11AB0, 0x11AF8, 0x11BC0, 0x11BE0, 0x11BF0, 0x11BF9, 0x11C00,
|
||||
0x11C08, 0x11C0A, 0x11C2E, 0x11C40, 0x11C40, 0x11C50, 0x11C6C, 0x11C72, 0x11C8F, 0x11D00, 0x11D06, 0x11D08,
|
||||
0x11D09, 0x11D0B, 0x11D30, 0x11D46, 0x11D46, 0x11D50, 0x11D59, 0x11D60, 0x11D65, 0x11D67, 0x11D68, 0x11D6A,
|
||||
0x11D89, 0x11D98, 0x11D98, 0x11DA0, 0x11DA9, 0x11EE0, 0x11EF2, 0x11F02, 0x11F02, 0x11F04, 0x11F10, 0x11F12,
|
||||
0x11F33, 0x11F50, 0x11F59, 0x11FB0, 0x11FB0, 0x11FC0, 0x11FD4, 0x12000, 0x12399, 0x12400, 0x1246E, 0x12480,
|
||||
0x12543, 0x12F90, 0x12FF0, 0x13000, 0x1342F, 0x13441, 0x13446, 0x13460, 0x143FA, 0x14400, 0x14646, 0x16100,
|
||||
0x1611D, 0x16130, 0x16139, 0x16800, 0x16A38, 0x16A40, 0x16A5E, 0x16A60, 0x16A69, 0x16A70, 0x16ABE, 0x16AC0,
|
||||
0x16AC9, 0x16AD0, 0x16AED, 0x16B00, 0x16B2F, 0x16B40, 0x16B43, 0x16B50, 0x16B59, 0x16B5B, 0x16B61, 0x16B63,
|
||||
0x16B77, 0x16B7D, 0x16B8F, 0x16D40, 0x16D6C, 0x16D70, 0x16D79, 0x16E40, 0x16E96, 0x16F00, 0x16F4A, 0x16F50,
|
||||
0x16F50, 0x16F93, 0x16F9F, 0x16FE0, 0x16FE1, 0x16FE3, 0x16FE3, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF,
|
||||
0x18D08, 0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150,
|
||||
0x1B152, 0x1B155, 0x1B155, 0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1BC00, 0x1BC6A, 0x1BC70, 0x1BC7C, 0x1BC80,
|
||||
0x1BC88, 0x1BC90, 0x1BC99, 0x1CCF0, 0x1CCF9, 0x1D2C0, 0x1D2D3, 0x1D2E0, 0x1D2F3, 0x1D360, 0x1D378, 0x1D400,
|
||||
0x1D454, 0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE,
|
||||
0x1D4B9, 0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514, 0x1D516,
|
||||
0x1D51C, 0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552,
|
||||
0x1D6A5, 0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714, 0x1D716, 0x1D734, 0x1D736,
|
||||
0x1D74E, 0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA, 0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1D7CE,
|
||||
0x1D7FF, 0x1DF00, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D, 0x1E100, 0x1E12C, 0x1E137, 0x1E13D, 0x1E140,
|
||||
0x1E149, 0x1E14E, 0x1E14E, 0x1E290, 0x1E2AD, 0x1E2C0, 0x1E2EB, 0x1E2F0, 0x1E2F9, 0x1E4D0, 0x1E4EB, 0x1E4F0,
|
||||
0x1E4F9, 0x1E5D0, 0x1E5ED, 0x1E5F0, 0x1E5FA, 0x1E7E0, 0x1E7E6, 0x1E7E8, 0x1E7EB, 0x1E7ED, 0x1E7EE, 0x1E7F0,
|
||||
0x1E7FE, 0x1E800, 0x1E8C4, 0x1E8C7, 0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1E94B, 0x1E950, 0x1E959, 0x1EC71,
|
||||
0x1ECAB, 0x1ECAD, 0x1ECAF, 0x1ECB1, 0x1ECB4, 0x1ED01, 0x1ED2D, 0x1ED2F, 0x1ED3D, 0x1EE00, 0x1EE03, 0x1EE05,
|
||||
0x1EE1F, 0x1EE21, 0x1EE22, 0x1EE24, 0x1EE24, 0x1EE27, 0x1EE27, 0x1EE29, 0x1EE32, 0x1EE34, 0x1EE37, 0x1EE39,
|
||||
0x1EE39, 0x1EE3B, 0x1EE3B, 0x1EE42, 0x1EE42, 0x1EE47, 0x1EE47, 0x1EE49, 0x1EE49, 0x1EE4B, 0x1EE4B, 0x1EE4D,
|
||||
0x1EE4F, 0x1EE51, 0x1EE52, 0x1EE54, 0x1EE54, 0x1EE57, 0x1EE57, 0x1EE59, 0x1EE59, 0x1EE5B, 0x1EE5B, 0x1EE5D,
|
||||
0x1EE5D, 0x1EE5F, 0x1EE5F, 0x1EE61, 0x1EE62, 0x1EE64, 0x1EE64, 0x1EE67, 0x1EE6A, 0x1EE6C, 0x1EE72, 0x1EE74,
|
||||
0x1EE77, 0x1EE79, 0x1EE7C, 0x1EE7E, 0x1EE7E, 0x1EE80, 0x1EE89, 0x1EE8B, 0x1EE9B, 0x1EEA1, 0x1EEA3, 0x1EEA5,
|
||||
0x1EEA9, 0x1EEAB, 0x1EEBB, 0x1F100, 0x1F10C, 0x1FBF0, 0x1FBF9, 0x20000, 0x2A6DF, 0x2A700, 0x2B739, 0x2B740,
|
||||
0x2B81D, 0x2B820, 0x2CEA1, 0x2CEB0, 0x2EBE0, 0x2EBF0, 0x2EE5D, 0x2F800, 0x2FA1D, 0x30000, 0x3134A, 0x31350,
|
||||
0x323AF,
|
||||
)
|
||||
|
||||
internal val DEFAULT_IGNORABLE: IntArray = intArrayOf(
|
||||
0xAD, 0xAD, 0x34F, 0x34F, 0x61C, 0x61C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180F, 0x200B, 0x200F, 0x202A,
|
||||
0x202E, 0x2060, 0x206F, 0x3164, 0x3164, 0xFE00, 0xFE0F, 0xFEFF, 0xFEFF, 0xFFA0, 0xFFA0, 0xFFF0, 0xFFF8, 0x1BCA0,
|
||||
0x1BCA3, 0x1D173, 0x1D17A, 0xE0000, 0xE0FFF,
|
||||
)
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
package org.gamecult.cultmesh
|
||||
|
||||
// The write receipt (contracts/cultnet/interop/write-receipt.md): the two frames, their strict decode, the dedupe
|
||||
// content hash, the hello capability test and the client settle rules. Mirrors packages/cultnet-ts (contracts.ts,
|
||||
// write-tracker.ts) and packages/cultnet-rs (write_content_hash). The replay of write-receipt-vectors.json lives in
|
||||
// WriteReceiptVectors.kt. Server rules (dedupe, limits, blank fields) are not a decoder's business and are not here.
|
||||
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
|
||||
const val WRITE_ADMITTED_VERSION = "cultnet.write_admitted.v0"
|
||||
const val ERROR_V1_VERSION = "cultnet.error.v1"
|
||||
const val ERROR_V0_VERSION = "cultnet.error.v0"
|
||||
|
||||
/** A connection is receipt-capable only when its hello lists both frames. */
|
||||
val CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS: List<String> = listOf(WRITE_ADMITTED_VERSION, ERROR_V1_VERSION)
|
||||
|
||||
const val RECEIPT_RESULT_WINDOW_MS = 5000L
|
||||
const val LEGACY_RESULT_WINDOW_MS = 500L
|
||||
|
||||
fun resultWindowMs(serverAdvertisesReceipts: Boolean): Long =
|
||||
if (serverAdvertisesReceipts) RECEIPT_RESULT_WINDOW_MS else LEGACY_RESULT_WINDOW_MS
|
||||
|
||||
/** The closed refusal-code set of error.v0; error.v1 adds `limit_reached`. */
|
||||
val CULTNET_ERROR_V0_CODES: Set<String> = setOf(
|
||||
"selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported",
|
||||
"unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid",
|
||||
)
|
||||
val CULTNET_ERROR_V1_CODES: Set<String> = CULTNET_ERROR_V0_CODES + "limit_reached"
|
||||
|
||||
fun helloAdvertisesWriteReceipts(hello: CultNetMessage): Boolean {
|
||||
val versions = hello.body["supportedMessageVersions"] as? List<*> ?: return false
|
||||
return CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS.all { it in versions }
|
||||
}
|
||||
|
||||
data class CultNetWriteAdmitted(val messageId: String, val operation: String, val schemaId: String, val recordKey: String) {
|
||||
fun toMessage() = CultNetMessage(
|
||||
WRITE_ADMITTED_VERSION,
|
||||
linkedMapOf("messageId" to messageId, "operation" to operation, "schemaId" to schemaId, "recordKey" to recordKey),
|
||||
)
|
||||
}
|
||||
|
||||
data class CultNetErrorDetails(val field: String?, val value: String?, val asOf: Long?, val current: Long?)
|
||||
|
||||
/** error.v0 (`messageId` always null, `version` 0) or error.v1 (`version` 1). */
|
||||
data class CultNetErrorFrame(
|
||||
val version: Int,
|
||||
val error: String,
|
||||
val routingHint: Map<String, Any?>?,
|
||||
val code: String?,
|
||||
val details: CultNetErrorDetails?,
|
||||
val messageId: String?,
|
||||
) {
|
||||
fun toMessage(): CultNetMessage {
|
||||
val body = linkedMapOf<String, Any?>(
|
||||
"error" to error,
|
||||
"routingHint" to routingHint,
|
||||
"code" to code,
|
||||
"details" to details?.let {
|
||||
linkedMapOf("field" to it.field, "value" to it.value, "asOf" to it.asOf, "current" to it.current)
|
||||
},
|
||||
)
|
||||
if (version == 1) body["messageId"] = messageId
|
||||
return CultNetMessage(if (version == 1) ERROR_V1_VERSION else ERROR_V0_VERSION, body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict decode of a write_admitted.v0 message against its closed schema: exactly five keys, a non-empty string each,
|
||||
* operation put|delete.
|
||||
*/
|
||||
fun decodeWriteAdmitted(message: CultNetMessage): CultNetWriteAdmitted {
|
||||
if (message.schemaVersion != WRITE_ADMITTED_VERSION) throw IOException("not a $WRITE_ADMITTED_VERSION message")
|
||||
closedKeys(message, setOf("messageId", "operation", "schemaId", "recordKey"))
|
||||
val operation = nonEmpty(message, "operation")
|
||||
if (operation != "put" && operation != "delete") throw IOException("operation must be put or delete")
|
||||
return CultNetWriteAdmitted(admittedMessageId(message), operation, nonEmpty(message, "schemaId"), nonEmpty(message, "recordKey"))
|
||||
}
|
||||
|
||||
// write-receipt.md: a letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) somewhere, no Default_Ignorable code point anywhere, as of
|
||||
// Unicode 16.0, compared raw (never trimmed). The tables are embedded (UnicodeMessageIdTables.kt, generated by
|
||||
// scripts/gen-unicode-messageid-tables.py), not the JDK's, so the running JVM's Unicode version changes no result; the other
|
||||
// four runtimes embed the same generated tables.
|
||||
private fun inRanges(ranges: IntArray, point: Int): Boolean {
|
||||
var low = 0
|
||||
var high = ranges.size / 2 - 1
|
||||
while (low <= high) {
|
||||
val mid = (low + high) ushr 1
|
||||
when {
|
||||
point < ranges[2 * mid] -> high = mid - 1
|
||||
point > ranges[2 * mid + 1] -> low = mid + 1
|
||||
else -> return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun admittedMessageId(message: CultNetMessage): String {
|
||||
val id = nonEmpty(message, "messageId")
|
||||
var hasLetterOrNumber = false
|
||||
id.codePoints().forEach { cp ->
|
||||
if (inRanges(DEFAULT_IGNORABLE, cp)) throw IOException("messageId holds a Default_Ignorable code point")
|
||||
if (inRanges(LETTER_OR_NUMBER, cp)) hasLetterOrNumber = true
|
||||
}
|
||||
if (!hasLetterOrNumber) throw IOException("messageId must contain a letter or number")
|
||||
return id
|
||||
}
|
||||
|
||||
/** Strict decode of error.v0 (no messageId, no limit_reached) or error.v1 (messageId required, string or nil, never empty). */
|
||||
fun decodeErrorFrame(message: CultNetMessage): CultNetErrorFrame {
|
||||
val version = when (message.schemaVersion) {
|
||||
ERROR_V1_VERSION -> 1
|
||||
ERROR_V0_VERSION -> 0
|
||||
else -> throw IOException("not an error message")
|
||||
}
|
||||
closedKeys(message, setOf("error", "routingHint", "code", "details") + if (version == 1) setOf("messageId") else emptySet())
|
||||
val error = nonEmpty(message, "error")
|
||||
val codeValue = message.body["code"]
|
||||
if (codeValue != null && (codeValue !is String || codeValue !in (if (version == 1) CULTNET_ERROR_V1_CODES else CULTNET_ERROR_V0_CODES))) {
|
||||
throw IOException("unknown error code")
|
||||
}
|
||||
var messageId: String? = null
|
||||
if (version == 1) {
|
||||
val id = message.body["messageId"]
|
||||
if (id != null && (id !is String || id.isEmpty())) throw IOException("messageId must be a non-empty string or nil")
|
||||
messageId = id as String?
|
||||
}
|
||||
return CultNetErrorFrame(version, error, routingHintOf(message.body["routingHint"]), codeValue as String?, detailsOf(message.body["details"]), messageId)
|
||||
}
|
||||
|
||||
private fun closedKeys(message: CultNetMessage, required: Set<String>) {
|
||||
if (!message.body.keys.containsAll(required)) throw IOException("${message.schemaVersion} is missing ${required - message.body.keys}")
|
||||
val unknown = message.body.keys - required
|
||||
if (unknown.isNotEmpty()) throw IOException("${message.schemaVersion} carries unknown keys $unknown")
|
||||
}
|
||||
|
||||
private fun nonEmpty(message: CultNetMessage, key: String): String {
|
||||
val value = message.body[key]
|
||||
if (value !is String || value.isEmpty()) throw IOException("$key must be a non-empty string")
|
||||
return value
|
||||
}
|
||||
|
||||
private fun routingHintOf(value: Any?): Map<String, Any?>? {
|
||||
if (value == null) return null
|
||||
if (value !is Map<*, *> || value.keys != setOf("shard", "reason")) throw IOException("routingHint must be nil or {shard, reason}")
|
||||
if (value["shard"] != null && value["shard"] !is Map<*, *>) throw IOException("routingHint.shard must be an object or nil")
|
||||
if (value["reason"] !is String) throw IOException("routingHint.reason must be a string")
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return value as Map<String, Any?>
|
||||
}
|
||||
|
||||
private fun detailsOf(value: Any?): CultNetErrorDetails? {
|
||||
if (value == null) return null
|
||||
if (value !is Map<*, *> || value.keys != setOf("field", "value", "asOf", "current")) {
|
||||
throw IOException("details must be nil or {field, value, asOf, current}")
|
||||
}
|
||||
fun text(key: String): String? = value[key].let { if (it == null || it is String) it as String? else throw IOException("details.$key must be a string or nil") }
|
||||
fun cursor(key: String): Long? = value[key].let {
|
||||
if (it == null) null else if (it is Long && it >= 0) it else throw IOException("details.$key must be a non-negative integer or nil")
|
||||
}
|
||||
return CultNetErrorDetails(text("field"), text("value"), cursor("asOf"), cursor("current"))
|
||||
}
|
||||
|
||||
/** sha256 of the MessagePack array [operation, schemaId, recordKey, payload]: payload a bin for a put, nil for a delete. Lowercase hex. */
|
||||
fun writeContentHash(operation: String, schemaId: String, recordKey: String, payload: ByteArray?): String {
|
||||
val writer = MessagePackWriter().array(4).string(operation).string(schemaId).string(recordKey)
|
||||
if (payload == null) writer.nil() else writer.binary(payload)
|
||||
return MessageDigest.getInstance("SHA-256").digest(writer.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/** Why nothing settled a write. */
|
||||
enum class WriteUnconfirmedReason(val wire: String) { WINDOW_ELAPSED("window_elapsed"), SESSION_ENDED("session_ended"), LOCAL_SEND_FAILED("local_send_failed") }
|
||||
|
||||
/** What a late answer is recorded as on a write that already settled unconfirmed. */
|
||||
enum class WriteRecorded(val wire: String) { ADMITTED_LATE("admitted_late"), REFUSED_LATE("refused_late") }
|
||||
|
||||
/** What the caller was told. Settled once; never changed by a later frame. */
|
||||
sealed class WriteSettlement {
|
||||
object Admitted : WriteSettlement()
|
||||
data class Refused(val code: String?, val error: String, val response: CultNetErrorFrame) : WriteSettlement()
|
||||
data class Unconfirmed(val reason: WriteUnconfirmedReason) : WriteSettlement()
|
||||
}
|
||||
|
||||
/**
|
||||
* The client half of the receipt rules: a pure state machine with an abstract clock, one decision, how each sent write
|
||||
* settles. Transport, timers and decoding belong to the caller; `frame` takes a message and decodes it strictly.
|
||||
*/
|
||||
class WriteTracker(private val windowMs: Long, private val onSettle: (String, WriteSettlement) -> Unit = { _, _ -> }) {
|
||||
private class Entry(val sentAtMs: Long) {
|
||||
var settlement: WriteSettlement? = null
|
||||
var recorded: WriteRecorded? = null
|
||||
}
|
||||
|
||||
private val writes = linkedMapOf<String, Entry>()
|
||||
private var ended = false
|
||||
|
||||
/** Registers a write sent at [atMs]. Re-sending a known id is the same write and does not restart its window. */
|
||||
fun send(messageId: String, atMs: Long) {
|
||||
tick(atMs)
|
||||
if (messageId in writes) return
|
||||
val entry = Entry(atMs)
|
||||
writes[messageId] = entry
|
||||
// On a session that already ended nothing can answer this write.
|
||||
if (ended) settle(messageId, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.SESSION_ENDED))
|
||||
}
|
||||
|
||||
/** Feeds one received message. Only write_admitted, error.v1 and error.v0 answer; anything else is ignored. */
|
||||
fun frame(message: CultNetMessage, atMs: Long) {
|
||||
tick(atMs)
|
||||
when (message.schemaVersion) {
|
||||
WRITE_ADMITTED_VERSION -> {
|
||||
answer(decodeWriteAdmitted(message).messageId, WriteSettlement.Admitted, WriteRecorded.ADMITTED_LATE)
|
||||
return
|
||||
}
|
||||
ERROR_V1_VERSION, ERROR_V0_VERSION -> {
|
||||
val error = decodeErrorFrame(message)
|
||||
val refusal = WriteSettlement.Refused(error.code, error.error, error)
|
||||
if (error.messageId != null) {
|
||||
answer(error.messageId, refusal, WriteRecorded.REFUSED_LATE)
|
||||
return
|
||||
}
|
||||
// An error that names no write is attributable only when exactly one write is unanswered.
|
||||
val unanswered = writes.filterValues { it.settlement == null }
|
||||
if (unanswered.size == 1) settle(unanswered.keys.first(), unanswered.values.first(), refusal)
|
||||
}
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
|
||||
/** Every unanswered write whose window, counted from its own send, has run settles unconfirmed. */
|
||||
fun tick(atMs: Long) {
|
||||
for ((id, entry) in writes) {
|
||||
if (entry.settlement == null && atMs - entry.sentAtMs >= windowMs) settle(id, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.WINDOW_ELAPSED))
|
||||
}
|
||||
}
|
||||
|
||||
/** The session ended: every unanswered write settles unconfirmed, and so does any write sent afterwards. */
|
||||
fun sessionEnd(atMs: Long) {
|
||||
tick(atMs)
|
||||
ended = true
|
||||
for ((id, entry) in writes) if (entry.settlement == null) settle(id, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.SESSION_ENDED))
|
||||
}
|
||||
|
||||
/** This client's own send of [messageId] failed: that write settles unconfirmed; the others are untouched. */
|
||||
fun sendFailed(messageId: String, atMs: Long) {
|
||||
val entry = writes[messageId]
|
||||
if (entry != null && entry.settlement == null) settle(messageId, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.LOCAL_SEND_FAILED))
|
||||
tick(atMs)
|
||||
}
|
||||
|
||||
/** The settlement told to the caller, or null while unanswered or unknown. */
|
||||
fun settlement(messageId: String): WriteSettlement? = writes[messageId]?.settlement
|
||||
|
||||
/** The late answer recorded on an unconfirmed write. */
|
||||
fun recorded(messageId: String): WriteRecorded? = writes[messageId]?.recorded
|
||||
|
||||
private fun answer(messageId: String, settlement: WriteSettlement, late: WriteRecorded) {
|
||||
val entry = writes[messageId] ?: return // names a write that is not in flight: settles nothing
|
||||
val current = entry.settlement
|
||||
if (current == null) settle(messageId, entry, settlement)
|
||||
else if (current is WriteSettlement.Unconfirmed && entry.recorded == null) entry.recorded = late
|
||||
}
|
||||
|
||||
private fun settle(messageId: String, entry: Entry, settlement: WriteSettlement) {
|
||||
entry.settlement = settlement
|
||||
onSettle(messageId, settlement)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unicode White_Space (PropList), the one definition of "blank" in write-receipt.md rule 5; Kotlin's own isBlank takes U+001C to U+001F. */
|
||||
private val WHITE_SPACE_CODE_POINTS: Set<Int> = (0x0009..0x000D).toSet() + 0x0020 + 0x0085 + 0x00A0 + 0x1680 +
|
||||
(0x2000..0x200A).toSet() + 0x2028 + 0x2029 + 0x202F + 0x205F + 0x3000
|
||||
|
||||
/** True for null, empty, or only White_Space code points. */
|
||||
fun isBlankText(text: String?): Boolean = text == null || text.codePoints().allMatch { it in WHITE_SPACE_CODE_POINTS }
|
||||
|
||||
/** Self-test: blank is White_Space, not Kotlin's isBlank (rule 5): U+001C to U+001F name something, U+00A0 and U+0085 do not. */
|
||||
fun rawDocumentRecordDropsOnlyWhiteSpaceSourceIds() {
|
||||
fun wireKeys(id: String) = CultNetRawDocumentRecord("note.v0", "note:1", "2026-11-08T00:00:00Z", payload = byteArrayOf(1), sourceRuntimeId = id, sourceAgentId = id, sourceRole = id).toWireMap().keys
|
||||
for (blank in listOf("", "\u0085", "\u00a0\u2003", "\u3000", "\t\r\n ")) {
|
||||
check(isBlankText(blank) && "sourceRuntimeId" !in wireKeys(blank)) { "blank ${blank.map { it.code }} must be dropped" }
|
||||
}
|
||||
for (named in listOf("\u001c", "\u001f", "\u200b", "\ufeff", "\u180e", "a")) {
|
||||
check(!isBlankText(named) && "sourceRuntimeId" in wireKeys(named) && "sourceRole" in wireKeys(named)) { "${named.map { it.code }} names something and must be kept" }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
package org.gamecult.cultmesh
|
||||
|
||||
// Replays contracts/cultnet/interop/write-receipt-vectors.json against this runtime: constants, contentHash, every
|
||||
// `wire` frame (decode to the vector's fields, re-encode to its bytes), every `schemaInvalid` frame (must be refused),
|
||||
// and every `scenarios` event replay through WriteTracker. `dedupe` and `blankFields` are server inputs and are not
|
||||
// run here. Mode: `java -cp ... org.gamecult.cultmesh.CultMeshKt write-receipt-vectors <vectors.json>`.
|
||||
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
// The event actions scenarios.semantics names; anything else is refused, not skipped.
|
||||
private val EVENT_KEYS = listOf("send", "frame", "tick", "sessionEnd", "sendFailed", "transportAck")
|
||||
|
||||
fun replayWriteReceiptVectors(path: String): Boolean {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val vectors = JsonReader(File(path).readText(Charsets.UTF_8)).readDocument() as Map<String, Any?>
|
||||
val results = linkedMapOf<String, List<String>>()
|
||||
fun check(name: String, run: () -> List<String>) {
|
||||
results[name] = try { run() } catch (e: Throwable) { listOf("threw: $e") }
|
||||
}
|
||||
|
||||
val constants = obj(vectors["constants"])
|
||||
check("constants") {
|
||||
listOfNotNull(
|
||||
"RECEIPT window".takeIf { constants["RECEIPT_RESULT_WINDOW_MS"] != RECEIPT_RESULT_WINDOW_MS },
|
||||
"LEGACY window".takeIf { constants["LEGACY_RESULT_WINDOW_MS"] != LEGACY_RESULT_WINDOW_MS },
|
||||
)
|
||||
}
|
||||
|
||||
for (case in list(obj(vectors["contentHash"])["cases"])) {
|
||||
val c = obj(case)
|
||||
check("contentHash/${c["name"]}") {
|
||||
val got = writeContentHash(c["operation"] as String, c["schemaId"] as String, c["recordKey"] as String, (c["payloadHex"] as String?)?.let(::unhex))
|
||||
if (got == c["sha256"]) emptyList() else listOf("$got != ${c["sha256"]}")
|
||||
}
|
||||
}
|
||||
|
||||
for (case in list(vectors["wire"])) {
|
||||
val c = obj(case)
|
||||
check("wire/${c["name"]}") {
|
||||
val bytes = unhex(c["messagePackHex"] as String)
|
||||
val problems = mutableListOf<String>()
|
||||
val message = decodeReceiptFrame(parseCultNetMessage(bytes))
|
||||
if (message.toWireMap() != c["message"]) problems += "decoded ${message.toWireMap()} != ${c["message"]}"
|
||||
val again = message.toBytes().joinToString("") { "%02X".format(it) }
|
||||
if (again != (c["messagePackHex"] as String).uppercase()) problems += "re-encoded bytes $again != captured ${c["messagePackHex"]}"
|
||||
problems
|
||||
}
|
||||
}
|
||||
|
||||
// admittedMessageId: a write_admitted messageId needs a letter or number and no Default_Ignorable code point, compared
|
||||
// raw. The frame is built with this runtime's own encoder; a case with `wireHex` (the lone surrogate, invalid UTF-8)
|
||||
// supplies the whole frame.
|
||||
for (case in list(obj(vectors["admittedMessageId"])["cases"])) {
|
||||
val c = obj(case)
|
||||
check("admittedMessageId/${c["name"]}") {
|
||||
val id = list(c["codePoints"]).joinToString("") { String(Character.toChars((it as String).toInt(16))) }.repeat(((c["repeat"] as Long?) ?: 1L).toInt())
|
||||
val bytes = (c["wireHex"] as String?)?.let(::unhex) ?: MessagePackWriter().value(linkedMapOf(
|
||||
"schemaVersion" to WRITE_ADMITTED_VERSION, "messageId" to id, "operation" to "put", "schemaId" to "note.v0", "recordKey" to "note:1",
|
||||
)).toByteArray()
|
||||
val decoded = try { decodeReceiptFrame(parseCultNetMessage(bytes)) } catch (_: IOException) { null }
|
||||
when {
|
||||
c["expect"] == "refuse" -> if (decoded == null) emptyList() else listOf("accepted an id that must be refused (${c["why"]})")
|
||||
decoded == null -> listOf("refused an id that must be accepted (${c["why"]})")
|
||||
decoded.toWireMap()["messageId"] != id -> listOf("id not echoed raw: ${decoded.toWireMap()["messageId"]}")
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The generated ranges file is the independent copy of the embedded tables: first, last and the points either side of every
|
||||
// range, so deleting or narrowing any range fails here.
|
||||
check("unicodeTableRanges") {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val ranges = JsonReader(File(File(path).parentFile, "write-admitted-message-id-ranges.json").readText(Charsets.UTF_8)).readDocument() as Map<String, Any?>
|
||||
fun pairs(key: String) = list(ranges[key]).map { list(it).map { n -> (n as Long).toInt() } }
|
||||
val letters = pairs("letterOrNumber")
|
||||
val ignorable = pairs("defaultIgnorable")
|
||||
val problems = mutableListOf<String>()
|
||||
if (letters.size <= 700 || ignorable.size != 17) problems += "ranges file holds ${letters.size} and ${ignorable.size} ranges"
|
||||
fun accepted(id: String): Boolean = try {
|
||||
decodeReceiptFrame(parseCultNetMessage(MessagePackWriter().value(linkedMapOf(
|
||||
"schemaVersion" to WRITE_ADMITTED_VERSION, "messageId" to id, "operation" to "put", "schemaId" to "note.v0", "recordKey" to "note:1",
|
||||
)).toByteArray()))
|
||||
true
|
||||
} catch (_: IOException) { false }
|
||||
for ((low, high) in letters) for ((point, want) in listOf(low to true, high to true, low - 1 to false, high + 1 to false)) {
|
||||
if (accepted(String(Character.toChars(point))) != want) problems += "U+${point.toString(16)} alone should be ${if (want) "accepted" else "refused"}"
|
||||
}
|
||||
for ((low, high) in ignorable) for ((point, want) in listOf(low to false, high to false, low - 1 to true, high + 1 to true)) {
|
||||
if (accepted("a" + String(Character.toChars(point))) != want) problems += "a + U+${point.toString(16)} should be ${if (want) "accepted" else "refused"}"
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
// A document payload string with a lone surrogate (ED A0 80) is not a write_admitted id: the shared MessagePack reader keeps
|
||||
// reading it. Strictness is scoped to the write_admitted frame, never to the reader.
|
||||
check("documentPayloadStringWithLoneSurrogateStillDecodes") {
|
||||
val text = MessagePackReader(unhex("A3EDA080")).readString()
|
||||
if (text.isNotEmpty()) emptyList() else listOf("decoded to an empty string")
|
||||
}
|
||||
|
||||
// admittedMessageIdDuplicates: Kotlin decodes the frame to a map, so it cannot see a repeated key: it judges the last one.
|
||||
for (case in list(obj(vectors["admittedMessageIdDuplicates"])["cases"])) {
|
||||
val c = obj(case)
|
||||
check("admittedMessageIdDuplicates/${c["name"]}") {
|
||||
val decoded = try { decodeReceiptFrame(parseCultNetMessage(unhex(c["wireHex"] as String))) } catch (_: IOException) { null }
|
||||
when {
|
||||
c["lastKeyWins"] == "refuse" -> if (decoded == null) emptyList() else listOf("accepted a frame whose last messageId key is invalid")
|
||||
decoded == null -> listOf("refused a frame whose last messageId key is valid")
|
||||
decoded.toWireMap()["messageId"] != c["lastKeyWinsEcho"] -> listOf("echoed ${decoded.toWireMap()["messageId"]}, wanted ${c["lastKeyWinsEcho"]}")
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (case in list(vectors["schemaInvalid"])) {
|
||||
val c = obj(case)
|
||||
check("schemaInvalid/${c["name"]}") {
|
||||
try {
|
||||
decodeReceiptFrame(parseCultNetMessage(unhex(c["messagePackHex"] as String)))
|
||||
listOf("accepted a frame that must be refused (${c["why"]})")
|
||||
} catch (_: IOException) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not a vector: `limit_reached` is in error.v1's enum only (write-receipt.md), as the TS and Rust suites also pin.
|
||||
check("error_v0_refuses_the_v1_only_limit_reached_code") {
|
||||
val body = linkedMapOf<String, Any?>("error" to "x", "routingHint" to null, "code" to "limit_reached", "details" to null)
|
||||
val v1 = decodeErrorFrame(CultNetMessage(ERROR_V1_VERSION, body + ("messageId" to null))).code
|
||||
try {
|
||||
decodeErrorFrame(CultNetMessage(ERROR_V0_VERSION, body))
|
||||
listOf("error.v0 accepted limit_reached")
|
||||
} catch (_: IOException) {
|
||||
if (v1 == "limit_reached") emptyList() else listOf("error.v1 lost limit_reached")
|
||||
}
|
||||
}
|
||||
|
||||
for (case in list(obj(vectors["scenarios"])["cases"])) {
|
||||
val scenario = obj(case)
|
||||
check("scenario/${scenario["name"]}") {
|
||||
val problems = mutableListOf<String>()
|
||||
// The fake server states its capability the only way a real one does: in its hello.
|
||||
val hello = cultNetHello(
|
||||
"fake-server", "fake",
|
||||
supportedMessageVersions = if (scenario["server"] == "receipt") CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS else emptyList(),
|
||||
)
|
||||
val windowMs = resultWindowMs(helloAdvertisesWriteReceipts(hello))
|
||||
if (windowMs != constants[scenario["window"] as String]) problems += "window $windowMs != ${scenario["window"]}"
|
||||
val told = linkedMapOf<String, WriteSettlement>()
|
||||
val tracker = WriteTracker(windowMs) { id, settlement ->
|
||||
if (id in told) problems += "$id settled twice"
|
||||
told[id] = settlement
|
||||
}
|
||||
for (event in list(scenario["events"]).map(::obj).sortedBy { it["atMs"] as Long }) {
|
||||
val at = event["atMs"] as Long
|
||||
val actions = event.keys - "atMs"
|
||||
if (actions.size != 1 || actions.first() !in EVENT_KEYS) { problems += "event at $at has keys $actions, wanted atMs and exactly one of $EVENT_KEYS"; continue }
|
||||
when {
|
||||
"send" in event -> tracker.send(event["send"] as String, at)
|
||||
// A frame reaches the client as bytes: the fake server encodes it, the client decodes it.
|
||||
"frame" in event -> tracker.frame(parseCultNetMessage(MessagePackWriter().value(event["frame"]).toByteArray()), at)
|
||||
"tick" in event -> tracker.tick(at)
|
||||
"sessionEnd" in event -> tracker.sessionEnd(at)
|
||||
"sendFailed" in event -> tracker.sendFailed(event["sendFailed"] as String, at)
|
||||
// transportAck: delivery is not an answer; the tracker has no such input and must not need one.
|
||||
}
|
||||
}
|
||||
val expect = obj(scenario["expect"])
|
||||
for ((id, wantAny) in expect) {
|
||||
val want = obj(wantAny)
|
||||
val got = told[id]
|
||||
if (got == null) { problems += "$id: never settled, wanted ${want["outcome"]}"; continue }
|
||||
val (outcome, code, reason) = when (got) {
|
||||
is WriteSettlement.Admitted -> Triple("admitted", null, null)
|
||||
is WriteSettlement.Refused -> Triple("refused", got.code, null)
|
||||
is WriteSettlement.Unconfirmed -> Triple("unconfirmed", null, got.reason.wire)
|
||||
}
|
||||
if (outcome != want["outcome"]) problems += "$id: told $outcome, wanted ${want["outcome"]}"
|
||||
if ("code" in want && code != want["code"]) problems += "$id: code $code, wanted ${want["code"]}"
|
||||
if ("reason" in want && reason != want["reason"]) problems += "$id: reason $reason, wanted ${want["reason"]}"
|
||||
if (tracker.recorded(id)?.wire != want["recorded"]) problems += "$id: recorded ${tracker.recorded(id)?.wire}, wanted ${want["recorded"]}"
|
||||
}
|
||||
for (id in told.keys) if (id !in expect) problems += "$id: settled but the scenario expects nothing for it"
|
||||
problems
|
||||
}
|
||||
}
|
||||
|
||||
// A runner that read fewer vectors than the file declares has silently skipped some.
|
||||
check("counts") {
|
||||
val declared = obj(vectors["counts"])
|
||||
fun ran(prefix: String) = results.keys.count { it.startsWith(prefix) }
|
||||
val loaded = mapOf(
|
||||
"wire" to list(vectors["wire"]).size, "schemaInvalid" to list(vectors["schemaInvalid"]).size,
|
||||
"contentHashCases" to list(obj(vectors["contentHash"])["cases"]).size, "scenarioCases" to list(obj(vectors["scenarios"])["cases"]).size,
|
||||
"admittedMessageIdCases" to list(obj(vectors["admittedMessageId"])["cases"]).size,
|
||||
)
|
||||
val executed = mapOf("wire" to ran("wire/"), "schemaInvalid" to ran("schemaInvalid/"), "contentHashCases" to ran("contentHash/"), "scenarioCases" to ran("scenario/"), "admittedMessageIdCases" to ran("admittedMessageId/"))
|
||||
loaded.keys.filter { loaded[it] != (declared[it] as Long).toInt() || executed[it] != loaded[it] }
|
||||
.map { "$it: declared ${declared[it]}, loaded ${loaded[it]}, ran ${executed[it]}" }
|
||||
}
|
||||
|
||||
for ((name, problems) in results) {
|
||||
println(if (problems.isEmpty()) "PASS $name" else "FAIL $name")
|
||||
problems.forEach { println(" $it") }
|
||||
}
|
||||
val failed = results.values.count { it.isNotEmpty() }
|
||||
println("${results.size - failed}/${results.size} passed")
|
||||
return failed == 0
|
||||
}
|
||||
|
||||
/** Strict decode of whichever receipt-family frame this is, returned as the message its typed form encodes; anything else is refused. */
|
||||
private fun decodeReceiptFrame(message: CultNetMessage): CultNetMessage = when (message.schemaVersion) {
|
||||
WRITE_ADMITTED_VERSION -> decodeWriteAdmitted(message).toMessage()
|
||||
ERROR_V1_VERSION, ERROR_V0_VERSION -> decodeErrorFrame(message).toMessage()
|
||||
else -> throw IOException("not a receipt frame: ${message.schemaVersion}")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun obj(value: Any?): Map<String, Any?> = value as Map<String, Any?>
|
||||
private fun list(value: Any?): List<Any?> = value as List<Any?>
|
||||
|
||||
private fun unhex(text: String): ByteArray = ByteArray(text.length / 2) { text.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
||||
|
||||
/** A small JSON reader for the vector file: objects, arrays, strings, integers, true/false/null. */
|
||||
private class JsonReader(private val text: String) {
|
||||
private var at = 0
|
||||
|
||||
fun readDocument(): Any? = readValue().also { skipSpace(); if (at != text.length) throw IOException("trailing JSON") }
|
||||
|
||||
private fun skipSpace() { while (at < text.length && text[at] in " \t\r\n") at++ }
|
||||
|
||||
private fun readValue(): Any? {
|
||||
skipSpace()
|
||||
return when (val c = text[at]) {
|
||||
'{' -> {
|
||||
at++
|
||||
val map = linkedMapOf<String, Any?>()
|
||||
skipSpace()
|
||||
if (text[at] == '}') { at++; return map }
|
||||
do {
|
||||
skipSpace()
|
||||
val key = readString()
|
||||
skipSpace(); expect(':')
|
||||
map[key] = readValue()
|
||||
skipSpace()
|
||||
} while (closes('}'))
|
||||
map
|
||||
}
|
||||
'[' -> {
|
||||
at++
|
||||
val items = mutableListOf<Any?>()
|
||||
skipSpace()
|
||||
if (text[at] == ']') { at++; return items }
|
||||
do {
|
||||
items += readValue()
|
||||
skipSpace()
|
||||
} while (closes(']'))
|
||||
items
|
||||
}
|
||||
'"' -> readString()
|
||||
't' -> literal("true", true)
|
||||
'f' -> literal("false", false)
|
||||
'n' -> literal("null", null)
|
||||
else -> {
|
||||
if (c != '-' && !c.isDigit()) throw IOException("unexpected '$c' at $at")
|
||||
val start = at++
|
||||
while (at < text.length && text[at].isDigit()) at++
|
||||
text.substring(start, at).toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** After an element: false when [close] ends the container, true after a comma. */
|
||||
private fun closes(close: Char): Boolean {
|
||||
val c = text[at++]
|
||||
if (c == close) return false
|
||||
if (c != ',') throw IOException("expected , or $close at ${at - 1}")
|
||||
return true
|
||||
}
|
||||
|
||||
private fun literal(word: String, value: Any?): Any? {
|
||||
if (!text.startsWith(word, at)) throw IOException("bad literal at $at")
|
||||
at += word.length
|
||||
return value
|
||||
}
|
||||
|
||||
private fun expect(c: Char) { if (text[at++] != c) throw IOException("expected $c at ${at - 1}") }
|
||||
|
||||
private fun readString(): String {
|
||||
expect('"')
|
||||
val out = StringBuilder()
|
||||
while (true) {
|
||||
val c = text[at++]
|
||||
when (c) {
|
||||
'"' -> return out.toString()
|
||||
'\\' -> when (val e = text[at++]) {
|
||||
'n' -> out.append('\n'); 't' -> out.append('\t'); 'r' -> out.append('\r')
|
||||
'b' -> out.append('\b'); 'f' -> out.append('\u000c')
|
||||
'u' -> { out.append(text.substring(at, at + 4).toInt(16).toChar()); at += 4 }
|
||||
'"', '\\', '/' -> out.append(e)
|
||||
else -> throw IOException("bad escape \\$e")
|
||||
}
|
||||
else -> out.append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -43,9 +43,10 @@ from cultmesh_py import (
|
|||
|
||||
note_doc = define_database_entry_type(
|
||||
"mesh.note",
|
||||
[("body", 0), ("name", 1), ("kind", 2)],
|
||||
[("body", 0), ("name", 1), ("kind", 2), ("slug", 3)],
|
||||
name="name",
|
||||
indexes={"kind": "kind"},
|
||||
indexes={"slug": "slug"}, # unique: no two notes hold one slug; watch_by_index and get_key_by_index look it up
|
||||
roles={"kind": "kind"}, # many notes share a kind: a selection alias, never a lookup
|
||||
)
|
||||
node = CultMesh.start_node(
|
||||
"mesh.cc",
|
||||
|
|
@ -55,13 +56,13 @@ node = CultMesh.start_node(
|
|||
node.database.register_document(note_doc)
|
||||
unsubscribe = node.database.watch_record(note_doc, "note:1", lambda change: print(change.change_kind))
|
||||
unsubscribe_named = node.database.watch_by_name(note_doc, "intro", lambda change: print(change.record_key))
|
||||
unsubscribe_kind = node.database.watch_by_index(note_doc, "kind", "guide", lambda change: print(change.record_key))
|
||||
node.database.put(note_doc, "note:1", {"body": "hello", "name": "intro", "kind": "guide"})
|
||||
unsubscribe_slug = node.database.watch_by_index(note_doc, "slug", "intro-guide", lambda change: print(change.record_key))
|
||||
node.database.put(note_doc, "note:1", {"body": "hello", "name": "intro", "kind": "guide", "slug": "intro-guide"})
|
||||
live_note = node.database.get_required(note_doc, "note:1")
|
||||
put_message = node.database.put_raw_message(
|
||||
note_doc,
|
||||
"note:2",
|
||||
{"body": "wire me", "name": "wire", "kind": "interop"},
|
||||
{"body": "wire me", "name": "wire", "kind": "interop", "slug": "wire-me"},
|
||||
shard_id="interop",
|
||||
shard_epoch=1,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from pathlib import Path
|
|||
from typing import Any, Callable
|
||||
|
||||
from cultcache_py import CultCache, CultCacheEnvelope, SingleFileMessagePackBackingStore
|
||||
from cultcache_py.cache import CultCacheError
|
||||
from cultcache_py.cache import CultCacheError, index_text
|
||||
from cultcache_py.documents import DocumentDefinition, extract_value
|
||||
from cultnet_py import (
|
||||
CultNetAppliedRecord,
|
||||
|
|
@ -520,7 +520,7 @@ class CultMeshDatabase:
|
|||
raise ValueError(f"Document type {document.type!r} has no index {index!r}")
|
||||
|
||||
def filtered(change: CultMeshDatabaseChange) -> None:
|
||||
if self._change_matches_extractor(change, extractor, value):
|
||||
if self._change_matches_extractor(change, extractor, value, index_text):
|
||||
callback(self._change_as_document(change, requested))
|
||||
|
||||
return self.watch(filtered, document=document)
|
||||
|
|
@ -1295,10 +1295,11 @@ class CultMeshDatabase:
|
|||
change: CultMeshDatabaseChange,
|
||||
extractor: str | Callable[[Any], str | int | float | bool | None],
|
||||
expected: str,
|
||||
render: Callable[[Any], str | None] = str,
|
||||
) -> bool:
|
||||
return (
|
||||
self._value_matches_extractor(change.value, extractor, expected)
|
||||
or self._value_matches_extractor(change.previous_value, extractor, expected)
|
||||
self._value_matches_extractor(change.value, extractor, expected, render)
|
||||
or self._value_matches_extractor(change.previous_value, extractor, expected, render)
|
||||
)
|
||||
|
||||
def _value_matches_extractor(
|
||||
|
|
@ -1306,11 +1307,12 @@ class CultMeshDatabase:
|
|||
value: Any | None,
|
||||
extractor: str | Callable[[Any], str | int | float | bool | None],
|
||||
expected: str,
|
||||
render: Callable[[Any], str | None] = str,
|
||||
) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
actual = extract_value(value, extractor)
|
||||
return actual is not None and str(actual) == expected
|
||||
return actual is not None and render(actual) == expected
|
||||
|
||||
def _previous_values_for_snapshot_response(self, response: dict[str, Any]) -> dict[tuple[str, str], Any]:
|
||||
if response.get("schemaVersion") != "cultnet.snapshot_response_raw.v0":
|
||||
|
|
|
|||
84
packages/cultmesh-py/tests/test_unique_index_watch.py
Normal file
84
packages/cultmesh-py/tests/test_unique_index_watch.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from cultcache_py import define_database_entry_type
|
||||
from cultcache_py.cache import CultCacheError
|
||||
from cultmesh_py import CultMesh
|
||||
|
||||
# variants:ruling:unique-indexes. `indexes` are unique lookups, `roles` are selection aliases. A watch by index reads the
|
||||
# lookup's own canonical text (cultcache_py.cache.index_text), never a second rendering of the value.
|
||||
|
||||
|
||||
class WatchByIndexTests(unittest.TestCase):
|
||||
def test_the_watch_and_the_lookup_agree_on_the_text_of_a_value(self) -> None:
|
||||
document = define_database_entry_type("mesh.priced", [("price", 0)], indexes={"price": "price"})
|
||||
node = CultMesh.create_node(runtime_id="mesh-watch-text")
|
||||
node.database.register_document(document)
|
||||
cases = [
|
||||
(2.0, "2", "2.0"),
|
||||
(0.1, "0.1000000000000000055511151231257827021181583404541015625", "0.1"),
|
||||
(-0.0, "0", "-0.0"),
|
||||
]
|
||||
for index, (value, text, str_text) in enumerate(cases):
|
||||
with self.subTest(value=value):
|
||||
key = f"item:{index}"
|
||||
heard: list[str] = []
|
||||
misheard: list[str] = []
|
||||
node.database.watch_by_index(document, "price", text, lambda change: heard.append(change.record_key))
|
||||
node.database.watch_by_index(document, "price", str_text, lambda change: misheard.append(change.record_key))
|
||||
node.database.put(document, key, {"price": value})
|
||||
self.assertEqual(node.database.get_by_index(document, "price", text), {"price": value})
|
||||
self.assertEqual(heard, [key])
|
||||
if str_text != text:
|
||||
self.assertEqual(misheard, [])
|
||||
self.assertIsNone(node.database.get_by_index(document, "price", str_text))
|
||||
|
||||
def test_a_watch_hears_a_record_leaving_a_value_by_the_canonical_text(self) -> None:
|
||||
document = define_database_entry_type("mesh.leaving", [("price", 0)], indexes={"price": "price"})
|
||||
node = CultMesh.create_node(runtime_id="mesh-watch-leaving")
|
||||
node.database.register_document(document)
|
||||
node.database.put(document, "item:1", {"price": 2.0})
|
||||
heard: list[str] = []
|
||||
node.database.watch_by_index(document, "price", "2", lambda change: heard.append(change.record_key))
|
||||
node.database.put(document, "item:1", {"price": 3.0})
|
||||
self.assertEqual(heard, ["item:1"])
|
||||
|
||||
def test_a_blank_value_is_never_indexed_so_never_watched(self) -> None:
|
||||
document = define_database_entry_type("mesh.blank", [("sku", 0)], indexes={"sku": "sku"})
|
||||
node = CultMesh.create_node(runtime_id="mesh-watch-blank")
|
||||
node.database.register_document(document)
|
||||
heard: list[str] = []
|
||||
node.database.watch_by_index(document, "sku", " ", lambda change: heard.append(change.record_key))
|
||||
node.database.put(document, "item:1", {"sku": " "})
|
||||
self.assertEqual(heard, [])
|
||||
|
||||
def test_a_role_alias_is_not_watchable_by_index(self) -> None:
|
||||
document = define_database_entry_type("mesh.roles", [("kind", 0)], roles={"kind": "kind"})
|
||||
node = CultMesh.create_node(runtime_id="mesh-watch-role")
|
||||
node.database.register_document(document)
|
||||
with self.assertRaisesRegex(ValueError, "has no index"):
|
||||
node.database.watch_by_index(document, "kind", "guide", lambda _: None)
|
||||
|
||||
|
||||
class ReadmeTests(unittest.TestCase):
|
||||
def test_the_readme_note_document_admits_many_notes_of_one_kind(self) -> None:
|
||||
readme = (Path(__file__).parents[1] / "README.md").read_text(encoding="utf-8")
|
||||
declaration = re.search(r"^note_doc = define_database_entry_type\(\n.*?^\)\n", readme, re.S | re.M)
|
||||
self.assertIsNotNone(declaration)
|
||||
scope: dict = {"define_database_entry_type": define_database_entry_type}
|
||||
exec(declaration.group(0), scope) # noqa: S102 - the README's own declaration
|
||||
note_doc = scope["note_doc"]
|
||||
node = CultMesh.create_node(runtime_id="mesh-readme")
|
||||
node.database.register_document(note_doc)
|
||||
node.database.put(note_doc, "note:1", {"body": "a", "name": "one", "kind": "guide", "slug": "one"})
|
||||
node.database.put(note_doc, "note:2", {"body": "b", "name": "two", "kind": "guide", "slug": "two"})
|
||||
self.assertEqual(node.database.get_by_index(note_doc, "slug", "two")["body"], "b")
|
||||
with self.assertRaises(CultCacheError):
|
||||
node.database.put(note_doc, "note:3", {"body": "c", "name": "three", "kind": "guide", "slug": "one"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
77
packages/cultmesh-rs/examples/receipt_interop_server.rs
Normal file
77
packages/cultmesh-rs/examples/receipt_interop_server.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
//! The real Rust RUDP document server on an ephemeral loopback port, for the cross-runtime write-receipt interop
|
||||
//! harness (packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts). It is the production server with a
|
||||
//! recording sink and nothing else: stdout carries one JSON line per fact the harness asserts on, and the server runs
|
||||
//! until stdin closes.
|
||||
//!
|
||||
//! {"event":"listening","port":N}
|
||||
//! {"event":"applied","op":"put","messageId":"...","schemaId":"...","recordKey":"..."}
|
||||
//!
|
||||
//! Record keys that start with `refuse:` are refused by the sink (authorize and commit), which is how the harness
|
||||
//! asks for a refused write.
|
||||
use anyhow::Result;
|
||||
use cultmesh_rs::{
|
||||
CultMeshRudpDocumentServer, CultMeshRudpRawDocumentReceipt, CultMeshRudpRawDocumentSink,
|
||||
CultMeshRudpSnapshotQuery, CultMeshSystemClock,
|
||||
};
|
||||
use cultnet_rs::CultNetRawDocumentRecord;
|
||||
use serde_json::json;
|
||||
use std::io::Read;
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const REFUSED_PREFIX: &str = "refuse:";
|
||||
|
||||
struct RecordingSink;
|
||||
|
||||
impl CultMeshRudpRawDocumentSink for RecordingSink {
|
||||
fn authorize_raw_document(&mut self, receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> {
|
||||
if receipt.document.record_key.starts_with(REFUSED_PREFIX) {
|
||||
anyhow::bail!("not authorized");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> {
|
||||
if receipt.document.record_key.starts_with(REFUSED_PREFIX) {
|
||||
anyhow::bail!("not authorized");
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
json!({
|
||||
"event": "applied",
|
||||
"op": "put",
|
||||
"messageId": receipt.message_id,
|
||||
"schemaId": receipt.document.schema_id,
|
||||
"recordKey": receipt.document.record_key,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let source = |_: &CultMeshRudpSnapshotQuery| -> Result<Vec<CultNetRawDocumentRecord>> { Ok(Vec::new()) };
|
||||
let mut server = CultMeshRudpDocumentServer::new(
|
||||
UdpSocket::bind("127.0.0.1:0")?,
|
||||
RecordingSink,
|
||||
source,
|
||||
CultMeshSystemClock::default(),
|
||||
Default::default(),
|
||||
)?;
|
||||
println!("{}", json!({ "event": "listening", "port": server.local_addr()?.port() }));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let watcher = Arc::clone(&stop);
|
||||
thread::spawn(move || {
|
||||
let mut sink = Vec::new();
|
||||
let _ = std::io::stdin().read_to_end(&mut sink);
|
||||
watcher.store(true, Ordering::SeqCst);
|
||||
});
|
||||
while !stop.load(Ordering::SeqCst) {
|
||||
server.poll_once()?;
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -780,6 +780,13 @@ where
|
|||
return Ok(CultMeshRudpPollOutcome::Handled);
|
||||
}
|
||||
|
||||
// Whether this packet is the first the session takes for its sequence: a repeat of an answered write, or a
|
||||
// packet too far ahead to be remembered, is not, and the write is not answered for it again.
|
||||
let first_receipt = packet.reliable
|
||||
&& self
|
||||
.sessions
|
||||
.get(&key)
|
||||
.is_some_and(|entry| !entry.session.has_received(packet.sequence));
|
||||
let result = {
|
||||
let entry = self
|
||||
.sessions
|
||||
|
|
@ -821,11 +828,28 @@ where
|
|||
}
|
||||
|
||||
let mut kept_rejection = None;
|
||||
// A superseded packet delivers no frame, yet it was acknowledged and will not be resent, so its write would
|
||||
// go unanswered. A packet that carries its whole frame is answered from its own payload. A superseded
|
||||
// fragment cannot be: the write's id may lie in a fragment this packet does not hold.
|
||||
if refuse_sequenced
|
||||
&& first_receipt
|
||||
&& self.sessions.get(&key).is_some_and(|entry| entry.session.has_received(packet.sequence))
|
||||
&& packet.fragment_count == 0
|
||||
&& !result.delivered.iter().any(|frame| frame.channel_id == "schema" && frame.sequence == packet.sequence)
|
||||
{
|
||||
if let CultMeshRudpPollOutcome::ApplicationRejected(rejection) =
|
||||
self.refuse_unreliable_write(key, &packet.payload)?
|
||||
{
|
||||
kept_rejection = Some(rejection);
|
||||
}
|
||||
}
|
||||
for frame in result.delivered {
|
||||
if frame.channel_id != "schema" {
|
||||
continue;
|
||||
}
|
||||
if refuse_sequenced {
|
||||
// `delivered` also holds the ordered frames this packet's arrival released from the hold; those were
|
||||
// not sent on the sequenced channel. They sit above the packet's own sequence, its own frame at or below.
|
||||
if refuse_sequenced && sequence_at_or_before(frame.sequence, packet.sequence) {
|
||||
if let CultMeshRudpPollOutcome::ApplicationRejected(rejection) =
|
||||
self.refuse_unreliable_write(key, &frame.payload)?
|
||||
{
|
||||
|
|
@ -1814,6 +1838,13 @@ where
|
|||
#[cfg(test)]
|
||||
mod send_failure_tests;
|
||||
|
||||
/// Whether `sequence` is at or before `mark` in serial order (RFC 1982): the 32-bit space is compared modulo 2^32,
|
||||
/// so a frame that began before the wrap and a packet after it still order correctly. The receive window of
|
||||
/// `cultnet_rs`' duplicate test does not belong here: a frame's first fragment may lie any distance back.
|
||||
fn sequence_at_or_before(sequence: u32, mark: u32) -> bool {
|
||||
mark.wrapping_sub(sequence) < 1 << 31
|
||||
}
|
||||
|
||||
fn validate_options(options: &CultMeshRudpDocumentServerOptions) -> Result<()> {
|
||||
if options.max_sessions == 0 {
|
||||
return Err(anyhow!("max_sessions must be greater than zero"));
|
||||
|
|
@ -1929,4 +1960,17 @@ mod tests {
|
|||
.unwrap();
|
||||
assert_eq!(hex(&bytes), MESSAGE_INVALID_HEX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_order_holds_across_the_wrap_and_beyond_the_receive_window() {
|
||||
assert!(sequence_at_or_before(7, 7));
|
||||
assert!(sequence_at_or_before(100, 5_200), "further back than the receive window");
|
||||
assert!(!sequence_at_or_before(5_200, 100));
|
||||
assert!(sequence_at_or_before(u32::MAX - 1, 1), "begun before the wrap, ended after it");
|
||||
assert!(!sequence_at_or_before(1, u32::MAX - 1), "after the wrap is later than before it");
|
||||
assert!(sequence_at_or_before(u32::MAX, 0), "the wrap itself");
|
||||
assert!(!sequence_at_or_before(0, u32::MAX));
|
||||
assert!(sequence_at_or_before(0, (1 << 31) - 1), "just inside half the space");
|
||||
assert!(!sequence_at_or_before(0, 1 << 31), "half the space away is not before");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2983,6 +2983,324 @@ fn only_reliable_non_sequenced_writes_are_accepted_on_a_receipt_session() -> Res
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// `bytes` with the MessagePack fixstr `from` replaced by the fixstr `to`, which may be blank.
|
||||
fn swap_fixstr(bytes: &[u8], from: &str, to: &str) -> Vec<u8> {
|
||||
assert!(from.len() < 32 && to.len() < 32);
|
||||
let mut needle = vec![0xa0 | from.len() as u8];
|
||||
needle.extend_from_slice(from.as_bytes());
|
||||
let at = bytes.windows(needle.len()).position(|window| window == needle).expect("marker is in the frame");
|
||||
let mut out = bytes[..at].to_vec();
|
||||
out.push(0xa0 | to.len() as u8);
|
||||
out.extend_from_slice(to.as_bytes());
|
||||
out.extend_from_slice(&bytes[at + needle.len()..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// A `schemaId` or `recordKey` that is present but empty or only whitespace names nothing: the write is refused
|
||||
/// `message_invalid` like a frame that does not read (field "message", the write's id), never reaches the store,
|
||||
/// and the session goes on (write-receipt.md, rule 5; vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`).
|
||||
#[test]
|
||||
fn a_write_whose_schema_id_or_record_key_names_nothing_is_refused_and_never_handed_to_the_store() -> Result<()> {
|
||||
let blanks = ["", " ", "\t \n"];
|
||||
let mut connection_id = 300;
|
||||
for blank in blanks {
|
||||
for blank_field in ["schemaId", "recordKey"] {
|
||||
for operation in ["put", "delete"] {
|
||||
connection_id += 1;
|
||||
let (schema_id, record_key) = match blank_field {
|
||||
"schemaId" => (blank, "note:1"),
|
||||
_ => ("test.raw.v1", blank),
|
||||
};
|
||||
// The encoder refuses a blank field, so encode a marker and swap the blank into the bytes.
|
||||
let bad = if operation == "put" {
|
||||
let mut record = document("zz-key-zz", vec![1, 2, 3]);
|
||||
record.schema_id = "zz-schema-zz".into();
|
||||
encode_cultnet_message_to_vec(
|
||||
&CultNetMessage::DocumentPutRaw { message_id: "bad-1".into(), document: record },
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
)?
|
||||
} else {
|
||||
encode_cultnet_message_to_vec(
|
||||
&CultNetMessage::DocumentDelete {
|
||||
message_id: "bad-1".into(),
|
||||
schema_id: "zz-schema-zz".into(),
|
||||
record_key: "zz-key-zz".into(),
|
||||
},
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
)?
|
||||
};
|
||||
let bad = swap_fixstr(&bad, "zz-schema-zz", schema_id);
|
||||
let bad = swap_fixstr(&bad, "zz-key-zz", record_key);
|
||||
let label = format!("{operation} with {blank_field} {blank:?}");
|
||||
let sink = Sink::default();
|
||||
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
|
||||
let mut peer = client(server.local_addr()?, connection_id)?;
|
||||
connect(&mut server, &mut [&mut peer])?;
|
||||
send_reliable(&mut peer, &receipt_hello())?;
|
||||
peer.send_reliable("schema", bad)?;
|
||||
send_reliable(&mut peer, &put("good-1", "note:2", vec![1]))?;
|
||||
let answers = answers_after(&mut server, &mut peer, 2)?;
|
||||
assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::MessageInvalid), Some("bad-1".into())), "{label}");
|
||||
assert_eq!(error_text_and_field(&answers[0]).1, Some("message".into()), "{label}");
|
||||
assert_eq!(answers[1], admitted("good-1", "note:2"), "{label}: the session goes on");
|
||||
let state = sink.0.lock().unwrap();
|
||||
assert_eq!(state.authorized, vec!["good-1".to_string()], "{label}: the store was never asked about the blank write");
|
||||
assert_eq!(state.receipts.len(), 1, "{label}: only the good write was applied");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The sequenced-channel refusal belongs to the frame that arrived sequenced. A reliable-sequenced packet that
|
||||
/// fills the gap an earlier ordered packet was held behind delivers both frames in one go: the sequenced write is
|
||||
/// refused, the ordered write it released is admitted.
|
||||
#[test]
|
||||
fn a_sequenced_packet_releasing_a_held_ordered_write_refuses_only_its_own() -> Result<()> {
|
||||
let sink = Sink::default();
|
||||
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
|
||||
let mut peer = RawPeer::connect(&mut server, 250)?;
|
||||
peer.send(&receipt_hello())?;
|
||||
server.poll_once()?;
|
||||
let mut packet_for = |peer: &mut RawPeer, id: &str, ordered: bool, sequenced: bool| -> Result<Vec<cultnet_rs::CultNetRudpPacket>> {
|
||||
let payload = encode_cultnet_message_to_vec(&put(id, &format!("note:{id}"), vec![1]), CultNetWireContract::CultNetSchemaV0)?;
|
||||
peer.session.send_many(
|
||||
"schema",
|
||||
payload,
|
||||
cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered, sequenced, now_ms: 3, reliable_expire_after_ms: None },
|
||||
Some(1200),
|
||||
)
|
||||
};
|
||||
let sequenced = packet_for(&mut peer, "s1", false, true)?;
|
||||
let ordered = packet_for(&mut peer, "o1", true, false)?;
|
||||
// The ordered write arrives first, one sequence ahead of the gap, and is held.
|
||||
for packet in &ordered {
|
||||
peer.send_packet(packet)?;
|
||||
}
|
||||
server.poll_once()?;
|
||||
assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "the ordered write waits behind the gap");
|
||||
for packet in &sequenced {
|
||||
peer.send_packet(packet)?;
|
||||
}
|
||||
server.poll_once()?;
|
||||
let mut answers = Vec::new();
|
||||
for packet in peer.drain() {
|
||||
for frame in peer.session.receive(&packet, 4)?.delivered {
|
||||
answers.push(decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?);
|
||||
}
|
||||
}
|
||||
let refusals: Vec<_> = answers.iter().filter(|a| matches!(a, CultNetMessage::ErrorV1 { .. })).collect();
|
||||
assert_eq!(refusals.len(), 1, "only the sequenced write is refused: {answers:?}");
|
||||
assert_eq!(refusal_of(refusals[0]), (Some(CultNetErrorCode::MessageInvalid), Some("s1".into())));
|
||||
assert!(answers.contains(&admitted("o1", "note:o1")), "the released ordered write is admitted: {answers:?}");
|
||||
assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "and applied");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The refusal of a write on a sequenced channel does not depend on the packet being the newest. A reliable-sequenced
|
||||
/// packet that arrives after a newer one on its channel is superseded: the session delivers no frame for it, yet
|
||||
/// the packet was acknowledged and will not be resent, so the server answers it from the packet's own payload.
|
||||
#[test]
|
||||
fn a_superseded_sequenced_write_is_still_refused() -> Result<()> {
|
||||
let sink = Sink::default();
|
||||
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
|
||||
let mut peer = RawPeer::connect(&mut server, 253)?;
|
||||
peer.send(&receipt_hello())?;
|
||||
server.poll_once()?;
|
||||
let packet_for = |peer: &mut RawPeer, id: &str| -> Result<Vec<cultnet_rs::CultNetRudpPacket>> {
|
||||
let payload = encode_cultnet_message_to_vec(&put(id, &format!("note:{id}"), vec![1]), CultNetWireContract::CultNetSchemaV0)?;
|
||||
peer.session.send_many(
|
||||
"schema",
|
||||
payload,
|
||||
cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered: false, sequenced: true, now_ms: 3, reliable_expire_after_ms: None },
|
||||
Some(1200),
|
||||
)
|
||||
};
|
||||
let older = packet_for(&mut peer, "old")?;
|
||||
let newer = packet_for(&mut peer, "new")?;
|
||||
let mut refused_ids = Vec::new();
|
||||
for packets in [&newer, &older] {
|
||||
for packet in packets {
|
||||
peer.send_packet(packet)?;
|
||||
}
|
||||
server.poll_once()?;
|
||||
for packet in peer.drain() {
|
||||
for frame in peer.session.receive(&packet, 4)?.delivered {
|
||||
let answer = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
|
||||
if matches!(answer, CultNetMessage::ErrorV1 { .. }) {
|
||||
refused_ids.push(refusal_of(&answer).1.expect("a refusal names its write"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(refused_ids, vec!["new".to_string(), "old".to_string()], "the superseded write is answered too");
|
||||
assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "and nothing was applied");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One answer per write (write-receipt.md, rule 1): a sequenced packet the server already answered is answered
|
||||
/// once, however often the peer sends it again, and a packet too far ahead to be received draws nothing until it
|
||||
/// is received (the peer resends it, and the answer comes then).
|
||||
#[test]
|
||||
fn a_repeated_or_far_ahead_sequenced_write_draws_one_answer_not_one_per_send() -> Result<()> {
|
||||
let sink = Sink::default();
|
||||
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
|
||||
let mut peer = RawPeer::connect(&mut server, 256)?;
|
||||
peer.send(&receipt_hello())?;
|
||||
server.poll_once()?;
|
||||
let packet_for = |peer: &mut RawPeer, id: &str| -> Result<Vec<cultnet_rs::CultNetRudpPacket>> {
|
||||
let payload = encode_cultnet_message_to_vec(&put(id, &format!("note:{id}"), vec![1]), CultNetWireContract::CultNetSchemaV0)?;
|
||||
peer.session.send_many(
|
||||
"schema",
|
||||
payload,
|
||||
cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered: false, sequenced: true, now_ms: 3, reliable_expire_after_ms: None },
|
||||
Some(1200),
|
||||
)
|
||||
};
|
||||
let refusals_of = |peer: &mut RawPeer| -> Result<Vec<String>> {
|
||||
let mut ids = Vec::new();
|
||||
for packet in peer.drain() {
|
||||
for frame in peer.session.receive(&packet, 4)?.delivered {
|
||||
let answer = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
|
||||
if matches!(answer, CultNetMessage::ErrorV1 { .. }) {
|
||||
ids.push(refusal_of(&answer).1.expect("a refusal names its write"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
};
|
||||
// The same packet, sent three times (a resend whose acknowledgement was lost), is refused once.
|
||||
let once = packet_for(&mut peer, "dup")?;
|
||||
let mut refused = Vec::new();
|
||||
for _ in 0..3 {
|
||||
for packet in &once {
|
||||
peer.send_packet(packet)?;
|
||||
}
|
||||
server.poll_once()?;
|
||||
refused.extend(refusals_of(&mut peer)?);
|
||||
}
|
||||
assert_eq!(refused, vec!["dup".to_string()], "a repeat of an answered write is not answered again");
|
||||
|
||||
// A packet far past the receive window is not received: no answer yet, and none per resend.
|
||||
let ahead = packet_for(&mut peer, "ahead")?;
|
||||
let mut far = ahead[0].clone();
|
||||
far.sequence = once[0].sequence + 5_000;
|
||||
let mut refused = Vec::new();
|
||||
for _ in 0..3 {
|
||||
peer.send_packet(&far)?;
|
||||
server.poll_once()?;
|
||||
refused.extend(refusals_of(&mut peer)?);
|
||||
}
|
||||
assert_eq!(refused, Vec::<String>::new(), "an unreceived packet draws no answer");
|
||||
assert_eq!(sink.0.lock().unwrap().receipts.len(), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A superseded sequenced packet is answered even when its arrival fills a gap and releases another write: the
|
||||
/// frames that arrive with it do not stand in for its own.
|
||||
#[test]
|
||||
fn a_superseded_sequenced_write_is_refused_while_it_releases_a_held_ordered_write() -> Result<()> {
|
||||
let sink = Sink::default();
|
||||
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
|
||||
let mut peer = RawPeer::connect(&mut server, 255)?;
|
||||
peer.send(&receipt_hello())?;
|
||||
server.poll_once()?;
|
||||
let packet_for = |peer: &mut RawPeer, id: &str, ordered: bool, sequenced: bool| -> Result<Vec<cultnet_rs::CultNetRudpPacket>> {
|
||||
let payload = encode_cultnet_message_to_vec(&put(id, &format!("note:{id}"), vec![1]), CultNetWireContract::CultNetSchemaV0)?;
|
||||
peer.session.send_many(
|
||||
"schema",
|
||||
payload,
|
||||
cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered, sequenced, now_ms: 3, reliable_expire_after_ms: None },
|
||||
Some(1200),
|
||||
)
|
||||
};
|
||||
let older = packet_for(&mut peer, "old", false, true)?;
|
||||
let ordered = packet_for(&mut peer, "o1", true, false)?;
|
||||
let newer = packet_for(&mut peer, "new", false, true)?;
|
||||
let mut answers = Vec::new();
|
||||
for packets in [&newer, &ordered, &older] {
|
||||
for packet in packets {
|
||||
peer.send_packet(packet)?;
|
||||
}
|
||||
server.poll_once()?;
|
||||
for packet in peer.drain() {
|
||||
for frame in peer.session.receive(&packet, 4)?.delivered {
|
||||
answers.push(decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut refused: Vec<String> = answers
|
||||
.iter()
|
||||
.filter(|a| matches!(a, CultNetMessage::ErrorV1 { .. }))
|
||||
.map(|a| refusal_of(a).1.expect("a refusal names its write"))
|
||||
.collect();
|
||||
refused.sort();
|
||||
assert_eq!(refused, vec!["new".to_string(), "old".to_string()], "{answers:?}");
|
||||
assert!(answers.contains(&admitted("o1", "note:o1")), "the released ordered write is admitted: {answers:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The refusal reaches a sequenced write however far apart its fragments' sequences sit: other traffic can take the
|
||||
/// sequences between the first fragment and the last, further back than the duplicate test's receive window.
|
||||
#[test]
|
||||
fn a_sequenced_write_whose_fragments_span_more_than_the_receive_window_is_refused() -> Result<()> {
|
||||
let sink = Sink::default();
|
||||
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
|
||||
let mut peer = RawPeer::connect(&mut server, 254)?;
|
||||
peer.send(&receipt_hello())?;
|
||||
server.poll_once()?;
|
||||
let payload = encode_cultnet_message_to_vec(&put("far", "note:far", vec![1; 64]), CultNetWireContract::CultNetSchemaV0)?;
|
||||
let mut packets = peer.session.send_many(
|
||||
"schema",
|
||||
payload,
|
||||
cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered: false, sequenced: true, now_ms: 3, reliable_expire_after_ms: None },
|
||||
Some(32),
|
||||
)?;
|
||||
let last = packets.pop().expect("the write is fragmented");
|
||||
assert!(!packets.is_empty());
|
||||
let mut next = last.sequence;
|
||||
let mut refused_ids = Vec::new();
|
||||
for packet in &packets {
|
||||
peer.send_packet(packet)?;
|
||||
server.poll_once()?;
|
||||
}
|
||||
// 5,000 reliable packets on another channel, in order, take the sequences between the fragments.
|
||||
for count in 0..5_000 {
|
||||
let mut filler = packets[0].clone();
|
||||
filler.channel_id = "filler".into();
|
||||
filler.sequenced = false;
|
||||
filler.fragment_id = 0;
|
||||
filler.fragment_index = 0;
|
||||
filler.fragment_count = 0;
|
||||
filler.payload = vec![0];
|
||||
filler.sequence = next;
|
||||
next += 1;
|
||||
peer.send_packet(&filler)?;
|
||||
server.poll_once()?;
|
||||
if count % 100 == 99 {
|
||||
// Read what the server sent, so the socket buffer never holds back the refusal.
|
||||
for packet in peer.drain() {
|
||||
peer.session.receive(&packet, 4)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut last = last;
|
||||
last.sequence = next;
|
||||
peer.send_packet(&last)?;
|
||||
server.poll_once()?;
|
||||
for packet in peer.drain() {
|
||||
for frame in peer.session.receive(&packet, 4)?.delivered {
|
||||
let answer = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
|
||||
if matches!(answer, CultNetMessage::ErrorV1 { .. }) {
|
||||
refused_ids.push(refusal_of(&answer).1.expect("a refusal names its write"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(refused_ids, vec!["far".to_string()]);
|
||||
assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "and nothing was applied");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A refused write is traffic: the peer that sends it is not idle, so its session outlives the idle timeout measured
|
||||
/// from the refusal and not from the packet before it.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@
|
|||
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||
"prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false",
|
||||
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --expose-gc --test dist-test/test/*.test.js"
|
||||
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --expose-gc --test dist-test/test/*.test.js",
|
||||
"test:interop": "npm --prefix ../.. run build --workspace @gamecult/cultcache-ts --workspace @gamecult/cultnet-ts && node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test --test-force-exit dist-test/test/interop/write-receipt-interop.test.js"
|
||||
},
|
||||
"keywords": [
|
||||
"cultmesh",
|
||||
|
|
|
|||
|
|
@ -2986,8 +2986,9 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async ()
|
|||
maxFragmentBytes: 1024,
|
||||
maxPendingReliablePackets: 16,
|
||||
connectTimeoutMs: 1_000,
|
||||
flushTimeoutMs: 25,
|
||||
resultWindowMs: 100,
|
||||
// The flush is bounded by the result window, so the window must outlast the transport ack on a loaded
|
||||
// runner: a short one turns a slow ack into local_send_failed, which is not the path this test checks.
|
||||
resultWindowMs: 750,
|
||||
sourceRole: "test-provider",
|
||||
},
|
||||
),
|
||||
|
|
|
|||
434
packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts
Normal file
434
packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
// The TypeScript write-receipt client (cultnet-ts WriteTracker and decoder, cultmesh-ts one-shot writer) against the two real
|
||||
// receipt servers over loopback, as child processes on ephemeral ports:
|
||||
//
|
||||
// rust packages/cultmesh-rs/examples/receipt_interop_server.rs, the RUDP document server, reached over RUDP.
|
||||
// csharp tests/GameCult.Networking.InteropPeer `receipt-serve`, CultNetDatabaseServer on Server (LiteNetLib). TypeScript has no
|
||||
// LiteNetLib client, so a second child, `receipt-relay`, carries bytes between the test and the server (4-byte
|
||||
// big-endian length, then the CultNet frame: CultNetPeer's own stream framing). It decodes and judges nothing.
|
||||
//
|
||||
// Every scenario runs against both targets; its outcome is read from the WriteTracker (admitted, refused, unconfirmed), and the
|
||||
// server's own stdout facts say what was committed. Contract: contracts/cultnet/interop/write-receipt.md and its vectors.
|
||||
//
|
||||
// Needs cargo and dotnet; run it with `npm run test:interop` in packages/cultmesh-ts (CARGO and DOTNET override the commands;
|
||||
// RECEIPT_INTEROP_PREBUILT=1 skips the two builds).
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile, spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { Duplex } from "node:stream";
|
||||
import { after, before, beforeEach, describe, test } from "node:test";
|
||||
import { promisify } from "node:util";
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
import { z } from "zod";
|
||||
import { defineDocumentType } from "@gamecult/cultcache-ts";
|
||||
import {
|
||||
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
|
||||
CultNetPeer,
|
||||
WriteTracker,
|
||||
defineCultNetDocumentBinding,
|
||||
helloAdvertisesWriteReceipts,
|
||||
resultWindowMs,
|
||||
type CultNetErrorV1Message,
|
||||
type CultNetHelloMessage,
|
||||
type CultNetMessage,
|
||||
type WriteSettlement,
|
||||
} from "@gamecult/cultnet-ts";
|
||||
import { CultMesh, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../../src/index";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
// dist-test/test/interop -> the repository root
|
||||
const repoRoot = resolve(__dirname, "..", "..", "..", "..", "..");
|
||||
const cargo = process.env.CARGO ?? "cargo";
|
||||
const dotnet = process.env.DOTNET ?? "dotnet";
|
||||
const rustManifestDir = join(repoRoot, "packages", "cultmesh-rs");
|
||||
const csharpProject = join(repoRoot, "tests", "GameCult.Networking.InteropPeer", "GameCult.Networking.InteropPeer.csproj");
|
||||
const csharpDll = join(repoRoot, "bin", "GameCult.Networking.InteropPeer", "Debug", "net10.0", "GameCult.Networking.InteropPeer.dll");
|
||||
// Set when the server binaries were built beforehand (the builds themselves cannot start under a tight address-space cap).
|
||||
const prebuilt = process.env.RECEIPT_INTEROP_PREBUILT === "1";
|
||||
const vectors = JSON.parse(readFileSync(join(repoRoot, "contracts", "cultnet", "interop", "write-receipt-vectors.json"), "utf8"));
|
||||
|
||||
const SCHEMA_ID = "receipt.interop_note.v1";
|
||||
const UNREADABLE = "message_invalid: the message could not be read";
|
||||
const sleep = (ms: number) => new Promise<void>((done) => setTimeout(done, ms));
|
||||
|
||||
async function waitFor(predicate: () => boolean, what: string, ms = 5_000): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - startedAt > ms) throw new Error(`timed out waiting for ${what}`);
|
||||
await sleep(5);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the servers -------------------------------------------------------------------------------------------------------
|
||||
|
||||
interface Fact { event: string; recordKey?: string; messageId?: string; [key: string]: unknown }
|
||||
|
||||
/** A running server child and the JSON facts it prints on stdout. */
|
||||
class ServerProcess {
|
||||
readonly facts: Fact[] = [];
|
||||
stderr = "";
|
||||
port = 0;
|
||||
constructor(readonly child: ChildProcess) {
|
||||
let pending = "";
|
||||
child.stdout!.on("data", (chunk: Buffer) => {
|
||||
pending += chunk.toString("utf8");
|
||||
for (let at = pending.indexOf("\n"); at >= 0; at = pending.indexOf("\n")) {
|
||||
const line = pending.slice(0, at).trim();
|
||||
pending = pending.slice(at + 1);
|
||||
if (line) this.facts.push(JSON.parse(line) as Fact);
|
||||
}
|
||||
});
|
||||
child.stderr!.on("data", (chunk: Buffer) => { this.stderr += chunk.toString("utf8"); });
|
||||
}
|
||||
/** How many times `event` was printed for `recordKey`. */
|
||||
count(event: string, recordKey: string): number {
|
||||
return this.facts.filter((fact) => fact.event === event && fact.recordKey === recordKey).length;
|
||||
}
|
||||
async stop(): Promise<void> {
|
||||
this.child.stdin!.end();
|
||||
await new Promise<void>((done) => {
|
||||
const killer = setTimeout(() => this.child.kill("SIGKILL"), 5_000);
|
||||
this.child.once("exit", () => { clearTimeout(killer); done(); });
|
||||
if (this.child.exitCode !== null) done();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function launch(command: string, args: string[]): Promise<ServerProcess> {
|
||||
const server = new ServerProcess(spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }));
|
||||
await waitFor(() => server.facts.some((fact) => fact.event === "listening") || server.child.exitCode !== null, "the server to listen", 60_000);
|
||||
const listening = server.facts.find((fact) => fact.event === "listening");
|
||||
if (!listening) throw new Error(`the server exited before listening:\n${server.stderr}`);
|
||||
server.port = listening.port as number;
|
||||
return server;
|
||||
}
|
||||
|
||||
/** One connection to a server: the peer that speaks CultNet, and how to close it. */
|
||||
interface Connection { peer: CultNetPeer; close(): void }
|
||||
|
||||
interface Target {
|
||||
name: string;
|
||||
/** Shard fields the server's database requires on a write, if any. */
|
||||
shard?: { shardId: string; shardEpoch: number };
|
||||
/** A document payload this target's store reads. */
|
||||
payload(text: string): Uint8Array;
|
||||
start(): Promise<ServerProcess>;
|
||||
connect(server: ServerProcess): Promise<Connection>;
|
||||
}
|
||||
|
||||
let nextConnectionId = 0x20206000;
|
||||
|
||||
const rust: Target = {
|
||||
name: "rust",
|
||||
payload: (text) => encode({ text }),
|
||||
async start() {
|
||||
if (!prebuilt) await execFileAsync(cargo, ["build", "--example", "receipt_interop_server", "--message-format", "short"], { cwd: rustManifestDir });
|
||||
const metadata = spawnSync(cargo, ["metadata", "--format-version", "1", "--no-deps"], { cwd: rustManifestDir, encoding: "utf8" });
|
||||
if (metadata.status !== 0) throw new Error(`cargo metadata failed: ${metadata.stderr}`);
|
||||
const exe = join(JSON.parse(metadata.stdout).target_directory, "debug", "examples", `receipt_interop_server${process.platform === "win32" ? ".exe" : ""}`);
|
||||
return launch(exe, []);
|
||||
},
|
||||
async connect(server) {
|
||||
const peer = await CultMesh.createRudpPeer("receipt-interop", nextConnectionId++, { host: "127.0.0.1", port: server.port, uri: `rudp://127.0.0.1:${server.port}` });
|
||||
return { peer, close: () => peer.close() };
|
||||
},
|
||||
};
|
||||
|
||||
// The C# server admits 30 connection requests per IP per minute (Server.MaxConnectionAttemptsPerMinute) and answers the rest with
|
||||
// no hello. Every scenario wants a fresh session, so before each C# test the harness waits until the last minute holds at most
|
||||
// CONNECTIONS_PER_MINUTE connections, leaving room for the two a test may open. The server's limit stays as it is.
|
||||
const CONNECTIONS_PER_MINUTE = 24;
|
||||
const connectedAt: number[] = [];
|
||||
async function awaitConnectionBudget(): Promise<void> {
|
||||
for (;;) {
|
||||
const now = Date.now();
|
||||
while (connectedAt.length > 0 && now - connectedAt[0] >= 61_000) connectedAt.shift();
|
||||
if (connectedAt.length <= CONNECTIONS_PER_MINUTE) return;
|
||||
await sleep(connectedAt[0] + 61_000 - now);
|
||||
}
|
||||
}
|
||||
|
||||
const csharp: Target = {
|
||||
name: "csharp",
|
||||
shard: { shardId: "receipt-interop-shard", shardEpoch: 1 },
|
||||
payload: (text) => encode([text]),
|
||||
async start() {
|
||||
if (!prebuilt) await execFileAsync(dotnet, ["build", csharpProject, "-nologo", "-v", "q"], { cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 });
|
||||
return launch(dotnet, [csharpDll, "receipt-serve"]);
|
||||
},
|
||||
async connect(server) {
|
||||
connectedAt.push(Date.now());
|
||||
const relay = spawn(dotnet, [csharpDll, "receipt-relay", "--port", String(server.port)], { stdio: ["pipe", "pipe", "ignore"] });
|
||||
const peer = new CultNetPeer(Duplex.from({ readable: relay.stdout!, writable: relay.stdin! }), { wireContract: "cultnet.schema.v0" });
|
||||
return { peer, close: () => { relay.stdin!.end(); relay.kill(); } };
|
||||
},
|
||||
};
|
||||
|
||||
// ---- a client session: one connection, the frames it saw, one WriteTracker per write -----------------------------------------
|
||||
|
||||
class Session {
|
||||
readonly frames: CultNetMessage[] = [];
|
||||
readonly invalid: Error[] = [];
|
||||
private readonly trackers = new Set<WriteTracker>();
|
||||
private serverReceipts = false;
|
||||
private constructor(readonly target: Target, private readonly connection: Connection) {
|
||||
connection.peer.on("message", (message: CultNetMessage) => {
|
||||
if (message.schemaVersion === "cultnet.hello.v0") this.serverReceipts = helloAdvertisesWriteReceipts(message);
|
||||
this.frames.push(message);
|
||||
for (const tracker of this.trackers) tracker.frame(message, Date.now());
|
||||
});
|
||||
connection.peer.on("invalidMessage", (error: Error) => this.invalid.push(error));
|
||||
connection.peer.on("close", () => { for (const tracker of this.trackers) tracker.sessionEnd(Date.now()); });
|
||||
}
|
||||
|
||||
static async open(target: Target, server: ServerProcess): Promise<Session> {
|
||||
return new Session(target, await target.connect(server));
|
||||
}
|
||||
|
||||
/** Declares the receipt capability and waits for the server's own hello. */
|
||||
async hello(versions: readonly string[] = CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS): Promise<CultNetHelloMessage> {
|
||||
this.connection.peer.sendHello({ schemaVersion: "cultnet.hello.v0", runtimeId: "receipt-interop", runtimeKind: "cultmesh-ts", supportedMessageVersions: [...versions] });
|
||||
await waitFor(() => this.frames.some((frame) => frame.schemaVersion === "cultnet.hello.v0"), "the server's hello");
|
||||
return this.frames.find((frame) => frame.schemaVersion === "cultnet.hello.v0") as CultNetHelloMessage;
|
||||
}
|
||||
|
||||
put(messageId: string, recordKey: string, text: string, document: Record<string, unknown> = {}): CultNetMessage {
|
||||
return this.shard({
|
||||
schemaVersion: "cultnet.document_put_raw.v0",
|
||||
messageId,
|
||||
document: {
|
||||
schemaId: SCHEMA_ID, recordKey, storedAt: new Date().toISOString(), payloadEncoding: "messagepack",
|
||||
payload: this.target.payload(text), ...document,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
delete(messageId: string, recordKey: string): CultNetMessage {
|
||||
return this.shard({ schemaVersion: "cultnet.document_delete.v0", messageId, schemaId: SCHEMA_ID, recordKey });
|
||||
}
|
||||
|
||||
private shard(message: unknown): CultNetMessage {
|
||||
return { ...(message as object), ...this.target.shard } as CultNetMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one write and settles it with its own tracker, windowed by what the server advertised (5000 ms against a receipt
|
||||
* server, 500 ms otherwise). Only the tracker decides the outcome.
|
||||
*/
|
||||
async write(message: CultNetMessage): Promise<WriteSettlement> {
|
||||
const messageId = (message as { messageId: string }).messageId;
|
||||
const tracker = new WriteTracker({ windowMs: resultWindowMs(this.serverReceipts) });
|
||||
this.trackers.add(tracker);
|
||||
tracker.send(messageId, Date.now());
|
||||
this.connection.peer.send(message);
|
||||
for (;;) {
|
||||
tracker.tick(Date.now());
|
||||
const state = tracker.state(messageId)!;
|
||||
if (state.outcome !== "pending") return state;
|
||||
await sleep(Math.min(20, tracker.nextDeadlineMs(Date.now()) ?? 20));
|
||||
}
|
||||
}
|
||||
|
||||
/** Frames of `schemaVersion` that name `messageId`. */
|
||||
answers(messageId: string, schemaVersion: string): CultNetMessage[] {
|
||||
return this.frames.filter((frame) => frame.schemaVersion === schemaVersion && (frame as { messageId?: string }).messageId === messageId);
|
||||
}
|
||||
|
||||
close(): void { this.connection.close(); }
|
||||
}
|
||||
|
||||
function refusal(settlement: WriteSettlement): CultNetErrorV1Message {
|
||||
assert.equal(settlement.outcome, "refused", JSON.stringify(settlement));
|
||||
return (settlement as Extract<WriteSettlement, { outcome: "refused" }>).response as CultNetErrorV1Message;
|
||||
}
|
||||
|
||||
// ---- the scenarios, run against each server -----------------------------------------------------------------------------------
|
||||
|
||||
for (const target of [rust, csharp]) {
|
||||
describe(`write receipt, TypeScript client against the real ${target.name} server`, () => {
|
||||
let server: ServerProcess;
|
||||
let session: Session | undefined;
|
||||
before(async () => { server = await target.start(); }, { timeout: 180_000 });
|
||||
after(async () => { session?.close(); await server?.stop(); });
|
||||
beforeEach(async () => { if (target === csharp) await awaitConnectionBudget(); }, { timeout: 90_000 });
|
||||
|
||||
/** A fresh receipt session (hello sent, the server's hello read) for a scenario. */
|
||||
async function receiptSession(): Promise<Session> {
|
||||
session?.close();
|
||||
session = await Session.open(target, server);
|
||||
const hello = await session.hello();
|
||||
assert.ok(helloAdvertisesWriteReceipts(hello), "the server's hello lists both receipt frames");
|
||||
return session;
|
||||
}
|
||||
|
||||
test("hello listing both frames: the server answers with its own hello listing both, then a put is receipted after it", { timeout: 30_000 }, async () => {
|
||||
const s = await receiptSession();
|
||||
assert.equal(s.frames[0].schemaVersion, "cultnet.hello.v0");
|
||||
const settlement = await s.write(s.put("hello-1", "hello:a", "one"));
|
||||
assert.deepEqual(settlement, { outcome: "admitted" });
|
||||
assert.deepEqual(s.frames.map((frame) => frame.schemaVersion), ["cultnet.hello.v0", "cultnet.write_admitted.v0"]);
|
||||
});
|
||||
|
||||
test("a hello listing only one of the two frames does not opt in: no receipt, and the put is applied", { timeout: 30_000 }, async () => {
|
||||
for (const listed of CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS) {
|
||||
session?.close();
|
||||
session = await Session.open(target, server);
|
||||
session.hello([listed]).catch(() => undefined); // a server may stay silent to a hello that does not opt in
|
||||
await sleep(300);
|
||||
const settlement = await session.write(session.put(`half-${listed}`, `half:${listed}`, "one"));
|
||||
assert.deepEqual(settlement, { outcome: "unconfirmed", reason: "window_elapsed" }, `listing only ${listed}`);
|
||||
assert.ok(!session.frames.some((frame) => frame.schemaVersion === "cultnet.write_admitted.v0" || frame.schemaVersion === "cultnet.error.v1"));
|
||||
await waitFor(() => server.count("applied", `half:${listed}`) === 1, "the put to be applied");
|
||||
}
|
||||
});
|
||||
|
||||
test("put admitted: the receipt echoes the write and the store has it once", { timeout: 30_000 }, async () => {
|
||||
const s = await receiptSession();
|
||||
const settlement = await s.write(s.put("put-1", "put:a", "one"));
|
||||
assert.deepEqual(settlement, { outcome: "admitted" });
|
||||
assert.deepEqual(s.answers("put-1", "cultnet.write_admitted.v0"), [
|
||||
{ schemaVersion: "cultnet.write_admitted.v0", messageId: "put-1", operation: "put", schemaId: SCHEMA_ID, recordKey: "put:a" },
|
||||
]);
|
||||
await waitFor(() => server.count("applied", "put:a") === 1, "the put to be applied");
|
||||
});
|
||||
|
||||
// The Rust RUDP document server has no delete: its sink only accepts documents, and every delete on a receipt session is
|
||||
// answered with a refusal (rudp_document_server.rs, deletes_on_a_receipt_session_are_refused_one_by_one). The spec
|
||||
// (Server rules 1 to 3) has a delete admitted and receipted like a put.
|
||||
test("delete admitted: the receipt says operation delete and the store drops the record", {
|
||||
timeout: 30_000,
|
||||
todo: target.name === "rust" ? "the Rust RUDP document server refuses every delete; there is no delete port in CultMeshRudpRawDocumentSink" : false,
|
||||
}, async () => {
|
||||
const s = await receiptSession();
|
||||
assert.deepEqual(await s.write(s.put("del-put", "del:a", "one")), { outcome: "admitted" });
|
||||
assert.deepEqual(await s.write(s.delete("del-1", "del:a")), { outcome: "admitted" });
|
||||
assert.deepEqual(s.answers("del-1", "cultnet.write_admitted.v0"), [
|
||||
{ schemaVersion: "cultnet.write_admitted.v0", messageId: "del-1", operation: "delete", schemaId: SCHEMA_ID, recordKey: "del:a" },
|
||||
]);
|
||||
await waitFor(() => server.count("removed", "del:a") === 1, "the delete to be applied");
|
||||
});
|
||||
|
||||
test("refused write: an error.v1 carrying the write's id, and nothing is applied", { timeout: 30_000 }, async () => {
|
||||
const s = await receiptSession();
|
||||
const response = refusal(await s.write(s.put("deny-1", "refuse:a", "one")));
|
||||
assert.equal(response.schemaVersion, "cultnet.error.v1");
|
||||
assert.equal(response.messageId, "deny-1");
|
||||
assert.equal(response.code, "write_not_authorized");
|
||||
// The session is kept: the next write on it is admitted.
|
||||
assert.deepEqual(await s.write(s.put("deny-2", "deny:b", "one")), { outcome: "admitted" });
|
||||
assert.equal(server.count("applied", "refuse:a"), 0);
|
||||
});
|
||||
|
||||
test("resend of the same id and content on the same session: re-receipted, applied once", { timeout: 30_000 }, async () => {
|
||||
const s = await receiptSession();
|
||||
assert.deepEqual(await s.write(s.put("resend-1", "resend:a", "one")), { outcome: "admitted" });
|
||||
assert.deepEqual(await s.write(s.put("resend-1", "resend:a", "one")), { outcome: "admitted" });
|
||||
const receipts = s.answers("resend-1", "cultnet.write_admitted.v0");
|
||||
assert.equal(receipts.length, 2);
|
||||
assert.deepEqual(receipts[0], receipts[1]);
|
||||
await sleep(200); // a second application would be printed by now
|
||||
assert.equal(server.count("applied", "resend:a"), 1);
|
||||
});
|
||||
|
||||
test("same id with different content: message_invalid naming the id, never a receipt, the first write untouched", { timeout: 30_000 }, async () => {
|
||||
const s = await receiptSession();
|
||||
assert.deepEqual(await s.write(s.put("clash-1", "clash:a", "one")), { outcome: "admitted" });
|
||||
const response = refusal(await s.write(s.put("clash-1", "clash:a", "other")));
|
||||
assert.equal(response.code, "message_invalid");
|
||||
assert.equal(response.messageId, "clash-1");
|
||||
assert.equal(response.details?.field, "messageId");
|
||||
assert.equal(s.answers("clash-1", "cultnet.write_admitted.v0").length, 1, "no receipt for the changed write");
|
||||
await sleep(200);
|
||||
assert.equal(server.count("applied", "clash:a"), 1);
|
||||
});
|
||||
|
||||
// Each case of the vectors' blankFields section: a document that names nothing is refused as an unreadable frame, with the
|
||||
// write's id and the fixed text, and never applied; a document that is merely missing optional fields is admitted.
|
||||
for (const blank of vectors.blankFields.cases as { name: string; set: Record<string, unknown>; outcome: "admitted" | "refused" }[]) {
|
||||
test(`blank-field vector ${blank.name}: ${blank.outcome}`, { timeout: 30_000 }, async () => {
|
||||
const s = await receiptSession();
|
||||
const { payloadHex, ...fields } = blank.set;
|
||||
// A case that sets recordKey replaces the harness key, so the store is read under the key the server actually saw.
|
||||
const recordKey = typeof fields.recordKey === "string" ? fields.recordKey : `blank:${blank.name}`;
|
||||
const message = s.put(`blank-${blank.name}`, recordKey, "one", fields);
|
||||
const sent = (message as unknown as { document: Record<string, unknown> }).document;
|
||||
for (const [key, value] of Object.entries(fields)) if (value === null) delete sent[key]; // nil means absent on the wire here
|
||||
if (payloadHex === "") sent.payload = new Uint8Array();
|
||||
const settlement = await s.write(message);
|
||||
if (blank.outcome === "admitted") {
|
||||
assert.deepEqual(settlement, { outcome: "admitted" });
|
||||
await waitFor(() => server.count("applied", recordKey) === 1, "the put to be applied");
|
||||
return;
|
||||
}
|
||||
const response = refusal(settlement);
|
||||
assert.equal(response.code, "message_invalid");
|
||||
assert.equal(response.error, UNREADABLE);
|
||||
assert.equal(response.details?.field, "message");
|
||||
assert.equal(response.messageId, `blank-${blank.name}`);
|
||||
await sleep(200);
|
||||
assert.equal(server.count("applied", recordKey), 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("a delete that names nothing is refused as unreadable with its id", { timeout: 30_000 }, async () => {
|
||||
const s = await receiptSession();
|
||||
const message = s.delete("blank-del", "blank:del");
|
||||
(message as unknown as { schemaId: string }).schemaId = "";
|
||||
const response = refusal(await s.write(message));
|
||||
assert.equal(response.code, "message_invalid");
|
||||
assert.equal(response.error, UNREADABLE);
|
||||
assert.equal(response.details?.field, "message");
|
||||
assert.equal(response.messageId, "blank-del");
|
||||
});
|
||||
|
||||
test("legacy client (no hello): no receipt frames, the write is applied, and its refusal is the old error.v0", { timeout: 30_000 }, async () => {
|
||||
session?.close();
|
||||
session = await Session.open(target, server);
|
||||
assert.deepEqual(await session.write(session.put("legacy-1", "legacy:a", "one")), { outcome: "unconfirmed", reason: "window_elapsed" });
|
||||
await waitFor(() => server.count("applied", "legacy:a") === 1, "the put to be applied");
|
||||
const refused = (await session.write(session.put("legacy-2", "refuse:legacy", "one"))) as Extract<WriteSettlement, { outcome: "refused" }>;
|
||||
assert.equal(refused.outcome, "refused");
|
||||
assert.equal(refused.response.schemaVersion, "cultnet.error.v0");
|
||||
assert.deepEqual(session.frames.map((frame) => frame.schemaVersion), ["cultnet.error.v0"]);
|
||||
assert.equal(session.invalid.length, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the one-shot writer, which is the real cultmesh-ts client path, against the Rust RUDP server ----------------------------
|
||||
// (The C# server speaks LiteNetLib, not RUDP: no RUDP client path reaches it.)
|
||||
|
||||
describe("write receipt, cultmesh-ts publishRudpDocumentOnce against the real rust server", () => {
|
||||
const note = defineDocumentType({ type: "receipt.interop_note", schemaId: SCHEMA_ID, schema: z.object({ text: z.string() }), name: "text" });
|
||||
const binding = defineCultNetDocumentBinding({ definition: note });
|
||||
let server: ServerProcess;
|
||||
before(async () => { server = await rust.start(); }, { timeout: 180_000 });
|
||||
after(async () => { await server?.stop(); });
|
||||
const endpoint = () => ({ host: "127.0.0.1", port: server.port, uri: `rudp://127.0.0.1:${server.port}` });
|
||||
|
||||
test("writeReceipts: the put resolves admitted on the receipt, after the server applied it", { timeout: 30_000 }, async () => {
|
||||
const outcome = await CultMesh.publishRudpDocumentOnce("receipt-interop", nextConnectionId++, endpoint(), binding, "once:a", { text: "one" }, { writeReceipts: true, messageId: "once-1" });
|
||||
assert.equal(outcome, "admitted");
|
||||
await waitFor(() => server.count("applied", "once:a") === 1, "the put to be applied");
|
||||
});
|
||||
|
||||
test("writeReceipts: a refused put rejects with the server's error.v1 naming the put", { timeout: 30_000 }, async () => {
|
||||
await assert.rejects(
|
||||
CultMesh.publishRudpDocumentOnce("receipt-interop", nextConnectionId++, endpoint(), binding, "refuse:once", { text: "one" }, { writeReceipts: true, messageId: "once-2" }),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof CultMeshRudpWriteRefusedError);
|
||||
assert.equal(error.response.schemaVersion, "cultnet.error.v1");
|
||||
assert.equal((error.response as CultNetErrorV1Message).messageId, "once-2");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("without writeReceipts the server stays silent: the put is unconfirmed, not admitted, though the server applied it", { timeout: 30_000 }, async () => {
|
||||
await assert.rejects(
|
||||
CultMesh.publishRudpDocumentOnce("receipt-interop", nextConnectionId++, endpoint(), binding, "once:legacy", { text: "one" }, { messageId: "once-3" }),
|
||||
(error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed",
|
||||
);
|
||||
await waitFor(() => server.count("applied", "once:legacy") === 1, "the put to be applied");
|
||||
});
|
||||
});
|
||||
|
|
@ -287,18 +287,21 @@ test("a replaced RUDP generation drops operations queued behind an active handle
|
|||
runtimeId: identity.serviceInstanceId,
|
||||
connectionId,
|
||||
sessionToken: "signed-provider-token",
|
||||
operationTimeoutMs: 100,
|
||||
operationTimeoutMs: 1, // expires long before the test ends: the settle-by-timeout path is always exercised
|
||||
socketFactory: () => socket,
|
||||
}).connect(identity, new AbortController().signal);
|
||||
try {
|
||||
// The handler is held open and the closed generation never answers, so these two settle only by the
|
||||
// client's operation timeout or by connection.close(). Observe them from the start: whichever fires
|
||||
// first is a legitimate end of the call, and this test asserts on server-side handler calls, not on it.
|
||||
const first = connection.register(
|
||||
{ identity, requestedLeaseDurationMs: 10_000 },
|
||||
new AbortController().signal,
|
||||
);
|
||||
).then(() => undefined, () => undefined);
|
||||
const second = connection.register(
|
||||
{ identity, requestedLeaseDurationMs: 10_000 },
|
||||
new AbortController().signal,
|
||||
);
|
||||
).then(() => undefined, () => undefined);
|
||||
await waitFor(() => handlerCalls === 1, "first queued provider operation");
|
||||
|
||||
const replacement = new CultNetRudpSession({ connectionId });
|
||||
|
|
@ -318,7 +321,7 @@ test("a replaced RUDP generation drops operations queued behind an active handle
|
|||
assert.equal(handlerCalls, 1, "transport-queued work from the closed generation must be dropped");
|
||||
|
||||
connection.close();
|
||||
await Promise.allSettled([first, second]);
|
||||
await Promise.all([first, second]);
|
||||
} finally {
|
||||
connection.close();
|
||||
server.close();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
## Unreleased (breaking; version 0.x, so the next minor)
|
||||
|
||||
- `CultNetDatabaseChange` carries `commit_id` and `commit_count` (`commitId`/`commitCount`; None outside a multi-record commit, as C# writes nil): `from_wire` refuses an empty or non-string id and a count that is not an integer of at least 2, `to_wire` writes them when set, and the published `database_change_raw` schema lists both. Python applies no batch. The `simulation_fact` document's `shard`, `frame`, `subject` and `claim_kind` aliases are `roles` (many facts share each); the catalog bytes do not move.
|
||||
- Decodes the write receipt frames of `contracts/cultnet/interop/write-receipt.md`: `parse_message` validates `cultnet.write_admitted.v0` and `cultnet.error.v1` against their closed schemas (unknown keys, empty or non-string ids, an unknown operation or code are refused; `error.v1` requires `messageId`, string or null; `limit_reached` is `error.v1` only). Added `CULTNET_ERROR_V1_CODES`, `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS`, `write_content_hash` and `hello_advertises_write_receipts`; `hello` takes `supported_message_versions`. `tests/test_write_receipt.py` replays the vectors' `contentHash`, `wire` and `schemaInvalid` sections. Not implemented here: the client write tracker (`scenarios`) and the server rules (`dedupe`, `blankFields`); this package has no receipt server and no write client.
|
||||
- Added `message_invalid` to `CULTNET_ERROR_CODES` and `message_invalid_error(field, reason)`, byte-identical to the C# `ForMessageInvalid`. `cultmesh-py` refuses a malformed put or delete with it instead of the out-of-schema `malformed_document_put` and `malformed_document_delete`, which are removed.
|
||||
- Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per
|
||||
|
|
|
|||
|
|
@ -692,7 +692,7 @@ simulation_fact_document: DocumentDefinition[CultMeshSimulationFact] = define_da
|
|||
],
|
||||
cls=CultMeshSimulationFact,
|
||||
name="fact_id",
|
||||
indexes={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"},
|
||||
roles={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"},
|
||||
schema_name=SIMULATION_FACT_DOCUMENT_TYPE,
|
||||
schema_version=SIMULATION_FACT_SCHEMA_VERSION,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from typing import Any
|
|||
|
||||
import msgpack
|
||||
|
||||
from .unicode_messageid_tables import DEFAULT_IGNORABLE, LETTER_OR_NUMBER
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CultNetMessage:
|
||||
|
|
@ -69,6 +71,36 @@ def _is_name(value: Any) -> bool:
|
|||
return isinstance(value, str) and value != ""
|
||||
|
||||
|
||||
# write-receipt.md: a letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) somewhere, no Default_Ignorable code point anywhere, as of
|
||||
# Unicode 16.0, compared raw (never stripped). The tables are embedded (unicode_messageid_tables.py, generated by
|
||||
# scripts/gen-unicode-messageid-tables.py), not unicodedata's, so the running Python's Unicode version changes no result;
|
||||
# the other four runtimes embed the same generated tables.
|
||||
def _in_ranges(ranges: tuple[int, ...], point: int) -> bool:
|
||||
"""Whether [point] lies in the flattened, sorted (low, high) pairs."""
|
||||
low, high = 0, len(ranges) // 2 - 1
|
||||
while low <= high:
|
||||
mid = (low + high) // 2
|
||||
if point < ranges[2 * mid]:
|
||||
high = mid - 1
|
||||
elif point > ranges[2 * mid + 1]:
|
||||
low = mid + 1
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_admitted_message_id(value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
has_letter_or_number = False
|
||||
for ch in value:
|
||||
point = ord(ch)
|
||||
if _in_ranges(DEFAULT_IGNORABLE, point):
|
||||
return False
|
||||
has_letter_or_number = has_letter_or_number or _in_ranges(LETTER_OR_NUMBER, point)
|
||||
return has_letter_or_number
|
||||
|
||||
|
||||
def _validate_write_admitted_body(body: dict[str, Any]) -> None:
|
||||
"""Refuse a cultnet.write_admitted.v0 body that is not exactly the five keys of its closed schema."""
|
||||
expected = ("messageId", "operation", "schemaId", "recordKey")
|
||||
|
|
@ -77,6 +109,8 @@ def _validate_write_admitted_body(body: dict[str, Any]) -> None:
|
|||
for key in ("messageId", "schemaId", "recordKey"):
|
||||
if not _is_name(body[key]):
|
||||
raise ValueError(f"cultnet.write_admitted.v0 {key} must be a non-empty string")
|
||||
if not _is_admitted_message_id(body["messageId"]):
|
||||
raise ValueError("cultnet.write_admitted.v0 messageId must contain a letter or number and no Default_Ignorable code point")
|
||||
if body["operation"] not in ("put", "delete"):
|
||||
raise ValueError("cultnet.write_admitted.v0 operation must be put or delete")
|
||||
|
||||
|
|
|
|||
|
|
@ -429,9 +429,11 @@ def wire_message_schema_properties(schema_version: str) -> dict[str, Any]:
|
|||
"messageId": common["messageId"],
|
||||
"subscriptionId": common["subscriptionId"],
|
||||
"changeKind": {"enum": ["added", "updated", "removed"]},
|
||||
"document": {"$ref": "#/$defs/rawDocumentRecord"},
|
||||
"schemaId": common["schemaId"],
|
||||
"recordKey": common["recordKey"],
|
||||
"document": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/rawDocumentRecord"}]},
|
||||
"schemaId": {"type": ["string", "null"], "minLength": 1},
|
||||
"recordKey": {"type": ["string", "null"], "minLength": 1},
|
||||
"commitId": {"type": ["string", "null"], "minLength": 1},
|
||||
"commitCount": {"type": ["integer", "null"], "minimum": 2},
|
||||
},
|
||||
"cultnet.shard_catalog_request.v0": {
|
||||
**common,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ class CultNetDatabaseChange:
|
|||
raw_document: CultNetRawDocumentRecord | None = None
|
||||
schema_id: str | None = None
|
||||
record_key: str | None = None
|
||||
commit_id: str | None = None
|
||||
"""Set on every change of one multi-record commit, the same for all of them; None outside one (C# writes nil)."""
|
||||
commit_count: int | None = None
|
||||
"""How many changes the commit holds (>= 2); None outside one. Carried data: Python applies no batch."""
|
||||
|
||||
@classmethod
|
||||
def from_wire(cls, message: dict[str, Any]) -> "CultNetDatabaseChange":
|
||||
|
|
@ -31,6 +35,12 @@ class CultNetDatabaseChange:
|
|||
if raw_document is not None:
|
||||
schema_id = schema_id or raw_document.schema_id
|
||||
record_key = record_key or raw_document.record_key
|
||||
commit_id = message.get("commitId")
|
||||
if commit_id is not None and not (isinstance(commit_id, str) and commit_id):
|
||||
raise ValueError(f"database change commitId must be a non-empty string, received {commit_id!r}")
|
||||
commit_count = message.get("commitCount")
|
||||
if commit_count is not None and (isinstance(commit_count, bool) or not isinstance(commit_count, int) or commit_count < 2):
|
||||
raise ValueError(f"database change commitCount must be an integer of at least 2, received {commit_count!r}")
|
||||
return cls(
|
||||
message_id=str(message.get("messageId") or ""),
|
||||
subscription_id=str(message.get("subscriptionId") or ""),
|
||||
|
|
@ -39,6 +49,8 @@ class CultNetDatabaseChange:
|
|||
raw_document=raw_document,
|
||||
schema_id=schema_id,
|
||||
record_key=record_key,
|
||||
commit_id=commit_id,
|
||||
commit_count=commit_count,
|
||||
)
|
||||
|
||||
def to_wire(self) -> dict[str, Any]:
|
||||
|
|
@ -56,6 +68,10 @@ class CultNetDatabaseChange:
|
|||
wire["schemaId"] = self.schema_id
|
||||
if self.record_key is not None:
|
||||
wire["recordKey"] = self.record_key
|
||||
if self.commit_id is not None:
|
||||
wire["commitId"] = self.commit_id
|
||||
if self.commit_count is not None:
|
||||
wire["commitCount"] = self.commit_count
|
||||
return wire
|
||||
|
||||
|
||||
|
|
|
|||
127
packages/cultnet-py/src/cultnet_py/unicode_messageid_tables.py
Normal file
127
packages/cultnet-py/src/cultnet_py/unicode_messageid_tables.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
|
||||
|
||||
# (low, high) pairs, flattened and sorted.
|
||||
LETTER_OR_NUMBER: tuple[int, ...] = (
|
||||
0x30, 0x39, 0x41, 0x5A, 0x61, 0x7A, 0xAA, 0xAA, 0xB2, 0xB3, 0xB5, 0xB5, 0xB9, 0xBA, 0xBC, 0xBE, 0xC0, 0xD6, 0xD8,
|
||||
0xF6, 0xF8, 0x2C1, 0x2C6, 0x2D1, 0x2E0, 0x2E4, 0x2EC, 0x2EC, 0x2EE, 0x2EE, 0x370, 0x374, 0x376, 0x377, 0x37A,
|
||||
0x37D, 0x37F, 0x37F, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3F5, 0x3F7, 0x481, 0x48A,
|
||||
0x52F, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x5D0, 0x5EA, 0x5EF, 0x5F2, 0x620, 0x64A, 0x660, 0x669, 0x66E,
|
||||
0x66F, 0x671, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x6EE, 0x6FC, 0x6FF, 0x6FF, 0x710, 0x710, 0x712, 0x72F, 0x74D,
|
||||
0x7A5, 0x7B1, 0x7B1, 0x7C0, 0x7EA, 0x7F4, 0x7F5, 0x7FA, 0x7FA, 0x800, 0x815, 0x81A, 0x81A, 0x824, 0x824, 0x828,
|
||||
0x828, 0x840, 0x858, 0x860, 0x86A, 0x870, 0x887, 0x889, 0x88E, 0x8A0, 0x8C9, 0x904, 0x939, 0x93D, 0x93D, 0x950,
|
||||
0x950, 0x958, 0x961, 0x966, 0x96F, 0x971, 0x980, 0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2,
|
||||
0x9B2, 0x9B6, 0x9B9, 0x9BD, 0x9BD, 0x9CE, 0x9CE, 0x9DC, 0x9DD, 0x9DF, 0x9E1, 0x9E6, 0x9F1, 0x9F4, 0x9F9, 0x9FC,
|
||||
0x9FC, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59,
|
||||
0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA6F, 0xA72, 0xA74, 0xA85, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2,
|
||||
0xAB3, 0xAB5, 0xAB9, 0xABD, 0xABD, 0xAD0, 0xAD0, 0xAE0, 0xAE1, 0xAE6, 0xAEF, 0xAF9, 0xAF9, 0xB05, 0xB0C, 0xB0F,
|
||||
0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB35, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66,
|
||||
0xB6F, 0xB71, 0xB77, 0xB83, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E,
|
||||
0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB9, 0xBD0, 0xBD0, 0xBE6, 0xBF2, 0xC05, 0xC0C, 0xC0E, 0xC10, 0xC12,
|
||||
0xC28, 0xC2A, 0xC39, 0xC3D, 0xC3D, 0xC58, 0xC5A, 0xC5D, 0xC5D, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC78, 0xC7E, 0xC80,
|
||||
0xC80, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBD, 0xCBD, 0xCDD, 0xCDE, 0xCE0,
|
||||
0xCE1, 0xCE6, 0xCEF, 0xCF1, 0xCF2, 0xD04, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD3A, 0xD3D, 0xD3D, 0xD4E, 0xD4E, 0xD54,
|
||||
0xD56, 0xD58, 0xD61, 0xD66, 0xD78, 0xD7A, 0xD7F, 0xD85, 0xD96, 0xD9A, 0xDB1, 0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0,
|
||||
0xDC6, 0xDE6, 0xDEF, 0xE01, 0xE30, 0xE32, 0xE33, 0xE40, 0xE46, 0xE50, 0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE86,
|
||||
0xE8A, 0xE8C, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xED0,
|
||||
0xED9, 0xEDC, 0xEDF, 0xF00, 0xF00, 0xF20, 0xF33, 0xF40, 0xF47, 0xF49, 0xF6C, 0xF88, 0xF8C, 0x1000, 0x102A,
|
||||
0x103F, 0x1049, 0x1050, 0x1055, 0x105A, 0x105D, 0x1061, 0x1061, 0x1065, 0x1066, 0x106E, 0x1070, 0x1075, 0x1081,
|
||||
0x108E, 0x108E, 0x1090, 0x1099, 0x10A0, 0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FC, 0x1248,
|
||||
0x124A, 0x124D, 0x1250, 0x1256, 0x1258, 0x1258, 0x125A, 0x125D, 0x1260, 0x1288, 0x128A, 0x128D, 0x1290, 0x12B0,
|
||||
0x12B2, 0x12B5, 0x12B8, 0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12D6, 0x12D8, 0x1310, 0x1312, 0x1315,
|
||||
0x1318, 0x135A, 0x1369, 0x137C, 0x1380, 0x138F, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1401, 0x166C, 0x166F, 0x167F,
|
||||
0x1681, 0x169A, 0x16A0, 0x16EA, 0x16EE, 0x16F8, 0x1700, 0x1711, 0x171F, 0x1731, 0x1740, 0x1751, 0x1760, 0x176C,
|
||||
0x176E, 0x1770, 0x1780, 0x17B3, 0x17D7, 0x17D7, 0x17DC, 0x17DC, 0x17E0, 0x17E9, 0x17F0, 0x17F9, 0x1810, 0x1819,
|
||||
0x1820, 0x1878, 0x1880, 0x1884, 0x1887, 0x18A8, 0x18AA, 0x18AA, 0x18B0, 0x18F5, 0x1900, 0x191E, 0x1946, 0x196D,
|
||||
0x1970, 0x1974, 0x1980, 0x19AB, 0x19B0, 0x19C9, 0x19D0, 0x19DA, 0x1A00, 0x1A16, 0x1A20, 0x1A54, 0x1A80, 0x1A89,
|
||||
0x1A90, 0x1A99, 0x1AA7, 0x1AA7, 0x1B05, 0x1B33, 0x1B45, 0x1B4C, 0x1B50, 0x1B59, 0x1B83, 0x1BA0, 0x1BAE, 0x1BE5,
|
||||
0x1C00, 0x1C23, 0x1C40, 0x1C49, 0x1C4D, 0x1C7D, 0x1C80, 0x1C8A, 0x1C90, 0x1CBA, 0x1CBD, 0x1CBF, 0x1CE9, 0x1CEC,
|
||||
0x1CEE, 0x1CF3, 0x1CF5, 0x1CF6, 0x1CFA, 0x1CFA, 0x1D00, 0x1DBF, 0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45,
|
||||
0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4,
|
||||
0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC,
|
||||
0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x2070, 0x2071, 0x2074, 0x2079, 0x207F, 0x2089, 0x2090, 0x209C, 0x2102, 0x2102,
|
||||
0x2107, 0x2107, 0x210A, 0x2113, 0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128,
|
||||
0x212A, 0x212D, 0x212F, 0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2150, 0x2189, 0x2460, 0x249B,
|
||||
0x24EA, 0x24FF, 0x2776, 0x2793, 0x2C00, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2CFD, 0x2CFD, 0x2D00, 0x2D25,
|
||||
0x2D27, 0x2D27, 0x2D2D, 0x2D2D, 0x2D30, 0x2D67, 0x2D6F, 0x2D6F, 0x2D80, 0x2D96, 0x2DA0, 0x2DA6, 0x2DA8, 0x2DAE,
|
||||
0x2DB0, 0x2DB6, 0x2DB8, 0x2DBE, 0x2DC0, 0x2DC6, 0x2DC8, 0x2DCE, 0x2DD0, 0x2DD6, 0x2DD8, 0x2DDE, 0x2E2F, 0x2E2F,
|
||||
0x3005, 0x3007, 0x3021, 0x3029, 0x3031, 0x3035, 0x3038, 0x303C, 0x3041, 0x3096, 0x309D, 0x309F, 0x30A1, 0x30FA,
|
||||
0x30FC, 0x30FF, 0x3105, 0x312F, 0x3131, 0x318E, 0x3192, 0x3195, 0x31A0, 0x31BF, 0x31F0, 0x31FF, 0x3220, 0x3229,
|
||||
0x3248, 0x324F, 0x3251, 0x325F, 0x3280, 0x3289, 0x32B1, 0x32BF, 0x3400, 0x4DBF, 0x4E00, 0xA48C, 0xA4D0, 0xA4FD,
|
||||
0xA500, 0xA60C, 0xA610, 0xA62B, 0xA640, 0xA66E, 0xA67F, 0xA69D, 0xA6A0, 0xA6EF, 0xA717, 0xA71F, 0xA722, 0xA788,
|
||||
0xA78B, 0xA7CD, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, 0xA7D5, 0xA7DC, 0xA7F2, 0xA801, 0xA803, 0xA805, 0xA807, 0xA80A,
|
||||
0xA80C, 0xA822, 0xA830, 0xA835, 0xA840, 0xA873, 0xA882, 0xA8B3, 0xA8D0, 0xA8D9, 0xA8F2, 0xA8F7, 0xA8FB, 0xA8FB,
|
||||
0xA8FD, 0xA8FE, 0xA900, 0xA925, 0xA930, 0xA946, 0xA960, 0xA97C, 0xA984, 0xA9B2, 0xA9CF, 0xA9D9, 0xA9E0, 0xA9E4,
|
||||
0xA9E6, 0xA9FE, 0xAA00, 0xAA28, 0xAA40, 0xAA42, 0xAA44, 0xAA4B, 0xAA50, 0xAA59, 0xAA60, 0xAA76, 0xAA7A, 0xAA7A,
|
||||
0xAA7E, 0xAAAF, 0xAAB1, 0xAAB1, 0xAAB5, 0xAAB6, 0xAAB9, 0xAABD, 0xAAC0, 0xAAC0, 0xAAC2, 0xAAC2, 0xAADB, 0xAADD,
|
||||
0xAAE0, 0xAAEA, 0xAAF2, 0xAAF4, 0xAB01, 0xAB06, 0xAB09, 0xAB0E, 0xAB11, 0xAB16, 0xAB20, 0xAB26, 0xAB28, 0xAB2E,
|
||||
0xAB30, 0xAB5A, 0xAB5C, 0xAB69, 0xAB70, 0xABE2, 0xABF0, 0xABF9, 0xAC00, 0xD7A3, 0xD7B0, 0xD7C6, 0xD7CB, 0xD7FB,
|
||||
0xF900, 0xFA6D, 0xFA70, 0xFAD9, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB1D, 0xFB1F, 0xFB28, 0xFB2A, 0xFB36,
|
||||
0xFB38, 0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3D, 0xFD50, 0xFD8F,
|
||||
0xFD92, 0xFDC7, 0xFDF0, 0xFDFB, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFF10, 0xFF19, 0xFF21, 0xFF3A, 0xFF41, 0xFF5A,
|
||||
0xFF66, 0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0x10000, 0x1000B, 0x1000D,
|
||||
0x10026, 0x10028, 0x1003A, 0x1003C, 0x1003D, 0x1003F, 0x1004D, 0x10050, 0x1005D, 0x10080, 0x100FA, 0x10107,
|
||||
0x10133, 0x10140, 0x10178, 0x1018A, 0x1018B, 0x10280, 0x1029C, 0x102A0, 0x102D0, 0x102E1, 0x102FB, 0x10300,
|
||||
0x10323, 0x1032D, 0x1034A, 0x10350, 0x10375, 0x10380, 0x1039D, 0x103A0, 0x103C3, 0x103C8, 0x103CF, 0x103D1,
|
||||
0x103D5, 0x10400, 0x1049D, 0x104A0, 0x104A9, 0x104B0, 0x104D3, 0x104D8, 0x104FB, 0x10500, 0x10527, 0x10530,
|
||||
0x10563, 0x10570, 0x1057A, 0x1057C, 0x1058A, 0x1058C, 0x10592, 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3,
|
||||
0x105B1, 0x105B3, 0x105B9, 0x105BB, 0x105BC, 0x105C0, 0x105F3, 0x10600, 0x10736, 0x10740, 0x10755, 0x10760,
|
||||
0x10767, 0x10780, 0x10785, 0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10800, 0x10805, 0x10808, 0x10808, 0x1080A,
|
||||
0x10835, 0x10837, 0x10838, 0x1083C, 0x1083C, 0x1083F, 0x10855, 0x10858, 0x10876, 0x10879, 0x1089E, 0x108A7,
|
||||
0x108AF, 0x108E0, 0x108F2, 0x108F4, 0x108F5, 0x108FB, 0x1091B, 0x10920, 0x10939, 0x10980, 0x109B7, 0x109BC,
|
||||
0x109CF, 0x109D2, 0x10A00, 0x10A10, 0x10A13, 0x10A15, 0x10A17, 0x10A19, 0x10A35, 0x10A40, 0x10A48, 0x10A60,
|
||||
0x10A7E, 0x10A80, 0x10A9F, 0x10AC0, 0x10AC7, 0x10AC9, 0x10AE4, 0x10AEB, 0x10AEF, 0x10B00, 0x10B35, 0x10B40,
|
||||
0x10B55, 0x10B58, 0x10B72, 0x10B78, 0x10B91, 0x10BA9, 0x10BAF, 0x10C00, 0x10C48, 0x10C80, 0x10CB2, 0x10CC0,
|
||||
0x10CF2, 0x10CFA, 0x10D23, 0x10D30, 0x10D39, 0x10D40, 0x10D65, 0x10D6F, 0x10D85, 0x10E60, 0x10E7E, 0x10E80,
|
||||
0x10EA9, 0x10EB0, 0x10EB1, 0x10EC2, 0x10EC4, 0x10F00, 0x10F27, 0x10F30, 0x10F45, 0x10F51, 0x10F54, 0x10F70,
|
||||
0x10F81, 0x10FB0, 0x10FCB, 0x10FE0, 0x10FF6, 0x11003, 0x11037, 0x11052, 0x1106F, 0x11071, 0x11072, 0x11075,
|
||||
0x11075, 0x11083, 0x110AF, 0x110D0, 0x110E8, 0x110F0, 0x110F9, 0x11103, 0x11126, 0x11136, 0x1113F, 0x11144,
|
||||
0x11144, 0x11147, 0x11147, 0x11150, 0x11172, 0x11176, 0x11176, 0x11183, 0x111B2, 0x111C1, 0x111C4, 0x111D0,
|
||||
0x111DA, 0x111DC, 0x111DC, 0x111E1, 0x111F4, 0x11200, 0x11211, 0x11213, 0x1122B, 0x1123F, 0x11240, 0x11280,
|
||||
0x11286, 0x11288, 0x11288, 0x1128A, 0x1128D, 0x1128F, 0x1129D, 0x1129F, 0x112A8, 0x112B0, 0x112DE, 0x112F0,
|
||||
0x112F9, 0x11305, 0x1130C, 0x1130F, 0x11310, 0x11313, 0x11328, 0x1132A, 0x11330, 0x11332, 0x11333, 0x11335,
|
||||
0x11339, 0x1133D, 0x1133D, 0x11350, 0x11350, 0x1135D, 0x11361, 0x11380, 0x11389, 0x1138B, 0x1138B, 0x1138E,
|
||||
0x1138E, 0x11390, 0x113B5, 0x113B7, 0x113B7, 0x113D1, 0x113D1, 0x113D3, 0x113D3, 0x11400, 0x11434, 0x11447,
|
||||
0x1144A, 0x11450, 0x11459, 0x1145F, 0x11461, 0x11480, 0x114AF, 0x114C4, 0x114C5, 0x114C7, 0x114C7, 0x114D0,
|
||||
0x114D9, 0x11580, 0x115AE, 0x115D8, 0x115DB, 0x11600, 0x1162F, 0x11644, 0x11644, 0x11650, 0x11659, 0x11680,
|
||||
0x116AA, 0x116B8, 0x116B8, 0x116C0, 0x116C9, 0x116D0, 0x116E3, 0x11700, 0x1171A, 0x11730, 0x1173B, 0x11740,
|
||||
0x11746, 0x11800, 0x1182B, 0x118A0, 0x118F2, 0x118FF, 0x11906, 0x11909, 0x11909, 0x1190C, 0x11913, 0x11915,
|
||||
0x11916, 0x11918, 0x1192F, 0x1193F, 0x1193F, 0x11941, 0x11941, 0x11950, 0x11959, 0x119A0, 0x119A7, 0x119AA,
|
||||
0x119D0, 0x119E1, 0x119E1, 0x119E3, 0x119E3, 0x11A00, 0x11A00, 0x11A0B, 0x11A32, 0x11A3A, 0x11A3A, 0x11A50,
|
||||
0x11A50, 0x11A5C, 0x11A89, 0x11A9D, 0x11A9D, 0x11AB0, 0x11AF8, 0x11BC0, 0x11BE0, 0x11BF0, 0x11BF9, 0x11C00,
|
||||
0x11C08, 0x11C0A, 0x11C2E, 0x11C40, 0x11C40, 0x11C50, 0x11C6C, 0x11C72, 0x11C8F, 0x11D00, 0x11D06, 0x11D08,
|
||||
0x11D09, 0x11D0B, 0x11D30, 0x11D46, 0x11D46, 0x11D50, 0x11D59, 0x11D60, 0x11D65, 0x11D67, 0x11D68, 0x11D6A,
|
||||
0x11D89, 0x11D98, 0x11D98, 0x11DA0, 0x11DA9, 0x11EE0, 0x11EF2, 0x11F02, 0x11F02, 0x11F04, 0x11F10, 0x11F12,
|
||||
0x11F33, 0x11F50, 0x11F59, 0x11FB0, 0x11FB0, 0x11FC0, 0x11FD4, 0x12000, 0x12399, 0x12400, 0x1246E, 0x12480,
|
||||
0x12543, 0x12F90, 0x12FF0, 0x13000, 0x1342F, 0x13441, 0x13446, 0x13460, 0x143FA, 0x14400, 0x14646, 0x16100,
|
||||
0x1611D, 0x16130, 0x16139, 0x16800, 0x16A38, 0x16A40, 0x16A5E, 0x16A60, 0x16A69, 0x16A70, 0x16ABE, 0x16AC0,
|
||||
0x16AC9, 0x16AD0, 0x16AED, 0x16B00, 0x16B2F, 0x16B40, 0x16B43, 0x16B50, 0x16B59, 0x16B5B, 0x16B61, 0x16B63,
|
||||
0x16B77, 0x16B7D, 0x16B8F, 0x16D40, 0x16D6C, 0x16D70, 0x16D79, 0x16E40, 0x16E96, 0x16F00, 0x16F4A, 0x16F50,
|
||||
0x16F50, 0x16F93, 0x16F9F, 0x16FE0, 0x16FE1, 0x16FE3, 0x16FE3, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF,
|
||||
0x18D08, 0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150,
|
||||
0x1B152, 0x1B155, 0x1B155, 0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1BC00, 0x1BC6A, 0x1BC70, 0x1BC7C, 0x1BC80,
|
||||
0x1BC88, 0x1BC90, 0x1BC99, 0x1CCF0, 0x1CCF9, 0x1D2C0, 0x1D2D3, 0x1D2E0, 0x1D2F3, 0x1D360, 0x1D378, 0x1D400,
|
||||
0x1D454, 0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE,
|
||||
0x1D4B9, 0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514, 0x1D516,
|
||||
0x1D51C, 0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552,
|
||||
0x1D6A5, 0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714, 0x1D716, 0x1D734, 0x1D736,
|
||||
0x1D74E, 0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA, 0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1D7CE,
|
||||
0x1D7FF, 0x1DF00, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D, 0x1E100, 0x1E12C, 0x1E137, 0x1E13D, 0x1E140,
|
||||
0x1E149, 0x1E14E, 0x1E14E, 0x1E290, 0x1E2AD, 0x1E2C0, 0x1E2EB, 0x1E2F0, 0x1E2F9, 0x1E4D0, 0x1E4EB, 0x1E4F0,
|
||||
0x1E4F9, 0x1E5D0, 0x1E5ED, 0x1E5F0, 0x1E5FA, 0x1E7E0, 0x1E7E6, 0x1E7E8, 0x1E7EB, 0x1E7ED, 0x1E7EE, 0x1E7F0,
|
||||
0x1E7FE, 0x1E800, 0x1E8C4, 0x1E8C7, 0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1E94B, 0x1E950, 0x1E959, 0x1EC71,
|
||||
0x1ECAB, 0x1ECAD, 0x1ECAF, 0x1ECB1, 0x1ECB4, 0x1ED01, 0x1ED2D, 0x1ED2F, 0x1ED3D, 0x1EE00, 0x1EE03, 0x1EE05,
|
||||
0x1EE1F, 0x1EE21, 0x1EE22, 0x1EE24, 0x1EE24, 0x1EE27, 0x1EE27, 0x1EE29, 0x1EE32, 0x1EE34, 0x1EE37, 0x1EE39,
|
||||
0x1EE39, 0x1EE3B, 0x1EE3B, 0x1EE42, 0x1EE42, 0x1EE47, 0x1EE47, 0x1EE49, 0x1EE49, 0x1EE4B, 0x1EE4B, 0x1EE4D,
|
||||
0x1EE4F, 0x1EE51, 0x1EE52, 0x1EE54, 0x1EE54, 0x1EE57, 0x1EE57, 0x1EE59, 0x1EE59, 0x1EE5B, 0x1EE5B, 0x1EE5D,
|
||||
0x1EE5D, 0x1EE5F, 0x1EE5F, 0x1EE61, 0x1EE62, 0x1EE64, 0x1EE64, 0x1EE67, 0x1EE6A, 0x1EE6C, 0x1EE72, 0x1EE74,
|
||||
0x1EE77, 0x1EE79, 0x1EE7C, 0x1EE7E, 0x1EE7E, 0x1EE80, 0x1EE89, 0x1EE8B, 0x1EE9B, 0x1EEA1, 0x1EEA3, 0x1EEA5,
|
||||
0x1EEA9, 0x1EEAB, 0x1EEBB, 0x1F100, 0x1F10C, 0x1FBF0, 0x1FBF9, 0x20000, 0x2A6DF, 0x2A700, 0x2B739, 0x2B740,
|
||||
0x2B81D, 0x2B820, 0x2CEA1, 0x2CEB0, 0x2EBE0, 0x2EBF0, 0x2EE5D, 0x2F800, 0x2FA1D, 0x30000, 0x3134A, 0x31350,
|
||||
0x323AF,
|
||||
)
|
||||
|
||||
DEFAULT_IGNORABLE: tuple[int, ...] = (
|
||||
0xAD, 0xAD, 0x34F, 0x34F, 0x61C, 0x61C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180F, 0x200B, 0x200F, 0x202A,
|
||||
0x202E, 0x2060, 0x206F, 0x3164, 0x3164, 0xFE00, 0xFE0F, 0xFEFF, 0xFEFF, 0xFFA0, 0xFFA0, 0xFFF0, 0xFFF8, 0x1BCA0,
|
||||
0x1BCA3, 0x1D173, 0x1D17A, 0xE0000, 0xE0FFF,
|
||||
)
|
||||
|
|
@ -13,6 +13,7 @@ from datetime import UTC, datetime
|
|||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
import msgpack
|
||||
from cultcache_py import CultCache, define_database_entry_type
|
||||
from cultnet_py.benchmark import run_benchmark
|
||||
from cultnet_py.compare_csharp import (
|
||||
|
|
@ -1566,6 +1567,51 @@ class CultNetTests(unittest.TestCase):
|
|||
self.assertEqual(delete_change.schema_id, "schema-note")
|
||||
self.assertEqual(delete_change.to_wire()["recordKey"], "note:1")
|
||||
|
||||
def test_cultnet_database_change_commit_fields(self) -> None:
|
||||
base = {
|
||||
"schemaVersion": "cultnet.database_change_raw.v0",
|
||||
"messageId": "change-commit",
|
||||
"subscriptionId": "sub-1",
|
||||
"changeKind": "removed",
|
||||
"schemaId": "schema-note",
|
||||
"recordKey": "note:1",
|
||||
}
|
||||
grouped = CultNetDatabaseChange.from_wire({**base, "commitId": "commit-7", "commitCount": 3})
|
||||
self.assertEqual((grouped.commit_id, grouped.commit_count), ("commit-7", 3))
|
||||
self.assertEqual(grouped.to_wire()["commitId"], "commit-7")
|
||||
self.assertEqual(grouped.to_wire()["commitCount"], 3)
|
||||
self.assertEqual(CultNetDatabaseChange.from_wire(grouped.to_wire()), grouped)
|
||||
|
||||
for absent in (base, {**base, "commitId": None, "commitCount": None}): # C# writes nil outside a commit
|
||||
change = CultNetDatabaseChange.from_wire(absent)
|
||||
self.assertIsNone(change.commit_id)
|
||||
self.assertIsNone(change.commit_count)
|
||||
self.assertNotIn("commitId", change.to_wire())
|
||||
self.assertNotIn("commitCount", change.to_wire())
|
||||
|
||||
for field, bad in (
|
||||
("commitId", ""),
|
||||
("commitId", 7),
|
||||
("commitCount", 1),
|
||||
("commitCount", 0),
|
||||
("commitCount", True),
|
||||
("commitCount", 2.0),
|
||||
("commitCount", "3"),
|
||||
):
|
||||
with self.subTest(field=field, bad=bad), self.assertRaisesRegex(ValueError, field):
|
||||
CultNetDatabaseChange.from_wire({**base, field: bad})
|
||||
|
||||
def test_a_two_change_commit_written_by_csharp_parses(self) -> None:
|
||||
# tests/vectors/database-change-commit/two-change-commit.msgpack: the two change messages of one commit, written by the
|
||||
# C# serializer (CultNetReplicaCommitTests), so a rename or retyping of commitId/commitCount fails here.
|
||||
vector = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "database-change-commit" / "two-change-commit.msgpack"
|
||||
messages = list(msgpack.Unpacker(io.BytesIO(vector.read_bytes()), raw=False))
|
||||
self.assertEqual(len(messages), 2)
|
||||
changes = [CultNetDatabaseChange.from_wire(message) for message in messages]
|
||||
self.assertEqual([change.commit_count for change in changes], [2, 2])
|
||||
self.assertEqual({change.commit_id for change in changes}, {"9f1c0f0e4b7a4c1f9d2e"})
|
||||
self.assertEqual([change.to_wire()["commitCount"] for change in changes], [2, 2])
|
||||
|
||||
def test_cultnet_raw_put_helper_carries_message_id(self) -> None:
|
||||
put = document_put_raw(
|
||||
message_id="put-1",
|
||||
|
|
@ -2269,9 +2315,12 @@ class CultNetTests(unittest.TestCase):
|
|||
|
||||
change_schema = json.loads(by_version["cultnet.database_change_raw.v0"]["schemaJson"])
|
||||
self.assertEqual(change_schema["properties"]["changeKind"]["enum"], ["added", "updated", "removed"])
|
||||
self.assertIn("document", change_schema["properties"])
|
||||
self.assertIn("schemaId", change_schema["properties"])
|
||||
self.assertIn("recordKey", change_schema["properties"])
|
||||
# The C# writer sets every key, nil where unset (a removed change's document, an added change's recordKey and schemaId).
|
||||
self.assertEqual(change_schema["properties"]["document"]["oneOf"][0], {"type": "null"})
|
||||
self.assertEqual(change_schema["properties"]["schemaId"], {"type": ["string", "null"], "minLength": 1})
|
||||
self.assertEqual(change_schema["properties"]["recordKey"], {"type": ["string", "null"], "minLength": 1})
|
||||
self.assertEqual(change_schema["properties"]["commitId"], {"type": ["string", "null"], "minLength": 1})
|
||||
self.assertEqual(change_schema["properties"]["commitCount"], {"type": ["integer", "null"], "minimum": 2})
|
||||
|
||||
log_schema = json.loads(by_version["cultnet.shard_log_response.v0"]["schemaJson"])
|
||||
self.assertIn("entries", log_schema["required"])
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class WriteReceiptVectorTests(unittest.TestCase):
|
|||
def test_vector_sections_are_the_ones_this_file_accounts_for(self) -> None:
|
||||
self.assertEqual(
|
||||
set(_VECTORS) - {"provenance", "specification"},
|
||||
{"constants", "contentHash", "dedupe", "wire", "blankFields", "schemaInvalid", "scenarios", "counts"},
|
||||
{"constants", "contentHash", "dedupe", "wire", "blankFields", "schemaInvalid", "scenarios", "counts", "admittedMessageId", "admittedMessageIdDuplicates"},
|
||||
)
|
||||
self.assertEqual(len(_VECTORS["contentHash"]["cases"]), 4)
|
||||
self.assertEqual(len(_VECTORS["wire"]), 7)
|
||||
|
|
@ -68,6 +68,69 @@ class WriteReceiptVectorTests(unittest.TestCase):
|
|||
self.assertEqual(counts["schemaInvalid"], len(_VECTORS["schemaInvalid"]))
|
||||
self.assertEqual(counts["contentHashCases"], len(_VECTORS["contentHash"]["cases"]))
|
||||
self.assertEqual(counts["scenarioCases"], len(_VECTORS["scenarios"]["cases"]))
|
||||
self.assertEqual(counts["admittedMessageIdCases"], len(_VECTORS["admittedMessageId"]["cases"]))
|
||||
|
||||
def test_admitted_message_id_vectors_are_refused_or_accepted_raw(self) -> None:
|
||||
# `admittedMessageId`: a write_admitted messageId needs a letter or number and no Default_Ignorable code point,
|
||||
# compared raw (never trimmed). A lone surrogate is not valid UTF-8, so its vector carries the whole frame.
|
||||
cases = _VECTORS["admittedMessageId"]["cases"]
|
||||
failures = []
|
||||
for case in cases:
|
||||
raw = (
|
||||
bytes.fromhex(case["wireHex"])
|
||||
if "wireHex" in case
|
||||
else _pack(_admitted(messageId="".join(chr(int(cp, 16)) for cp in case["codePoints"]) * case.get("repeat", 1)))
|
||||
)
|
||||
try:
|
||||
message = parse_message(raw)
|
||||
except Exception: # noqa: BLE001 - any refusal counts
|
||||
refused = True
|
||||
else:
|
||||
refused = False
|
||||
wanted = "".join(chr(int(cp, 16)) for cp in case["codePoints"]) * case.get("repeat", 1)
|
||||
if message.body["messageId"] != wanted:
|
||||
failures.append(f"{case['name']}: id not echoed raw")
|
||||
if refused != (case["expect"] == "refuse"):
|
||||
failures.append(f"{case['name']}: expected {case['expect']}")
|
||||
self.assertEqual(failures, [])
|
||||
|
||||
def test_a_repeated_message_id_key_follows_the_last_key_wins_policy(self) -> None:
|
||||
# Python decodes the frame to a dict, so it cannot see a duplicate: it judges the last key (admittedMessageIdDuplicates).
|
||||
section = _VECTORS["admittedMessageIdDuplicates"]
|
||||
self.assertEqual(_VECTORS["counts"]["admittedMessageIdDuplicateCases"], len(section["cases"]))
|
||||
for case in section["cases"]:
|
||||
raw = bytes.fromhex(case["wireHex"])
|
||||
if case["lastKeyWins"] == "refuse":
|
||||
with self.assertRaises(ValueError, msg=case["name"]):
|
||||
parse_message(raw)
|
||||
else:
|
||||
self.assertEqual(parse_message(raw).body["messageId"], case["lastKeyWinsEcho"], case["name"])
|
||||
|
||||
def test_every_range_of_the_unicode_tables_is_pinned_at_both_ends(self) -> None:
|
||||
# write-admitted-message-id-ranges.json is the generated, independent copy: for each letter-or-number range its first and
|
||||
# last point are accepted and the points either side refused; for each Default_Ignorable range "a" beside its first and
|
||||
# last point is refused and beside the points either side accepted. Deleting or narrowing any embedded range fails here.
|
||||
ranges = json.loads((_INTEROP / "write-admitted-message-id-ranges.json").read_text(encoding="utf-8"))
|
||||
self.assertGreater(len(ranges["letterOrNumber"]), 700)
|
||||
self.assertEqual(len(ranges["defaultIgnorable"]), 17)
|
||||
|
||||
def accepted(text: str) -> bool:
|
||||
try:
|
||||
parse_message(_pack(_admitted(messageId=text)))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
failures = []
|
||||
for low, high in ranges["letterOrNumber"]:
|
||||
for point, want in ((low, True), (high, True), (low - 1, False), (high + 1, False)):
|
||||
if accepted(chr(point)) != want:
|
||||
failures.append(f"U+{point:04X} alone should be {'accepted' if want else 'refused'}")
|
||||
for low, high in ranges["defaultIgnorable"]:
|
||||
for point, want in ((low, False), (high, False), (low - 1, True), (high + 1, True)):
|
||||
if accepted("a" + chr(point)) != want:
|
||||
failures.append(f"a + U+{point:04X} should be {'accepted' if want else 'refused'}")
|
||||
self.assertEqual(failures, [])
|
||||
|
||||
def test_content_hash_matches_every_vector(self) -> None:
|
||||
for case in _VECTORS["contentHash"]["cases"]:
|
||||
|
|
|
|||
|
|
@ -680,7 +680,7 @@ fn validate_message(message: &CultNetMessage) -> Result<()> {
|
|||
}
|
||||
}
|
||||
CultNetMessage::WriteAdmitted { message_id, operation, schema_id, record_key } => {
|
||||
require_non_empty(message_id, "messageId")?;
|
||||
require_admitted_message_id(message_id)?;
|
||||
if operation != "put" && operation != "delete" {
|
||||
return Err(anyhow!("operation must be put or delete"));
|
||||
}
|
||||
|
|
@ -934,6 +934,41 @@ fn require_non_empty(value: &str, field: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// write-receipt.md: a write_admitted messageId is valid iff it holds a Unicode letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) and no
|
||||
// Default_Ignorable_Code_Point, as of Unicode 16.0, compared raw (never trimmed). The tables are embedded
|
||||
// (unicode_messageid_tables.rs, generated by scripts/gen-unicode-messageid-tables.py), not std's, so no platform's Unicode
|
||||
// version changes a result; the other four runtimes embed the same generated tables. Invalid UTF-8 never reaches this check:
|
||||
// the MessagePack str decode refuses it.
|
||||
use crate::unicode_messageid_tables::{DEFAULT_IGNORABLE, LETTER_OR_NUMBER};
|
||||
|
||||
fn in_ranges(ranges: &[(u32, u32)], point: u32) -> bool {
|
||||
ranges.binary_search_by(|&(low, high)| {
|
||||
if point < low {
|
||||
std::cmp::Ordering::Greater
|
||||
} else if point > high {
|
||||
std::cmp::Ordering::Less
|
||||
} else {
|
||||
std::cmp::Ordering::Equal
|
||||
}
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn require_admitted_message_id(value: &str) -> Result<()> {
|
||||
let mut has_letter_or_number = false;
|
||||
for ch in value.chars() {
|
||||
let point = ch as u32;
|
||||
if in_ranges(DEFAULT_IGNORABLE, point) {
|
||||
return Err(anyhow!("CultNet field messageId must not hold a Default_Ignorable code point"));
|
||||
}
|
||||
has_letter_or_number |= in_ranges(LETTER_OR_NUMBER, point);
|
||||
}
|
||||
if !has_letter_or_number {
|
||||
return Err(anyhow!("CultNet field messageId must contain a letter or number"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_exact(value: &str, expected: &str, field: &str) -> Result<()> {
|
||||
if value != expected {
|
||||
return Err(anyhow!("{field} must be {expected}"));
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ mod contracts;
|
|||
mod idunn_deployment_brake;
|
||||
mod idunn_lifecycle_brake;
|
||||
mod media_fec;
|
||||
mod unicode_messageid_tables;
|
||||
mod media_stream_contracts;
|
||||
mod media_stream_wire;
|
||||
mod provider_health_contracts;
|
||||
|
|
|
|||
|
|
@ -1277,6 +1277,12 @@ impl CultNetRudpSession {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether `receive` has taken this reliable sequence: delivered, held, superseded or a duplicate. A packet
|
||||
/// `receive` refused unremembered (too far ahead, or before the handshake) is not, and its sender resends it.
|
||||
pub fn has_received(&self, sequence: u32) -> bool {
|
||||
self.was_received(sequence)
|
||||
}
|
||||
|
||||
/// The one duplicate test: true for a sequence in the window that was
|
||||
/// received, for one below the window, and for one at or before the
|
||||
/// watermark. The watermark starts at the handshake's seed, so a frame the
|
||||
|
|
|
|||
147
packages/cultnet-rs/src/unicode_messageid_tables.rs
Normal file
147
packages/cultnet-rs/src/unicode_messageid_tables.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
|
||||
|
||||
// (low, high) pairs, sorted.
|
||||
pub const LETTER_OR_NUMBER: &[(u32, u32)] = &[
|
||||
(0x30, 0x39), (0x41, 0x5A), (0x61, 0x7A), (0xAA, 0xAA), (0xB2, 0xB3), (0xB5, 0xB5), (0xB9, 0xBA), (0xBC, 0xBE),
|
||||
(0xC0, 0xD6), (0xD8, 0xF6), (0xF8, 0x2C1), (0x2C6, 0x2D1), (0x2E0, 0x2E4), (0x2EC, 0x2EC), (0x2EE, 0x2EE),
|
||||
(0x370, 0x374), (0x376, 0x377), (0x37A, 0x37D), (0x37F, 0x37F), (0x386, 0x386), (0x388, 0x38A), (0x38C, 0x38C),
|
||||
(0x38E, 0x3A1), (0x3A3, 0x3F5), (0x3F7, 0x481), (0x48A, 0x52F), (0x531, 0x556), (0x559, 0x559), (0x560, 0x588),
|
||||
(0x5D0, 0x5EA), (0x5EF, 0x5F2), (0x620, 0x64A), (0x660, 0x669), (0x66E, 0x66F), (0x671, 0x6D3), (0x6D5, 0x6D5),
|
||||
(0x6E5, 0x6E6), (0x6EE, 0x6FC), (0x6FF, 0x6FF), (0x710, 0x710), (0x712, 0x72F), (0x74D, 0x7A5), (0x7B1, 0x7B1),
|
||||
(0x7C0, 0x7EA), (0x7F4, 0x7F5), (0x7FA, 0x7FA), (0x800, 0x815), (0x81A, 0x81A), (0x824, 0x824), (0x828, 0x828),
|
||||
(0x840, 0x858), (0x860, 0x86A), (0x870, 0x887), (0x889, 0x88E), (0x8A0, 0x8C9), (0x904, 0x939), (0x93D, 0x93D),
|
||||
(0x950, 0x950), (0x958, 0x961), (0x966, 0x96F), (0x971, 0x980), (0x985, 0x98C), (0x98F, 0x990), (0x993, 0x9A8),
|
||||
(0x9AA, 0x9B0), (0x9B2, 0x9B2), (0x9B6, 0x9B9), (0x9BD, 0x9BD), (0x9CE, 0x9CE), (0x9DC, 0x9DD), (0x9DF, 0x9E1),
|
||||
(0x9E6, 0x9F1), (0x9F4, 0x9F9), (0x9FC, 0x9FC), (0xA05, 0xA0A), (0xA0F, 0xA10), (0xA13, 0xA28), (0xA2A, 0xA30),
|
||||
(0xA32, 0xA33), (0xA35, 0xA36), (0xA38, 0xA39), (0xA59, 0xA5C), (0xA5E, 0xA5E), (0xA66, 0xA6F), (0xA72, 0xA74),
|
||||
(0xA85, 0xA8D), (0xA8F, 0xA91), (0xA93, 0xAA8), (0xAAA, 0xAB0), (0xAB2, 0xAB3), (0xAB5, 0xAB9), (0xABD, 0xABD),
|
||||
(0xAD0, 0xAD0), (0xAE0, 0xAE1), (0xAE6, 0xAEF), (0xAF9, 0xAF9), (0xB05, 0xB0C), (0xB0F, 0xB10), (0xB13, 0xB28),
|
||||
(0xB2A, 0xB30), (0xB32, 0xB33), (0xB35, 0xB39), (0xB3D, 0xB3D), (0xB5C, 0xB5D), (0xB5F, 0xB61), (0xB66, 0xB6F),
|
||||
(0xB71, 0xB77), (0xB83, 0xB83), (0xB85, 0xB8A), (0xB8E, 0xB90), (0xB92, 0xB95), (0xB99, 0xB9A), (0xB9C, 0xB9C),
|
||||
(0xB9E, 0xB9F), (0xBA3, 0xBA4), (0xBA8, 0xBAA), (0xBAE, 0xBB9), (0xBD0, 0xBD0), (0xBE6, 0xBF2), (0xC05, 0xC0C),
|
||||
(0xC0E, 0xC10), (0xC12, 0xC28), (0xC2A, 0xC39), (0xC3D, 0xC3D), (0xC58, 0xC5A), (0xC5D, 0xC5D), (0xC60, 0xC61),
|
||||
(0xC66, 0xC6F), (0xC78, 0xC7E), (0xC80, 0xC80), (0xC85, 0xC8C), (0xC8E, 0xC90), (0xC92, 0xCA8), (0xCAA, 0xCB3),
|
||||
(0xCB5, 0xCB9), (0xCBD, 0xCBD), (0xCDD, 0xCDE), (0xCE0, 0xCE1), (0xCE6, 0xCEF), (0xCF1, 0xCF2), (0xD04, 0xD0C),
|
||||
(0xD0E, 0xD10), (0xD12, 0xD3A), (0xD3D, 0xD3D), (0xD4E, 0xD4E), (0xD54, 0xD56), (0xD58, 0xD61), (0xD66, 0xD78),
|
||||
(0xD7A, 0xD7F), (0xD85, 0xD96), (0xD9A, 0xDB1), (0xDB3, 0xDBB), (0xDBD, 0xDBD), (0xDC0, 0xDC6), (0xDE6, 0xDEF),
|
||||
(0xE01, 0xE30), (0xE32, 0xE33), (0xE40, 0xE46), (0xE50, 0xE59), (0xE81, 0xE82), (0xE84, 0xE84), (0xE86, 0xE8A),
|
||||
(0xE8C, 0xEA3), (0xEA5, 0xEA5), (0xEA7, 0xEB0), (0xEB2, 0xEB3), (0xEBD, 0xEBD), (0xEC0, 0xEC4), (0xEC6, 0xEC6),
|
||||
(0xED0, 0xED9), (0xEDC, 0xEDF), (0xF00, 0xF00), (0xF20, 0xF33), (0xF40, 0xF47), (0xF49, 0xF6C), (0xF88, 0xF8C),
|
||||
(0x1000, 0x102A), (0x103F, 0x1049), (0x1050, 0x1055), (0x105A, 0x105D), (0x1061, 0x1061), (0x1065, 0x1066),
|
||||
(0x106E, 0x1070), (0x1075, 0x1081), (0x108E, 0x108E), (0x1090, 0x1099), (0x10A0, 0x10C5), (0x10C7, 0x10C7),
|
||||
(0x10CD, 0x10CD), (0x10D0, 0x10FA), (0x10FC, 0x1248), (0x124A, 0x124D), (0x1250, 0x1256), (0x1258, 0x1258),
|
||||
(0x125A, 0x125D), (0x1260, 0x1288), (0x128A, 0x128D), (0x1290, 0x12B0), (0x12B2, 0x12B5), (0x12B8, 0x12BE),
|
||||
(0x12C0, 0x12C0), (0x12C2, 0x12C5), (0x12C8, 0x12D6), (0x12D8, 0x1310), (0x1312, 0x1315), (0x1318, 0x135A),
|
||||
(0x1369, 0x137C), (0x1380, 0x138F), (0x13A0, 0x13F5), (0x13F8, 0x13FD), (0x1401, 0x166C), (0x166F, 0x167F),
|
||||
(0x1681, 0x169A), (0x16A0, 0x16EA), (0x16EE, 0x16F8), (0x1700, 0x1711), (0x171F, 0x1731), (0x1740, 0x1751),
|
||||
(0x1760, 0x176C), (0x176E, 0x1770), (0x1780, 0x17B3), (0x17D7, 0x17D7), (0x17DC, 0x17DC), (0x17E0, 0x17E9),
|
||||
(0x17F0, 0x17F9), (0x1810, 0x1819), (0x1820, 0x1878), (0x1880, 0x1884), (0x1887, 0x18A8), (0x18AA, 0x18AA),
|
||||
(0x18B0, 0x18F5), (0x1900, 0x191E), (0x1946, 0x196D), (0x1970, 0x1974), (0x1980, 0x19AB), (0x19B0, 0x19C9),
|
||||
(0x19D0, 0x19DA), (0x1A00, 0x1A16), (0x1A20, 0x1A54), (0x1A80, 0x1A89), (0x1A90, 0x1A99), (0x1AA7, 0x1AA7),
|
||||
(0x1B05, 0x1B33), (0x1B45, 0x1B4C), (0x1B50, 0x1B59), (0x1B83, 0x1BA0), (0x1BAE, 0x1BE5), (0x1C00, 0x1C23),
|
||||
(0x1C40, 0x1C49), (0x1C4D, 0x1C7D), (0x1C80, 0x1C8A), (0x1C90, 0x1CBA), (0x1CBD, 0x1CBF), (0x1CE9, 0x1CEC),
|
||||
(0x1CEE, 0x1CF3), (0x1CF5, 0x1CF6), (0x1CFA, 0x1CFA), (0x1D00, 0x1DBF), (0x1E00, 0x1F15), (0x1F18, 0x1F1D),
|
||||
(0x1F20, 0x1F45), (0x1F48, 0x1F4D), (0x1F50, 0x1F57), (0x1F59, 0x1F59), (0x1F5B, 0x1F5B), (0x1F5D, 0x1F5D),
|
||||
(0x1F5F, 0x1F7D), (0x1F80, 0x1FB4), (0x1FB6, 0x1FBC), (0x1FBE, 0x1FBE), (0x1FC2, 0x1FC4), (0x1FC6, 0x1FCC),
|
||||
(0x1FD0, 0x1FD3), (0x1FD6, 0x1FDB), (0x1FE0, 0x1FEC), (0x1FF2, 0x1FF4), (0x1FF6, 0x1FFC), (0x2070, 0x2071),
|
||||
(0x2074, 0x2079), (0x207F, 0x2089), (0x2090, 0x209C), (0x2102, 0x2102), (0x2107, 0x2107), (0x210A, 0x2113),
|
||||
(0x2115, 0x2115), (0x2119, 0x211D), (0x2124, 0x2124), (0x2126, 0x2126), (0x2128, 0x2128), (0x212A, 0x212D),
|
||||
(0x212F, 0x2139), (0x213C, 0x213F), (0x2145, 0x2149), (0x214E, 0x214E), (0x2150, 0x2189), (0x2460, 0x249B),
|
||||
(0x24EA, 0x24FF), (0x2776, 0x2793), (0x2C00, 0x2CE4), (0x2CEB, 0x2CEE), (0x2CF2, 0x2CF3), (0x2CFD, 0x2CFD),
|
||||
(0x2D00, 0x2D25), (0x2D27, 0x2D27), (0x2D2D, 0x2D2D), (0x2D30, 0x2D67), (0x2D6F, 0x2D6F), (0x2D80, 0x2D96),
|
||||
(0x2DA0, 0x2DA6), (0x2DA8, 0x2DAE), (0x2DB0, 0x2DB6), (0x2DB8, 0x2DBE), (0x2DC0, 0x2DC6), (0x2DC8, 0x2DCE),
|
||||
(0x2DD0, 0x2DD6), (0x2DD8, 0x2DDE), (0x2E2F, 0x2E2F), (0x3005, 0x3007), (0x3021, 0x3029), (0x3031, 0x3035),
|
||||
(0x3038, 0x303C), (0x3041, 0x3096), (0x309D, 0x309F), (0x30A1, 0x30FA), (0x30FC, 0x30FF), (0x3105, 0x312F),
|
||||
(0x3131, 0x318E), (0x3192, 0x3195), (0x31A0, 0x31BF), (0x31F0, 0x31FF), (0x3220, 0x3229), (0x3248, 0x324F),
|
||||
(0x3251, 0x325F), (0x3280, 0x3289), (0x32B1, 0x32BF), (0x3400, 0x4DBF), (0x4E00, 0xA48C), (0xA4D0, 0xA4FD),
|
||||
(0xA500, 0xA60C), (0xA610, 0xA62B), (0xA640, 0xA66E), (0xA67F, 0xA69D), (0xA6A0, 0xA6EF), (0xA717, 0xA71F),
|
||||
(0xA722, 0xA788), (0xA78B, 0xA7CD), (0xA7D0, 0xA7D1), (0xA7D3, 0xA7D3), (0xA7D5, 0xA7DC), (0xA7F2, 0xA801),
|
||||
(0xA803, 0xA805), (0xA807, 0xA80A), (0xA80C, 0xA822), (0xA830, 0xA835), (0xA840, 0xA873), (0xA882, 0xA8B3),
|
||||
(0xA8D0, 0xA8D9), (0xA8F2, 0xA8F7), (0xA8FB, 0xA8FB), (0xA8FD, 0xA8FE), (0xA900, 0xA925), (0xA930, 0xA946),
|
||||
(0xA960, 0xA97C), (0xA984, 0xA9B2), (0xA9CF, 0xA9D9), (0xA9E0, 0xA9E4), (0xA9E6, 0xA9FE), (0xAA00, 0xAA28),
|
||||
(0xAA40, 0xAA42), (0xAA44, 0xAA4B), (0xAA50, 0xAA59), (0xAA60, 0xAA76), (0xAA7A, 0xAA7A), (0xAA7E, 0xAAAF),
|
||||
(0xAAB1, 0xAAB1), (0xAAB5, 0xAAB6), (0xAAB9, 0xAABD), (0xAAC0, 0xAAC0), (0xAAC2, 0xAAC2), (0xAADB, 0xAADD),
|
||||
(0xAAE0, 0xAAEA), (0xAAF2, 0xAAF4), (0xAB01, 0xAB06), (0xAB09, 0xAB0E), (0xAB11, 0xAB16), (0xAB20, 0xAB26),
|
||||
(0xAB28, 0xAB2E), (0xAB30, 0xAB5A), (0xAB5C, 0xAB69), (0xAB70, 0xABE2), (0xABF0, 0xABF9), (0xAC00, 0xD7A3),
|
||||
(0xD7B0, 0xD7C6), (0xD7CB, 0xD7FB), (0xF900, 0xFA6D), (0xFA70, 0xFAD9), (0xFB00, 0xFB06), (0xFB13, 0xFB17),
|
||||
(0xFB1D, 0xFB1D), (0xFB1F, 0xFB28), (0xFB2A, 0xFB36), (0xFB38, 0xFB3C), (0xFB3E, 0xFB3E), (0xFB40, 0xFB41),
|
||||
(0xFB43, 0xFB44), (0xFB46, 0xFBB1), (0xFBD3, 0xFD3D), (0xFD50, 0xFD8F), (0xFD92, 0xFDC7), (0xFDF0, 0xFDFB),
|
||||
(0xFE70, 0xFE74), (0xFE76, 0xFEFC), (0xFF10, 0xFF19), (0xFF21, 0xFF3A), (0xFF41, 0xFF5A), (0xFF66, 0xFFBE),
|
||||
(0xFFC2, 0xFFC7), (0xFFCA, 0xFFCF), (0xFFD2, 0xFFD7), (0xFFDA, 0xFFDC), (0x10000, 0x1000B), (0x1000D, 0x10026),
|
||||
(0x10028, 0x1003A), (0x1003C, 0x1003D), (0x1003F, 0x1004D), (0x10050, 0x1005D), (0x10080, 0x100FA),
|
||||
(0x10107, 0x10133), (0x10140, 0x10178), (0x1018A, 0x1018B), (0x10280, 0x1029C), (0x102A0, 0x102D0),
|
||||
(0x102E1, 0x102FB), (0x10300, 0x10323), (0x1032D, 0x1034A), (0x10350, 0x10375), (0x10380, 0x1039D),
|
||||
(0x103A0, 0x103C3), (0x103C8, 0x103CF), (0x103D1, 0x103D5), (0x10400, 0x1049D), (0x104A0, 0x104A9),
|
||||
(0x104B0, 0x104D3), (0x104D8, 0x104FB), (0x10500, 0x10527), (0x10530, 0x10563), (0x10570, 0x1057A),
|
||||
(0x1057C, 0x1058A), (0x1058C, 0x10592), (0x10594, 0x10595), (0x10597, 0x105A1), (0x105A3, 0x105B1),
|
||||
(0x105B3, 0x105B9), (0x105BB, 0x105BC), (0x105C0, 0x105F3), (0x10600, 0x10736), (0x10740, 0x10755),
|
||||
(0x10760, 0x10767), (0x10780, 0x10785), (0x10787, 0x107B0), (0x107B2, 0x107BA), (0x10800, 0x10805),
|
||||
(0x10808, 0x10808), (0x1080A, 0x10835), (0x10837, 0x10838), (0x1083C, 0x1083C), (0x1083F, 0x10855),
|
||||
(0x10858, 0x10876), (0x10879, 0x1089E), (0x108A7, 0x108AF), (0x108E0, 0x108F2), (0x108F4, 0x108F5),
|
||||
(0x108FB, 0x1091B), (0x10920, 0x10939), (0x10980, 0x109B7), (0x109BC, 0x109CF), (0x109D2, 0x10A00),
|
||||
(0x10A10, 0x10A13), (0x10A15, 0x10A17), (0x10A19, 0x10A35), (0x10A40, 0x10A48), (0x10A60, 0x10A7E),
|
||||
(0x10A80, 0x10A9F), (0x10AC0, 0x10AC7), (0x10AC9, 0x10AE4), (0x10AEB, 0x10AEF), (0x10B00, 0x10B35),
|
||||
(0x10B40, 0x10B55), (0x10B58, 0x10B72), (0x10B78, 0x10B91), (0x10BA9, 0x10BAF), (0x10C00, 0x10C48),
|
||||
(0x10C80, 0x10CB2), (0x10CC0, 0x10CF2), (0x10CFA, 0x10D23), (0x10D30, 0x10D39), (0x10D40, 0x10D65),
|
||||
(0x10D6F, 0x10D85), (0x10E60, 0x10E7E), (0x10E80, 0x10EA9), (0x10EB0, 0x10EB1), (0x10EC2, 0x10EC4),
|
||||
(0x10F00, 0x10F27), (0x10F30, 0x10F45), (0x10F51, 0x10F54), (0x10F70, 0x10F81), (0x10FB0, 0x10FCB),
|
||||
(0x10FE0, 0x10FF6), (0x11003, 0x11037), (0x11052, 0x1106F), (0x11071, 0x11072), (0x11075, 0x11075),
|
||||
(0x11083, 0x110AF), (0x110D0, 0x110E8), (0x110F0, 0x110F9), (0x11103, 0x11126), (0x11136, 0x1113F),
|
||||
(0x11144, 0x11144), (0x11147, 0x11147), (0x11150, 0x11172), (0x11176, 0x11176), (0x11183, 0x111B2),
|
||||
(0x111C1, 0x111C4), (0x111D0, 0x111DA), (0x111DC, 0x111DC), (0x111E1, 0x111F4), (0x11200, 0x11211),
|
||||
(0x11213, 0x1122B), (0x1123F, 0x11240), (0x11280, 0x11286), (0x11288, 0x11288), (0x1128A, 0x1128D),
|
||||
(0x1128F, 0x1129D), (0x1129F, 0x112A8), (0x112B0, 0x112DE), (0x112F0, 0x112F9), (0x11305, 0x1130C),
|
||||
(0x1130F, 0x11310), (0x11313, 0x11328), (0x1132A, 0x11330), (0x11332, 0x11333), (0x11335, 0x11339),
|
||||
(0x1133D, 0x1133D), (0x11350, 0x11350), (0x1135D, 0x11361), (0x11380, 0x11389), (0x1138B, 0x1138B),
|
||||
(0x1138E, 0x1138E), (0x11390, 0x113B5), (0x113B7, 0x113B7), (0x113D1, 0x113D1), (0x113D3, 0x113D3),
|
||||
(0x11400, 0x11434), (0x11447, 0x1144A), (0x11450, 0x11459), (0x1145F, 0x11461), (0x11480, 0x114AF),
|
||||
(0x114C4, 0x114C5), (0x114C7, 0x114C7), (0x114D0, 0x114D9), (0x11580, 0x115AE), (0x115D8, 0x115DB),
|
||||
(0x11600, 0x1162F), (0x11644, 0x11644), (0x11650, 0x11659), (0x11680, 0x116AA), (0x116B8, 0x116B8),
|
||||
(0x116C0, 0x116C9), (0x116D0, 0x116E3), (0x11700, 0x1171A), (0x11730, 0x1173B), (0x11740, 0x11746),
|
||||
(0x11800, 0x1182B), (0x118A0, 0x118F2), (0x118FF, 0x11906), (0x11909, 0x11909), (0x1190C, 0x11913),
|
||||
(0x11915, 0x11916), (0x11918, 0x1192F), (0x1193F, 0x1193F), (0x11941, 0x11941), (0x11950, 0x11959),
|
||||
(0x119A0, 0x119A7), (0x119AA, 0x119D0), (0x119E1, 0x119E1), (0x119E3, 0x119E3), (0x11A00, 0x11A00),
|
||||
(0x11A0B, 0x11A32), (0x11A3A, 0x11A3A), (0x11A50, 0x11A50), (0x11A5C, 0x11A89), (0x11A9D, 0x11A9D),
|
||||
(0x11AB0, 0x11AF8), (0x11BC0, 0x11BE0), (0x11BF0, 0x11BF9), (0x11C00, 0x11C08), (0x11C0A, 0x11C2E),
|
||||
(0x11C40, 0x11C40), (0x11C50, 0x11C6C), (0x11C72, 0x11C8F), (0x11D00, 0x11D06), (0x11D08, 0x11D09),
|
||||
(0x11D0B, 0x11D30), (0x11D46, 0x11D46), (0x11D50, 0x11D59), (0x11D60, 0x11D65), (0x11D67, 0x11D68),
|
||||
(0x11D6A, 0x11D89), (0x11D98, 0x11D98), (0x11DA0, 0x11DA9), (0x11EE0, 0x11EF2), (0x11F02, 0x11F02),
|
||||
(0x11F04, 0x11F10), (0x11F12, 0x11F33), (0x11F50, 0x11F59), (0x11FB0, 0x11FB0), (0x11FC0, 0x11FD4),
|
||||
(0x12000, 0x12399), (0x12400, 0x1246E), (0x12480, 0x12543), (0x12F90, 0x12FF0), (0x13000, 0x1342F),
|
||||
(0x13441, 0x13446), (0x13460, 0x143FA), (0x14400, 0x14646), (0x16100, 0x1611D), (0x16130, 0x16139),
|
||||
(0x16800, 0x16A38), (0x16A40, 0x16A5E), (0x16A60, 0x16A69), (0x16A70, 0x16ABE), (0x16AC0, 0x16AC9),
|
||||
(0x16AD0, 0x16AED), (0x16B00, 0x16B2F), (0x16B40, 0x16B43), (0x16B50, 0x16B59), (0x16B5B, 0x16B61),
|
||||
(0x16B63, 0x16B77), (0x16B7D, 0x16B8F), (0x16D40, 0x16D6C), (0x16D70, 0x16D79), (0x16E40, 0x16E96),
|
||||
(0x16F00, 0x16F4A), (0x16F50, 0x16F50), (0x16F93, 0x16F9F), (0x16FE0, 0x16FE1), (0x16FE3, 0x16FE3),
|
||||
(0x17000, 0x187F7), (0x18800, 0x18CD5), (0x18CFF, 0x18D08), (0x1AFF0, 0x1AFF3), (0x1AFF5, 0x1AFFB),
|
||||
(0x1AFFD, 0x1AFFE), (0x1B000, 0x1B122), (0x1B132, 0x1B132), (0x1B150, 0x1B152), (0x1B155, 0x1B155),
|
||||
(0x1B164, 0x1B167), (0x1B170, 0x1B2FB), (0x1BC00, 0x1BC6A), (0x1BC70, 0x1BC7C), (0x1BC80, 0x1BC88),
|
||||
(0x1BC90, 0x1BC99), (0x1CCF0, 0x1CCF9), (0x1D2C0, 0x1D2D3), (0x1D2E0, 0x1D2F3), (0x1D360, 0x1D378),
|
||||
(0x1D400, 0x1D454), (0x1D456, 0x1D49C), (0x1D49E, 0x1D49F), (0x1D4A2, 0x1D4A2), (0x1D4A5, 0x1D4A6),
|
||||
(0x1D4A9, 0x1D4AC), (0x1D4AE, 0x1D4B9), (0x1D4BB, 0x1D4BB), (0x1D4BD, 0x1D4C3), (0x1D4C5, 0x1D505),
|
||||
(0x1D507, 0x1D50A), (0x1D50D, 0x1D514), (0x1D516, 0x1D51C), (0x1D51E, 0x1D539), (0x1D53B, 0x1D53E),
|
||||
(0x1D540, 0x1D544), (0x1D546, 0x1D546), (0x1D54A, 0x1D550), (0x1D552, 0x1D6A5), (0x1D6A8, 0x1D6C0),
|
||||
(0x1D6C2, 0x1D6DA), (0x1D6DC, 0x1D6FA), (0x1D6FC, 0x1D714), (0x1D716, 0x1D734), (0x1D736, 0x1D74E),
|
||||
(0x1D750, 0x1D76E), (0x1D770, 0x1D788), (0x1D78A, 0x1D7A8), (0x1D7AA, 0x1D7C2), (0x1D7C4, 0x1D7CB),
|
||||
(0x1D7CE, 0x1D7FF), (0x1DF00, 0x1DF1E), (0x1DF25, 0x1DF2A), (0x1E030, 0x1E06D), (0x1E100, 0x1E12C),
|
||||
(0x1E137, 0x1E13D), (0x1E140, 0x1E149), (0x1E14E, 0x1E14E), (0x1E290, 0x1E2AD), (0x1E2C0, 0x1E2EB),
|
||||
(0x1E2F0, 0x1E2F9), (0x1E4D0, 0x1E4EB), (0x1E4F0, 0x1E4F9), (0x1E5D0, 0x1E5ED), (0x1E5F0, 0x1E5FA),
|
||||
(0x1E7E0, 0x1E7E6), (0x1E7E8, 0x1E7EB), (0x1E7ED, 0x1E7EE), (0x1E7F0, 0x1E7FE), (0x1E800, 0x1E8C4),
|
||||
(0x1E8C7, 0x1E8CF), (0x1E900, 0x1E943), (0x1E94B, 0x1E94B), (0x1E950, 0x1E959), (0x1EC71, 0x1ECAB),
|
||||
(0x1ECAD, 0x1ECAF), (0x1ECB1, 0x1ECB4), (0x1ED01, 0x1ED2D), (0x1ED2F, 0x1ED3D), (0x1EE00, 0x1EE03),
|
||||
(0x1EE05, 0x1EE1F), (0x1EE21, 0x1EE22), (0x1EE24, 0x1EE24), (0x1EE27, 0x1EE27), (0x1EE29, 0x1EE32),
|
||||
(0x1EE34, 0x1EE37), (0x1EE39, 0x1EE39), (0x1EE3B, 0x1EE3B), (0x1EE42, 0x1EE42), (0x1EE47, 0x1EE47),
|
||||
(0x1EE49, 0x1EE49), (0x1EE4B, 0x1EE4B), (0x1EE4D, 0x1EE4F), (0x1EE51, 0x1EE52), (0x1EE54, 0x1EE54),
|
||||
(0x1EE57, 0x1EE57), (0x1EE59, 0x1EE59), (0x1EE5B, 0x1EE5B), (0x1EE5D, 0x1EE5D), (0x1EE5F, 0x1EE5F),
|
||||
(0x1EE61, 0x1EE62), (0x1EE64, 0x1EE64), (0x1EE67, 0x1EE6A), (0x1EE6C, 0x1EE72), (0x1EE74, 0x1EE77),
|
||||
(0x1EE79, 0x1EE7C), (0x1EE7E, 0x1EE7E), (0x1EE80, 0x1EE89), (0x1EE8B, 0x1EE9B), (0x1EEA1, 0x1EEA3),
|
||||
(0x1EEA5, 0x1EEA9), (0x1EEAB, 0x1EEBB), (0x1F100, 0x1F10C), (0x1FBF0, 0x1FBF9), (0x20000, 0x2A6DF),
|
||||
(0x2A700, 0x2B739), (0x2B740, 0x2B81D), (0x2B820, 0x2CEA1), (0x2CEB0, 0x2EBE0), (0x2EBF0, 0x2EE5D),
|
||||
(0x2F800, 0x2FA1D), (0x30000, 0x3134A), (0x31350, 0x323AF),
|
||||
];
|
||||
|
||||
pub const DEFAULT_IGNORABLE: &[(u32, u32)] = &[
|
||||
(0xAD, 0xAD), (0x34F, 0x34F), (0x61C, 0x61C), (0x115F, 0x1160), (0x17B4, 0x17B5), (0x180B, 0x180F),
|
||||
(0x200B, 0x200F), (0x202A, 0x202E), (0x2060, 0x206F), (0x3164, 0x3164), (0xFE00, 0xFE0F), (0xFEFF, 0xFEFF),
|
||||
(0xFFA0, 0xFFA0), (0xFFF0, 0xFFF8), (0x1BCA0, 0x1BCA3), (0x1D173, 0x1D17A), (0xE0000, 0xE0FFF),
|
||||
];
|
||||
43
packages/cultnet-rs/tests/database_change_commit_vector.rs
Normal file
43
packages/cultnet-rs/tests/database_change_commit_vector.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//! `tests/vectors/database-change-commit/two-change-commit.msgpack` is written only by the C# reference: two
|
||||
//! `cultnet.database_change_raw.v0` messages of one commit, every key present, nil where unset. cultnet-rs has no typed
|
||||
//! `database_change_raw` message yet, so this walks the stream with the same `rmpv` values its wire decoder builds on and
|
||||
//! pins the keys and values a Rust adopter will read. A rename of `commitId` or `commitCount` in the C# writer fails here.
|
||||
|
||||
use rmpv::Value;
|
||||
use std::io::Cursor;
|
||||
|
||||
fn messages() -> Vec<Value> {
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/vectors/database-change-commit/two-change-commit.msgpack");
|
||||
let bytes = std::fs::read(path).expect("the vector is committed");
|
||||
let mut cursor = Cursor::new(bytes.as_slice());
|
||||
let mut out = Vec::new();
|
||||
while (cursor.position() as usize) < bytes.len() {
|
||||
out.push(rmpv::decode::read_value(&mut cursor).expect("a msgpack value"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn field<'a>(message: &'a Value, key: &str) -> &'a Value {
|
||||
message
|
||||
.as_map()
|
||||
.and_then(|entries| entries.iter().find(|(k, _)| k.as_str() == Some(key)))
|
||||
.map(|(_, v)| v)
|
||||
.unwrap_or_else(|| panic!("the C# writer wrote no key {key}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_two_change_commit_written_by_csharp_carries_its_commit_id_count_and_changes() {
|
||||
let messages = messages();
|
||||
assert_eq!(messages.len(), 2);
|
||||
for message in &messages {
|
||||
assert_eq!(field(message, "schemaVersion").as_str(), Some("cultnet.database_change_raw.v0"));
|
||||
assert_eq!(field(message, "commitId").as_str(), Some("9f1c0f0e4b7a4c1f9d2e"));
|
||||
assert_eq!(field(message, "commitCount").as_u64(), Some(2));
|
||||
assert_eq!(field(message, "changeKind").as_str(), Some("removed"));
|
||||
assert_eq!(field(message, "subscriptionId").as_str(), Some("sub-1"));
|
||||
assert_eq!(field(message, "schemaId").as_str(), Some("schema-note"));
|
||||
assert!(field(message, "document").is_nil(), "a removed change's document is nil");
|
||||
}
|
||||
let keys: Vec<_> = messages.iter().map(|m| field(m, "recordKey").as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(keys, ["note:a", "note:b"]);
|
||||
}
|
||||
|
|
@ -153,3 +153,103 @@ fn error_v0_refuses_the_v1_only_limit_reached_code() {
|
|||
as_v0[at + 15] = b'0';
|
||||
assert!(decode_cultnet_message_from_slice(&as_v0, CultNetWireContract::CultNetSchemaV0).is_err());
|
||||
}
|
||||
|
||||
/// Vectors `admittedMessageId`: a write_admitted messageId needs a letter or number and no Default_Ignorable code
|
||||
/// point, compared raw (never trimmed). The frame is the spec's `write_admitted_put` bytes with the id swapped in; a
|
||||
/// case with `wireHex` (the lone surrogate, invalid UTF-8) supplies the whole frame.
|
||||
#[test]
|
||||
fn write_admitted_message_id_vectors_are_refused_or_accepted_raw() {
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../contracts/cultnet/interop/write-receipt-vectors.json");
|
||||
let vectors: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
|
||||
let cases = vectors["admittedMessageId"]["cases"].as_array().unwrap();
|
||||
assert_eq!(vectors["counts"]["admittedMessageIdCases"].as_u64().unwrap() as usize, cases.len());
|
||||
let template = unhex("85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31");
|
||||
let old_id_at = template.windows(5).position(|w| w == b"put-1").unwrap() - 1;
|
||||
let mut failures = Vec::new();
|
||||
for case in cases {
|
||||
let name = case["name"].as_str().unwrap();
|
||||
let id: String = case["codePoints"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|cp| char::from_u32(u32::from_str_radix(cp.as_str().unwrap(), 16).unwrap()))
|
||||
.collect::<String>()
|
||||
.repeat(case["repeat"].as_u64().unwrap_or(1) as usize);
|
||||
let frame = match case["wireHex"].as_str() {
|
||||
Some(hex) => unhex(hex),
|
||||
None => {
|
||||
let len = id.len();
|
||||
let header: Vec<u8> = if len < 32 {
|
||||
vec![0xA0 | len as u8]
|
||||
} else if len < 256 {
|
||||
vec![0xD9, len as u8]
|
||||
} else {
|
||||
vec![0xDA, (len >> 8) as u8, len as u8]
|
||||
};
|
||||
[&template[..old_id_at], &header[..], id.as_bytes(), &template[old_id_at + 6..]].concat()
|
||||
}
|
||||
};
|
||||
let decoded = decode_cultnet_message_from_slice(&frame, CultNetWireContract::CultNetSchemaV0);
|
||||
match (case["expect"].as_str().unwrap(), decoded) {
|
||||
("refuse", Ok(_)) => failures.push(format!("{name}: expected refuse")),
|
||||
("accept", Err(error)) => failures.push(format!("{name}: expected accept, got {error}")),
|
||||
("accept", Ok(CultNetMessage::WriteAdmitted { message_id, .. })) if message_id != id => {
|
||||
failures.push(format!("{name}: id not echoed raw"))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} failing:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// The generated ranges file is the independent copy of the embedded tables: first, last and the points either side of every range.
|
||||
#[test]
|
||||
fn every_range_of_the_unicode_tables_is_pinned_at_both_ends() {
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../contracts/cultnet/interop/write-admitted-message-id-ranges.json");
|
||||
let ranges: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
|
||||
let letters = ranges["letterOrNumber"].as_array().unwrap();
|
||||
let ignorable = ranges["defaultIgnorable"].as_array().unwrap();
|
||||
assert!(letters.len() > 700);
|
||||
assert_eq!(ignorable.len(), 17);
|
||||
let template = unhex("85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31");
|
||||
let old_id_at = template.windows(5).position(|w| w == b"put-1").unwrap() - 1;
|
||||
let accepted = |id: &str| {
|
||||
let header = vec![0xA0 | id.len() as u8];
|
||||
let frame = [&template[..old_id_at], &header[..], id.as_bytes(), &template[old_id_at + 6..]].concat();
|
||||
decode_cultnet_message_from_slice(&frame, CultNetWireContract::CultNetSchemaV0).is_ok()
|
||||
};
|
||||
let point = |value: i64| char::from_u32(value as u32).unwrap().to_string();
|
||||
let mut failures = Vec::new();
|
||||
for range in letters {
|
||||
let (low, high) = (range[0].as_i64().unwrap(), range[1].as_i64().unwrap());
|
||||
for (at, want) in [(low, true), (high, true), (low - 1, false), (high + 1, false)] {
|
||||
if accepted(&point(at)) != want {
|
||||
failures.push(format!("U+{at:X} alone should be {}", if want { "accepted" } else { "refused" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
for range in ignorable {
|
||||
let (low, high) = (range[0].as_i64().unwrap(), range[1].as_i64().unwrap());
|
||||
for (at, want) in [(low, false), (high, false), (low - 1, true), (high + 1, true)] {
|
||||
if accepted(&format!("a{}", point(at))) != want {
|
||||
failures.push(format!("a + U+{at:X} should be {}", if want { "accepted" } else { "refused" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} failing:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Vectors `admittedMessageIdDuplicates`: a repeated messageId key is refused whatever the values. This pins the outcome, not the
|
||||
/// cause (today serde's `duplicate field messageId` in the typed decode of write_admitted).
|
||||
#[test]
|
||||
fn a_repeated_message_id_key_is_refused_whatever_the_values() {
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../contracts/cultnet/interop/write-receipt-vectors.json");
|
||||
let vectors: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
|
||||
let cases = vectors["admittedMessageIdDuplicates"]["cases"].as_array().unwrap();
|
||||
assert_eq!(vectors["counts"]["admittedMessageIdDuplicateCases"].as_u64().unwrap() as usize, cases.len());
|
||||
for case in cases {
|
||||
assert_eq!(case["seesDuplicate"], "refuse");
|
||||
let decoded = decode_cultnet_message_from_slice(&unhex(case["wireHex"].as_str().unwrap()), CultNetWireContract::CultNetSchemaV0);
|
||||
assert!(decoded.is_err(), "{}: a repeated messageId key must be refused", case["name"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ Current message families:
|
|||
- `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.database_change_raw.v0` (optional `commitId` and `commitCount >= 2` mark the changes of one commit; carried and validated here, no TS code groups or applies by them)
|
||||
- `cultnet.sample.change_name.v0`
|
||||
- `cultnet.sample.chat.v0`
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import verifySchema from "../contracts/cultnet/cultnet.verify.schema.json";
|
|||
import loginSuccessSchema from "../contracts/cultnet/cultnet.login-success.schema.json";
|
||||
import errorSchema from "../contracts/cultnet/cultnet.error.schema.json";
|
||||
import errorV1Schema from "../contracts/cultnet/interop/cultnet.error.v1.schema.json";
|
||||
import { DEFAULT_IGNORABLE, LETTER_OR_NUMBER } from "./unicode-messageid-tables";
|
||||
import writeAdmittedSchema from "../contracts/cultnet/interop/cultnet.write-admitted.schema.json";
|
||||
import sampleChangeNameSchema from "../contracts/cultnet/cultnet.sample-change-name.schema.json";
|
||||
import sampleChatSchema from "../contracts/cultnet/cultnet.sample-chat.schema.json";
|
||||
|
|
@ -423,6 +424,10 @@ export interface CultNetDatabaseChangeRawMessage {
|
|||
document?: CultNetRawDocumentRecord;
|
||||
recordKey?: string;
|
||||
schemaId?: string;
|
||||
/** Opaque, one subscription: changes sharing a commitId are the changes of one commit. */
|
||||
commitId?: string | null;
|
||||
/** How many changes the commit carries, at least two. The C# server sends null on a change outside a grouped commit. */
|
||||
commitCount?: number | null;
|
||||
}
|
||||
|
||||
export interface CultNetSchemaDescriptor {
|
||||
|
|
@ -950,6 +955,10 @@ export function parseCultNetMessage(input: unknown, wireContract: CultNetWireCon
|
|||
return input as CultNetDatabaseChangeRawMessage;
|
||||
}
|
||||
|
||||
if (schemaVersion === "cultnet.write_admitted.v0" && !isValidAdmittedMessageId((input as { messageId?: unknown }).messageId)) {
|
||||
throw new Error("Validation failed for cultnet.write_admitted.v0: /messageId: must contain a letter or number and no Default_Ignorable code point");
|
||||
}
|
||||
|
||||
if (!validator(input)) {
|
||||
const keys = Object.keys(input as Record<string, unknown>).join(",");
|
||||
throw new Error(`${renderValidationErrors(schemaVersion, validator)}; keys=${keys}`);
|
||||
|
|
@ -1392,6 +1401,15 @@ function validateDatabaseChangeRawMessage(
|
|||
);
|
||||
}
|
||||
|
||||
if (candidate.commitId !== undefined && candidate.commitId !== null &&
|
||||
(typeof candidate.commitId !== "string" || candidate.commitId.length === 0)) {
|
||||
throw new Error("Validation failed for cultnet.database_change_raw.v0: /commitId: must be a non-empty string");
|
||||
}
|
||||
if (candidate.commitCount !== undefined && candidate.commitCount !== null &&
|
||||
(typeof candidate.commitCount !== "number" || !Number.isInteger(candidate.commitCount) || candidate.commitCount < 2)) {
|
||||
throw new Error("Validation failed for cultnet.database_change_raw.v0: /commitCount: must be an integer of at least 2");
|
||||
}
|
||||
|
||||
if (candidate.changeKind === "removed") {
|
||||
requireNonEmptyString(candidate.recordKey, "/recordKey");
|
||||
requireNonEmptyString(candidate.schemaId, "/schemaId");
|
||||
|
|
@ -1460,6 +1478,45 @@ function validateRawDocumentRecord(
|
|||
requireOptionalStringArray(candidate.tags, `${instancePath}/tags`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A write_admitted messageId is valid iff it holds a Unicode letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) and no
|
||||
* Default_Ignorable_Code_Point, as of Unicode 16.0, compared raw (write-receipt.md; the id is echoed byte for byte, so nothing
|
||||
* is trimmed). The tables are embedded (unicode-messageid-tables.ts, generated by scripts/gen-unicode-messageid-tables.py), not
|
||||
* the regex engine's, so the running Node's Unicode version changes no result; the other four runtimes embed the same tables.
|
||||
*/
|
||||
function isValidAdmittedMessageId(value: unknown): boolean {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
let hasLetterOrNumber = false;
|
||||
for (const ch of value) {
|
||||
const point = ch.codePointAt(0) as number;
|
||||
// The decoded string must be well-formed Unicode: @msgpack/msgpack turns invalid UTF-8 (ED A0 80) into a lone surrogate.
|
||||
if ((point >= 0xd800 && point <= 0xdfff) || inRanges(DEFAULT_IGNORABLE, point)) {
|
||||
return false;
|
||||
}
|
||||
hasLetterOrNumber = hasLetterOrNumber || inRanges(LETTER_OR_NUMBER, point);
|
||||
}
|
||||
return hasLetterOrNumber;
|
||||
}
|
||||
|
||||
/** Whether [point] lies in the flattened, sorted [low, high] pairs. */
|
||||
function inRanges(ranges: readonly number[], point: number): boolean {
|
||||
let low = 0;
|
||||
let high = ranges.length / 2 - 1;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (point < ranges[2 * mid]) {
|
||||
high = mid - 1;
|
||||
} else if (point > ranges[2 * mid + 1]) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Blank is empty or only Unicode White_Space (write-receipt.md, rule 5), not `trim()`: JS trims U+FEFF and keeps U+0085. */
|
||||
function isBlank(value: string): boolean {
|
||||
return /^\p{White_Space}*$/u.test(value);
|
||||
|
|
|
|||
126
packages/cultnet-ts/src/unicode-messageid-tables.ts
Normal file
126
packages/cultnet-ts/src/unicode-messageid-tables.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
|
||||
|
||||
// [low, high] pairs, sorted.
|
||||
export const LETTER_OR_NUMBER: readonly number[] = [
|
||||
0x30, 0x39, 0x41, 0x5A, 0x61, 0x7A, 0xAA, 0xAA, 0xB2, 0xB3, 0xB5, 0xB5, 0xB9, 0xBA, 0xBC, 0xBE, 0xC0, 0xD6, 0xD8,
|
||||
0xF6, 0xF8, 0x2C1, 0x2C6, 0x2D1, 0x2E0, 0x2E4, 0x2EC, 0x2EC, 0x2EE, 0x2EE, 0x370, 0x374, 0x376, 0x377, 0x37A,
|
||||
0x37D, 0x37F, 0x37F, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3F5, 0x3F7, 0x481, 0x48A,
|
||||
0x52F, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x5D0, 0x5EA, 0x5EF, 0x5F2, 0x620, 0x64A, 0x660, 0x669, 0x66E,
|
||||
0x66F, 0x671, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x6EE, 0x6FC, 0x6FF, 0x6FF, 0x710, 0x710, 0x712, 0x72F, 0x74D,
|
||||
0x7A5, 0x7B1, 0x7B1, 0x7C0, 0x7EA, 0x7F4, 0x7F5, 0x7FA, 0x7FA, 0x800, 0x815, 0x81A, 0x81A, 0x824, 0x824, 0x828,
|
||||
0x828, 0x840, 0x858, 0x860, 0x86A, 0x870, 0x887, 0x889, 0x88E, 0x8A0, 0x8C9, 0x904, 0x939, 0x93D, 0x93D, 0x950,
|
||||
0x950, 0x958, 0x961, 0x966, 0x96F, 0x971, 0x980, 0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2,
|
||||
0x9B2, 0x9B6, 0x9B9, 0x9BD, 0x9BD, 0x9CE, 0x9CE, 0x9DC, 0x9DD, 0x9DF, 0x9E1, 0x9E6, 0x9F1, 0x9F4, 0x9F9, 0x9FC,
|
||||
0x9FC, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59,
|
||||
0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA6F, 0xA72, 0xA74, 0xA85, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2,
|
||||
0xAB3, 0xAB5, 0xAB9, 0xABD, 0xABD, 0xAD0, 0xAD0, 0xAE0, 0xAE1, 0xAE6, 0xAEF, 0xAF9, 0xAF9, 0xB05, 0xB0C, 0xB0F,
|
||||
0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB35, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66,
|
||||
0xB6F, 0xB71, 0xB77, 0xB83, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E,
|
||||
0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB9, 0xBD0, 0xBD0, 0xBE6, 0xBF2, 0xC05, 0xC0C, 0xC0E, 0xC10, 0xC12,
|
||||
0xC28, 0xC2A, 0xC39, 0xC3D, 0xC3D, 0xC58, 0xC5A, 0xC5D, 0xC5D, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC78, 0xC7E, 0xC80,
|
||||
0xC80, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBD, 0xCBD, 0xCDD, 0xCDE, 0xCE0,
|
||||
0xCE1, 0xCE6, 0xCEF, 0xCF1, 0xCF2, 0xD04, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD3A, 0xD3D, 0xD3D, 0xD4E, 0xD4E, 0xD54,
|
||||
0xD56, 0xD58, 0xD61, 0xD66, 0xD78, 0xD7A, 0xD7F, 0xD85, 0xD96, 0xD9A, 0xDB1, 0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0,
|
||||
0xDC6, 0xDE6, 0xDEF, 0xE01, 0xE30, 0xE32, 0xE33, 0xE40, 0xE46, 0xE50, 0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE86,
|
||||
0xE8A, 0xE8C, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xED0,
|
||||
0xED9, 0xEDC, 0xEDF, 0xF00, 0xF00, 0xF20, 0xF33, 0xF40, 0xF47, 0xF49, 0xF6C, 0xF88, 0xF8C, 0x1000, 0x102A, 0x103F,
|
||||
0x1049, 0x1050, 0x1055, 0x105A, 0x105D, 0x1061, 0x1061, 0x1065, 0x1066, 0x106E, 0x1070, 0x1075, 0x1081, 0x108E,
|
||||
0x108E, 0x1090, 0x1099, 0x10A0, 0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FC, 0x1248, 0x124A,
|
||||
0x124D, 0x1250, 0x1256, 0x1258, 0x1258, 0x125A, 0x125D, 0x1260, 0x1288, 0x128A, 0x128D, 0x1290, 0x12B0, 0x12B2,
|
||||
0x12B5, 0x12B8, 0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12D6, 0x12D8, 0x1310, 0x1312, 0x1315, 0x1318,
|
||||
0x135A, 0x1369, 0x137C, 0x1380, 0x138F, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1401, 0x166C, 0x166F, 0x167F, 0x1681,
|
||||
0x169A, 0x16A0, 0x16EA, 0x16EE, 0x16F8, 0x1700, 0x1711, 0x171F, 0x1731, 0x1740, 0x1751, 0x1760, 0x176C, 0x176E,
|
||||
0x1770, 0x1780, 0x17B3, 0x17D7, 0x17D7, 0x17DC, 0x17DC, 0x17E0, 0x17E9, 0x17F0, 0x17F9, 0x1810, 0x1819, 0x1820,
|
||||
0x1878, 0x1880, 0x1884, 0x1887, 0x18A8, 0x18AA, 0x18AA, 0x18B0, 0x18F5, 0x1900, 0x191E, 0x1946, 0x196D, 0x1970,
|
||||
0x1974, 0x1980, 0x19AB, 0x19B0, 0x19C9, 0x19D0, 0x19DA, 0x1A00, 0x1A16, 0x1A20, 0x1A54, 0x1A80, 0x1A89, 0x1A90,
|
||||
0x1A99, 0x1AA7, 0x1AA7, 0x1B05, 0x1B33, 0x1B45, 0x1B4C, 0x1B50, 0x1B59, 0x1B83, 0x1BA0, 0x1BAE, 0x1BE5, 0x1C00,
|
||||
0x1C23, 0x1C40, 0x1C49, 0x1C4D, 0x1C7D, 0x1C80, 0x1C8A, 0x1C90, 0x1CBA, 0x1CBD, 0x1CBF, 0x1CE9, 0x1CEC, 0x1CEE,
|
||||
0x1CF3, 0x1CF5, 0x1CF6, 0x1CFA, 0x1CFA, 0x1D00, 0x1DBF, 0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45, 0x1F48,
|
||||
0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4, 0x1FB6,
|
||||
0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC, 0x1FF2,
|
||||
0x1FF4, 0x1FF6, 0x1FFC, 0x2070, 0x2071, 0x2074, 0x2079, 0x207F, 0x2089, 0x2090, 0x209C, 0x2102, 0x2102, 0x2107,
|
||||
0x2107, 0x210A, 0x2113, 0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128, 0x212A,
|
||||
0x212D, 0x212F, 0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2150, 0x2189, 0x2460, 0x249B, 0x24EA,
|
||||
0x24FF, 0x2776, 0x2793, 0x2C00, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2CFD, 0x2CFD, 0x2D00, 0x2D25, 0x2D27,
|
||||
0x2D27, 0x2D2D, 0x2D2D, 0x2D30, 0x2D67, 0x2D6F, 0x2D6F, 0x2D80, 0x2D96, 0x2DA0, 0x2DA6, 0x2DA8, 0x2DAE, 0x2DB0,
|
||||
0x2DB6, 0x2DB8, 0x2DBE, 0x2DC0, 0x2DC6, 0x2DC8, 0x2DCE, 0x2DD0, 0x2DD6, 0x2DD8, 0x2DDE, 0x2E2F, 0x2E2F, 0x3005,
|
||||
0x3007, 0x3021, 0x3029, 0x3031, 0x3035, 0x3038, 0x303C, 0x3041, 0x3096, 0x309D, 0x309F, 0x30A1, 0x30FA, 0x30FC,
|
||||
0x30FF, 0x3105, 0x312F, 0x3131, 0x318E, 0x3192, 0x3195, 0x31A0, 0x31BF, 0x31F0, 0x31FF, 0x3220, 0x3229, 0x3248,
|
||||
0x324F, 0x3251, 0x325F, 0x3280, 0x3289, 0x32B1, 0x32BF, 0x3400, 0x4DBF, 0x4E00, 0xA48C, 0xA4D0, 0xA4FD, 0xA500,
|
||||
0xA60C, 0xA610, 0xA62B, 0xA640, 0xA66E, 0xA67F, 0xA69D, 0xA6A0, 0xA6EF, 0xA717, 0xA71F, 0xA722, 0xA788, 0xA78B,
|
||||
0xA7CD, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, 0xA7D5, 0xA7DC, 0xA7F2, 0xA801, 0xA803, 0xA805, 0xA807, 0xA80A, 0xA80C,
|
||||
0xA822, 0xA830, 0xA835, 0xA840, 0xA873, 0xA882, 0xA8B3, 0xA8D0, 0xA8D9, 0xA8F2, 0xA8F7, 0xA8FB, 0xA8FB, 0xA8FD,
|
||||
0xA8FE, 0xA900, 0xA925, 0xA930, 0xA946, 0xA960, 0xA97C, 0xA984, 0xA9B2, 0xA9CF, 0xA9D9, 0xA9E0, 0xA9E4, 0xA9E6,
|
||||
0xA9FE, 0xAA00, 0xAA28, 0xAA40, 0xAA42, 0xAA44, 0xAA4B, 0xAA50, 0xAA59, 0xAA60, 0xAA76, 0xAA7A, 0xAA7A, 0xAA7E,
|
||||
0xAAAF, 0xAAB1, 0xAAB1, 0xAAB5, 0xAAB6, 0xAAB9, 0xAABD, 0xAAC0, 0xAAC0, 0xAAC2, 0xAAC2, 0xAADB, 0xAADD, 0xAAE0,
|
||||
0xAAEA, 0xAAF2, 0xAAF4, 0xAB01, 0xAB06, 0xAB09, 0xAB0E, 0xAB11, 0xAB16, 0xAB20, 0xAB26, 0xAB28, 0xAB2E, 0xAB30,
|
||||
0xAB5A, 0xAB5C, 0xAB69, 0xAB70, 0xABE2, 0xABF0, 0xABF9, 0xAC00, 0xD7A3, 0xD7B0, 0xD7C6, 0xD7CB, 0xD7FB, 0xF900,
|
||||
0xFA6D, 0xFA70, 0xFAD9, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB1D, 0xFB1F, 0xFB28, 0xFB2A, 0xFB36, 0xFB38,
|
||||
0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3D, 0xFD50, 0xFD8F, 0xFD92,
|
||||
0xFDC7, 0xFDF0, 0xFDFB, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFF10, 0xFF19, 0xFF21, 0xFF3A, 0xFF41, 0xFF5A, 0xFF66,
|
||||
0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0x10000, 0x1000B, 0x1000D, 0x10026,
|
||||
0x10028, 0x1003A, 0x1003C, 0x1003D, 0x1003F, 0x1004D, 0x10050, 0x1005D, 0x10080, 0x100FA, 0x10107, 0x10133,
|
||||
0x10140, 0x10178, 0x1018A, 0x1018B, 0x10280, 0x1029C, 0x102A0, 0x102D0, 0x102E1, 0x102FB, 0x10300, 0x10323,
|
||||
0x1032D, 0x1034A, 0x10350, 0x10375, 0x10380, 0x1039D, 0x103A0, 0x103C3, 0x103C8, 0x103CF, 0x103D1, 0x103D5,
|
||||
0x10400, 0x1049D, 0x104A0, 0x104A9, 0x104B0, 0x104D3, 0x104D8, 0x104FB, 0x10500, 0x10527, 0x10530, 0x10563,
|
||||
0x10570, 0x1057A, 0x1057C, 0x1058A, 0x1058C, 0x10592, 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3, 0x105B1,
|
||||
0x105B3, 0x105B9, 0x105BB, 0x105BC, 0x105C0, 0x105F3, 0x10600, 0x10736, 0x10740, 0x10755, 0x10760, 0x10767,
|
||||
0x10780, 0x10785, 0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10800, 0x10805, 0x10808, 0x10808, 0x1080A, 0x10835,
|
||||
0x10837, 0x10838, 0x1083C, 0x1083C, 0x1083F, 0x10855, 0x10858, 0x10876, 0x10879, 0x1089E, 0x108A7, 0x108AF,
|
||||
0x108E0, 0x108F2, 0x108F4, 0x108F5, 0x108FB, 0x1091B, 0x10920, 0x10939, 0x10980, 0x109B7, 0x109BC, 0x109CF,
|
||||
0x109D2, 0x10A00, 0x10A10, 0x10A13, 0x10A15, 0x10A17, 0x10A19, 0x10A35, 0x10A40, 0x10A48, 0x10A60, 0x10A7E,
|
||||
0x10A80, 0x10A9F, 0x10AC0, 0x10AC7, 0x10AC9, 0x10AE4, 0x10AEB, 0x10AEF, 0x10B00, 0x10B35, 0x10B40, 0x10B55,
|
||||
0x10B58, 0x10B72, 0x10B78, 0x10B91, 0x10BA9, 0x10BAF, 0x10C00, 0x10C48, 0x10C80, 0x10CB2, 0x10CC0, 0x10CF2,
|
||||
0x10CFA, 0x10D23, 0x10D30, 0x10D39, 0x10D40, 0x10D65, 0x10D6F, 0x10D85, 0x10E60, 0x10E7E, 0x10E80, 0x10EA9,
|
||||
0x10EB0, 0x10EB1, 0x10EC2, 0x10EC4, 0x10F00, 0x10F27, 0x10F30, 0x10F45, 0x10F51, 0x10F54, 0x10F70, 0x10F81,
|
||||
0x10FB0, 0x10FCB, 0x10FE0, 0x10FF6, 0x11003, 0x11037, 0x11052, 0x1106F, 0x11071, 0x11072, 0x11075, 0x11075,
|
||||
0x11083, 0x110AF, 0x110D0, 0x110E8, 0x110F0, 0x110F9, 0x11103, 0x11126, 0x11136, 0x1113F, 0x11144, 0x11144,
|
||||
0x11147, 0x11147, 0x11150, 0x11172, 0x11176, 0x11176, 0x11183, 0x111B2, 0x111C1, 0x111C4, 0x111D0, 0x111DA,
|
||||
0x111DC, 0x111DC, 0x111E1, 0x111F4, 0x11200, 0x11211, 0x11213, 0x1122B, 0x1123F, 0x11240, 0x11280, 0x11286,
|
||||
0x11288, 0x11288, 0x1128A, 0x1128D, 0x1128F, 0x1129D, 0x1129F, 0x112A8, 0x112B0, 0x112DE, 0x112F0, 0x112F9,
|
||||
0x11305, 0x1130C, 0x1130F, 0x11310, 0x11313, 0x11328, 0x1132A, 0x11330, 0x11332, 0x11333, 0x11335, 0x11339,
|
||||
0x1133D, 0x1133D, 0x11350, 0x11350, 0x1135D, 0x11361, 0x11380, 0x11389, 0x1138B, 0x1138B, 0x1138E, 0x1138E,
|
||||
0x11390, 0x113B5, 0x113B7, 0x113B7, 0x113D1, 0x113D1, 0x113D3, 0x113D3, 0x11400, 0x11434, 0x11447, 0x1144A,
|
||||
0x11450, 0x11459, 0x1145F, 0x11461, 0x11480, 0x114AF, 0x114C4, 0x114C5, 0x114C7, 0x114C7, 0x114D0, 0x114D9,
|
||||
0x11580, 0x115AE, 0x115D8, 0x115DB, 0x11600, 0x1162F, 0x11644, 0x11644, 0x11650, 0x11659, 0x11680, 0x116AA,
|
||||
0x116B8, 0x116B8, 0x116C0, 0x116C9, 0x116D0, 0x116E3, 0x11700, 0x1171A, 0x11730, 0x1173B, 0x11740, 0x11746,
|
||||
0x11800, 0x1182B, 0x118A0, 0x118F2, 0x118FF, 0x11906, 0x11909, 0x11909, 0x1190C, 0x11913, 0x11915, 0x11916,
|
||||
0x11918, 0x1192F, 0x1193F, 0x1193F, 0x11941, 0x11941, 0x11950, 0x11959, 0x119A0, 0x119A7, 0x119AA, 0x119D0,
|
||||
0x119E1, 0x119E1, 0x119E3, 0x119E3, 0x11A00, 0x11A00, 0x11A0B, 0x11A32, 0x11A3A, 0x11A3A, 0x11A50, 0x11A50,
|
||||
0x11A5C, 0x11A89, 0x11A9D, 0x11A9D, 0x11AB0, 0x11AF8, 0x11BC0, 0x11BE0, 0x11BF0, 0x11BF9, 0x11C00, 0x11C08,
|
||||
0x11C0A, 0x11C2E, 0x11C40, 0x11C40, 0x11C50, 0x11C6C, 0x11C72, 0x11C8F, 0x11D00, 0x11D06, 0x11D08, 0x11D09,
|
||||
0x11D0B, 0x11D30, 0x11D46, 0x11D46, 0x11D50, 0x11D59, 0x11D60, 0x11D65, 0x11D67, 0x11D68, 0x11D6A, 0x11D89,
|
||||
0x11D98, 0x11D98, 0x11DA0, 0x11DA9, 0x11EE0, 0x11EF2, 0x11F02, 0x11F02, 0x11F04, 0x11F10, 0x11F12, 0x11F33,
|
||||
0x11F50, 0x11F59, 0x11FB0, 0x11FB0, 0x11FC0, 0x11FD4, 0x12000, 0x12399, 0x12400, 0x1246E, 0x12480, 0x12543,
|
||||
0x12F90, 0x12FF0, 0x13000, 0x1342F, 0x13441, 0x13446, 0x13460, 0x143FA, 0x14400, 0x14646, 0x16100, 0x1611D,
|
||||
0x16130, 0x16139, 0x16800, 0x16A38, 0x16A40, 0x16A5E, 0x16A60, 0x16A69, 0x16A70, 0x16ABE, 0x16AC0, 0x16AC9,
|
||||
0x16AD0, 0x16AED, 0x16B00, 0x16B2F, 0x16B40, 0x16B43, 0x16B50, 0x16B59, 0x16B5B, 0x16B61, 0x16B63, 0x16B77,
|
||||
0x16B7D, 0x16B8F, 0x16D40, 0x16D6C, 0x16D70, 0x16D79, 0x16E40, 0x16E96, 0x16F00, 0x16F4A, 0x16F50, 0x16F50,
|
||||
0x16F93, 0x16F9F, 0x16FE0, 0x16FE1, 0x16FE3, 0x16FE3, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF, 0x18D08,
|
||||
0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150, 0x1B152,
|
||||
0x1B155, 0x1B155, 0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1BC00, 0x1BC6A, 0x1BC70, 0x1BC7C, 0x1BC80, 0x1BC88,
|
||||
0x1BC90, 0x1BC99, 0x1CCF0, 0x1CCF9, 0x1D2C0, 0x1D2D3, 0x1D2E0, 0x1D2F3, 0x1D360, 0x1D378, 0x1D400, 0x1D454,
|
||||
0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE, 0x1D4B9,
|
||||
0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514, 0x1D516, 0x1D51C,
|
||||
0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552, 0x1D6A5,
|
||||
0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714, 0x1D716, 0x1D734, 0x1D736, 0x1D74E,
|
||||
0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA, 0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1D7CE, 0x1D7FF,
|
||||
0x1DF00, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D, 0x1E100, 0x1E12C, 0x1E137, 0x1E13D, 0x1E140, 0x1E149,
|
||||
0x1E14E, 0x1E14E, 0x1E290, 0x1E2AD, 0x1E2C0, 0x1E2EB, 0x1E2F0, 0x1E2F9, 0x1E4D0, 0x1E4EB, 0x1E4F0, 0x1E4F9,
|
||||
0x1E5D0, 0x1E5ED, 0x1E5F0, 0x1E5FA, 0x1E7E0, 0x1E7E6, 0x1E7E8, 0x1E7EB, 0x1E7ED, 0x1E7EE, 0x1E7F0, 0x1E7FE,
|
||||
0x1E800, 0x1E8C4, 0x1E8C7, 0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1E94B, 0x1E950, 0x1E959, 0x1EC71, 0x1ECAB,
|
||||
0x1ECAD, 0x1ECAF, 0x1ECB1, 0x1ECB4, 0x1ED01, 0x1ED2D, 0x1ED2F, 0x1ED3D, 0x1EE00, 0x1EE03, 0x1EE05, 0x1EE1F,
|
||||
0x1EE21, 0x1EE22, 0x1EE24, 0x1EE24, 0x1EE27, 0x1EE27, 0x1EE29, 0x1EE32, 0x1EE34, 0x1EE37, 0x1EE39, 0x1EE39,
|
||||
0x1EE3B, 0x1EE3B, 0x1EE42, 0x1EE42, 0x1EE47, 0x1EE47, 0x1EE49, 0x1EE49, 0x1EE4B, 0x1EE4B, 0x1EE4D, 0x1EE4F,
|
||||
0x1EE51, 0x1EE52, 0x1EE54, 0x1EE54, 0x1EE57, 0x1EE57, 0x1EE59, 0x1EE59, 0x1EE5B, 0x1EE5B, 0x1EE5D, 0x1EE5D,
|
||||
0x1EE5F, 0x1EE5F, 0x1EE61, 0x1EE62, 0x1EE64, 0x1EE64, 0x1EE67, 0x1EE6A, 0x1EE6C, 0x1EE72, 0x1EE74, 0x1EE77,
|
||||
0x1EE79, 0x1EE7C, 0x1EE7E, 0x1EE7E, 0x1EE80, 0x1EE89, 0x1EE8B, 0x1EE9B, 0x1EEA1, 0x1EEA3, 0x1EEA5, 0x1EEA9,
|
||||
0x1EEAB, 0x1EEBB, 0x1F100, 0x1F10C, 0x1FBF0, 0x1FBF9, 0x20000, 0x2A6DF, 0x2A700, 0x2B739, 0x2B740, 0x2B81D,
|
||||
0x2B820, 0x2CEA1, 0x2CEB0, 0x2EBE0, 0x2EBF0, 0x2EE5D, 0x2F800, 0x2FA1D, 0x30000, 0x3134A, 0x31350, 0x323AF,
|
||||
];
|
||||
|
||||
export const DEFAULT_IGNORABLE: readonly number[] = [
|
||||
0xAD, 0xAD, 0x34F, 0x34F, 0x61C, 0x61C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180F, 0x200B, 0x200F, 0x202A,
|
||||
0x202E, 0x2060, 0x206F, 0x3164, 0x3164, 0xFE00, 0xFE0F, 0xFEFF, 0xFEFF, 0xFFA0, 0xFFA0, 0xFFF0, 0xFFF8, 0x1BCA0,
|
||||
0x1BCA3, 0x1D173, 0x1D17A, 0xE0000, 0xE0FFF,
|
||||
];
|
||||
|
|
@ -54,6 +54,7 @@ import {
|
|||
validateGhostlightAgentStateGenerated,
|
||||
validateGhostlightAgentState,
|
||||
shardServes,
|
||||
type CultNetDatabaseChangeRawMessage,
|
||||
type CultNetLoginMessage,
|
||||
type GhostlightAgentStateShape,
|
||||
type GhostlightAgentStateDocument,
|
||||
|
|
@ -1688,6 +1689,55 @@ test("database subscription contracts match the C# live document lane", () => {
|
|||
assert.equal(removed.schemaVersion, "cultnet.database_change_raw.v0");
|
||||
assert.equal(removed.recordKey, "counter:main");
|
||||
|
||||
const grouped = parseCultNetMessage({
|
||||
schemaVersion: "cultnet.database_change_raw.v0",
|
||||
messageId: "change-counter-3",
|
||||
subscriptionId: "counter:browser-1",
|
||||
changeKind: "removed",
|
||||
recordKey: "counter:main",
|
||||
schemaId: "sample.counter_state.v1",
|
||||
commitId: "c1",
|
||||
commitCount: 2,
|
||||
});
|
||||
assert.equal((grouped as CultNetDatabaseChangeRawMessage).commitId, "c1");
|
||||
assert.equal((grouped as CultNetDatabaseChangeRawMessage).commitCount, 2);
|
||||
assert.deepEqual(encodeCultNetMessageForWire(grouped), grouped, "both commit fields travel");
|
||||
assert.equal((removed as CultNetDatabaseChangeRawMessage).commitId, undefined, "a change without them parses as before");
|
||||
const changeWith = (extra: Record<string, unknown>) => () => parseCultNetMessage({
|
||||
schemaVersion: "cultnet.database_change_raw.v0",
|
||||
messageId: "change-counter-4",
|
||||
subscriptionId: "counter:browser-1",
|
||||
changeKind: "removed",
|
||||
recordKey: "counter:main",
|
||||
schemaId: "sample.counter_state.v1",
|
||||
...extra,
|
||||
});
|
||||
// The C# server writes commitId and commitCount as nil on every change outside a grouped commit, so null is absent.
|
||||
const ungrouped = changeWith({ commitId: null, commitCount: null })() as CultNetDatabaseChangeRawMessage;
|
||||
assert.equal(ungrouped.commitId, null);
|
||||
assert.equal(ungrouped.commitCount, null);
|
||||
assert.doesNotThrow(changeWith({ commitId: null }));
|
||||
assert.doesNotThrow(changeWith({ commitCount: null }));
|
||||
assert.doesNotThrow(() => parseCultNetMessage({
|
||||
schemaVersion: "cultnet.database_change_raw.v0",
|
||||
messageId: "change-counter-5",
|
||||
subscriptionId: "counter:browser-1",
|
||||
changeKind: "added",
|
||||
document: {
|
||||
schemaId: "sample.counter_state.v1",
|
||||
recordKey: "counter:main",
|
||||
storedAt: "2026-08-17T00:00:00Z",
|
||||
payloadEncoding: "messagepack",
|
||||
payload: new Uint8Array([0x81, 0xa5, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x01]),
|
||||
},
|
||||
recordKey: null,
|
||||
schemaId: null,
|
||||
commitId: null,
|
||||
commitCount: null,
|
||||
}));
|
||||
for (const commitId of ["", 5]) assert.throws(changeWith({ commitId }), /\/commitId/, `commitId ${JSON.stringify(commitId)}`);
|
||||
for (const commitCount of [1, 0, 2.5, "two", -3]) assert.throws(changeWith({ commitCount }), /\/commitCount/, `commitCount ${commitCount}`);
|
||||
|
||||
const unsubscribe = parseCultNetMessage({
|
||||
schemaVersion: "cultnet.database_unsubscribe.v0",
|
||||
messageId: "unsubscribe-counter",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { decodeMulti } from "@msgpack/msgpack";
|
||||
|
||||
import { cultNetSchemas, parseCultNetMessage } from "../src/index";
|
||||
|
||||
// tests/vectors/database-change-commit/two-change-commit.msgpack is written only by the C# reference: two removed
|
||||
// database_change_raw messages of one commit, every key present, nil where unset (document included).
|
||||
const vector = join(__dirname, "..", "..", "..", "..", "tests", "vectors", "database-change-commit", "two-change-commit.msgpack");
|
||||
const changes = () => [...decodeMulti(readFileSync(vector))] as Record<string, unknown>[];
|
||||
|
||||
test("a two-change commit written by C# decodes with its commitId, commitCount and changes", () => {
|
||||
const messages = changes().map((message) => parseCultNetMessage(structuredClone(message)));
|
||||
assert.equal(messages.length, 2);
|
||||
for (const message of messages) {
|
||||
assert.equal(message.schemaVersion, "cultnet.database_change_raw.v0");
|
||||
assert.equal((message as { commitId?: unknown }).commitId, "9f1c0f0e4b7a4c1f9d2e");
|
||||
assert.equal((message as { commitCount?: unknown }).commitCount, 2);
|
||||
assert.equal((message as { changeKind?: unknown }).changeKind, "removed");
|
||||
assert.equal((message as { subscriptionId?: unknown }).subscriptionId, "sub-1");
|
||||
assert.equal((message as { schemaId?: unknown }).schemaId, "schema-note");
|
||||
}
|
||||
assert.deepEqual(messages.map((message) => (message as { recordKey?: unknown }).recordKey), ["note:a", "note:b"]);
|
||||
});
|
||||
|
||||
test("the database_change_raw schema accepts the C# vector's removed changes (document: nil) and still refuses an added change without a document", () => {
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
ajv.addSchema(cultNetSchemas.rawDocumentRecordSchema);
|
||||
const validate = ajv.compile(cultNetSchemas.databaseChangeRawSchema);
|
||||
for (const change of changes()) {
|
||||
assert.equal(change.document, null, "the C# writer wrote the key, nil");
|
||||
assert.equal(validate(change), true, JSON.stringify(validate.errors));
|
||||
assert.equal(validate({ ...change, changeKind: "added" }), false, "an added change needs a document");
|
||||
}
|
||||
});
|
||||
600
packages/cultnet-ts/test/governance-vectors.test.ts
Normal file
600
packages/cultnet-ts/test/governance-vectors.test.ts
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
|
||||
// contracts/cultnet/governance: the vote-API and fork-card contracts (vote-api.md, fork-card.md). The vectors are spec-authored
|
||||
// (scripts/gen-governance-vectors.py); this suite re-encodes every message with this runtime and must reproduce the bytes, judges
|
||||
// every negative with the committed schema, and replays the fork scenarios through a reference judge written from fork-card.md.
|
||||
const DIR = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "governance");
|
||||
const read = (name: string) => JSON.parse(readFileSync(join(DIR, name), "utf8"));
|
||||
const vote = read("vote-api-vectors.json");
|
||||
const fork = read("fork-card-vectors.json");
|
||||
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
const voteSchema = read("vote-api.schema.json");
|
||||
const forkSchema = read("fork-card.schema.json");
|
||||
ajv.addSchema(voteSchema);
|
||||
ajv.addSchema(forkSchema);
|
||||
const check = (schema: { $id: string }, def: string, value: unknown): boolean => {
|
||||
const validate = ajv.getSchema(`${schema.$id}#/$defs/${def}`);
|
||||
assert.ok(validate, `no schema def ${def}`);
|
||||
return validate(value) as boolean;
|
||||
};
|
||||
const hexOf = (value: unknown): string => Buffer.from(encode(value)).toString("hex").toUpperCase();
|
||||
const sha = (bytes: Buffer | string): string => createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
// Rules a JSON Schema cannot say: vote-api.md counts a reason in UTF-16 code units (as Bifrost does); maxLength counts code points.
|
||||
const voteRuleErrors = (def: string, message: any): string[] =>
|
||||
def === "cast" && message.ballot !== null && message.ballot.reason.length > 280 ? ["reason is over 280 UTF-16 code units"] : [];
|
||||
|
||||
test("vote API: every valid document validates, encodes to the vector bytes and decodes back", () => {
|
||||
for (const v of vote.valid) {
|
||||
assert.ok(check(voteSchema, v.def, v.message), v.name);
|
||||
assert.deepEqual(voteRuleErrors(v.def, v.message), [], v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
assert.deepEqual(decode(Buffer.from(v.messagePackHex, "hex")), v.message, v.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("vote API: every negative is refused by the schema, and its bytes are the vector's", () => {
|
||||
for (const v of vote.invalid) {
|
||||
assert.ok(!check(voteSchema, v.def, v.message) || voteRuleErrors(v.def, v.message).length > 0, `${v.name}: ${v.why}`);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("vote API: the commitment is SHA-256 of the canonical bytes", () => {
|
||||
for (const c of vote.commitment) {
|
||||
const b = c.ballot;
|
||||
const bytes = Buffer.from(`gamecult.sealed_ballot.v0\n${b.motionId}\n${c.voterUserAccountId}\n${b.persona}\n${b.round}\n${b.candidate}\n${b.reason}\n${b.salt}`, "utf8");
|
||||
assert.equal(bytes.toString("hex").toUpperCase(), c.canonicalHex, c.name);
|
||||
assert.equal(sha(bytes), c.sha256, c.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("vote API: a reveal matches its commit exactly when the ballot hashes to the commitment", () => {
|
||||
for (const p of vote.pairs) {
|
||||
const b = p.reveal.ballot;
|
||||
const digest = sha(Buffer.from(`gamecult.sealed_ballot.v0\n${b.motionId}\n${p.voterUserAccountId}\n${b.persona}\n${b.round}\n${b.candidate}\n${b.reason}\n${b.salt}`, "utf8"));
|
||||
assert.equal(digest === p.commit.commitment, p.matches, p.name);
|
||||
assert.ok(check(voteSchema, "cast", p.commit) && check(voteSchema, "cast", p.reveal), p.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("vote API: each refusal code has a vector, a status and the vector bytes", () => {
|
||||
const codes = (voteSchema.$defs.refusal.properties.code as { enum: string[] }).enum;
|
||||
assert.deepEqual(vote.refusals.map((r: { message: { code: string } }) => r.message.code).sort(), [...codes].sort());
|
||||
for (const r of vote.refusals) {
|
||||
assert.ok(check(voteSchema, "refusal", r.message), r.name);
|
||||
assert.equal(hexOf(r.message), r.messagePackHex, r.name);
|
||||
assert.ok(r.httpStatus >= 400, r.name);
|
||||
}
|
||||
assert.ok(vote.refusals.find((r: { name: string }) => r.name === "rate_limited").message.retryAfterSeconds >= 1);
|
||||
});
|
||||
|
||||
// ---- fork card (rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md) ----
|
||||
const jstr = (s: string): string => {
|
||||
let out = '"';
|
||||
for (const ch of s) {
|
||||
const o = ch.codePointAt(0)!;
|
||||
out += ch === '"' ? '\\"' : ch === "\\" ? "\\\\" : o === 8 ? "\\b" : o === 9 ? "\\t" : o === 10 ? "\\n" : o === 12 ? "\\f" : o === 13 ? "\\r"
|
||||
: o < 0x20 ? "\\u" + o.toString(16).padStart(4, "0") : ch;
|
||||
}
|
||||
return out + '"';
|
||||
};
|
||||
type Option = { id: string; text: string };
|
||||
type Shown = { title: string; context: string; options: Option[]; recommendation: string; depends: string[] };
|
||||
type Card = Shown & { fork_id: string; leans: string | null; deciders: string[]; status: string; ruling: string | null; ruled_from: string[]; options_hash: string };
|
||||
// RFC 8785 over the five shown members, every string NFC-normalised first; keys sorted; no numbers in the member set.
|
||||
const nfc = (s: string): string => s.normalize("NFC");
|
||||
const canonical = (c: Shown): Buffer =>
|
||||
Buffer.from(`{"context":${jstr(nfc(c.context))},"depends":[${c.depends.map((d) => jstr(nfc(d))).join(",")}],"options":[${c.options.map((o) => `{"id":${jstr(nfc(o.id))},"text":${jstr(nfc(o.text))}}`).join(",")}],"recommendation":${jstr(nfc(c.recommendation))},"title":${jstr(nfc(c.title))}}`, "utf8");
|
||||
const optionsHash = (c: Shown): string => sha(canonical(c));
|
||||
|
||||
const cardRuleErrors = (c: Card): string[] => {
|
||||
const ids = c.options.map((o) => o.id);
|
||||
const errors: string[] = [];
|
||||
if (new Set(ids).size !== ids.length) errors.push("duplicate option ids");
|
||||
if (!ids.includes(c.recommendation)) errors.push("recommendation is not an option");
|
||||
if (c.leans !== null && !ids.includes(c.leans)) errors.push("leans is not an option");
|
||||
if (new Set(c.deciders).size !== c.deciders.length) errors.push("duplicate deciders");
|
||||
if (c.depends.some((d) => Buffer.byteLength(d, "utf8") > 1000)) errors.push("a depends line is over 1000 UTF-8 bytes");
|
||||
if (c.status === "open" && c.ruling !== null) errors.push("an open card names a ruling");
|
||||
if (c.ruling === null && c.ruled_from.length > 0) errors.push("ruled_from without a ruling");
|
||||
if (c.options_hash !== optionsHash(c)) errors.push("options_hash is not the hash of the shown members");
|
||||
return errors;
|
||||
};
|
||||
|
||||
test("fork card: options hash is SHA-256 of the canonical JSON, computed here, and every shown member moves it", () => {
|
||||
for (const h of fork.optionsHash) {
|
||||
const bytes = canonical(h);
|
||||
assert.equal(bytes.toString("utf8"), h.canonical, h.name);
|
||||
assert.equal(bytes.toString("hex").toUpperCase(), h.canonicalHex, h.name);
|
||||
assert.equal(sha(bytes), h.sha256, h.name);
|
||||
}
|
||||
const byName = new Map<string, string>(fork.optionsHash.map((h: { name: string; sha256: string }) => [h.name, h.sha256]));
|
||||
const base = byName.get("heimdall_A_five_members")!;
|
||||
assert.equal(byName.get("hidden_members_do_not_move_it"), base);
|
||||
assert.equal(byName.get("heimdall_E_decomposed_equals_D"), byName.get("heimdall_D_nfc_escapes_astral"), "an NFD and an NFC spelling hash alike");
|
||||
for (const moved of ["heimdall_B_recommendation_changed", "heimdall_C_option_order_changed", "heimdall_F_option_id_changed", "heimdall_G_depends_emptied", "option_text_changed",
|
||||
"title_changed", "context_changed", "depends_order_changed", "depends_order_changed_reversed"]) {
|
||||
assert.notEqual(byName.get(moved), base, moved);
|
||||
}
|
||||
});
|
||||
|
||||
test("fork card: an answer result without counted_answer_id is refused by the schema", () => {
|
||||
const result = structuredClone(fork.scenarios.flatMap((sc: any) => sc.steps).find((st: any) => st.response?.answer).response);
|
||||
assert.ok(check(forkSchema, "answerResult", result));
|
||||
delete result.counted_answer_id;
|
||||
assert.ok(!check(forkSchema, "answerResult", result));
|
||||
});
|
||||
|
||||
// body_sha256 is Huginn's private "same body" digest, not a wire field: compact JSON, fixed key order, null for missing, the two illegal-form keys only when present.
|
||||
test("fork card: the body digest is SHA-256 of the compact JSON in the stated key order, and the fixed forms separate retracted true from false", () => {
|
||||
const byName = new Map<string, any>(fork.bodyDigests.map((d: { name: string }) => [d.name, d]));
|
||||
for (const d of fork.bodyDigests) {
|
||||
const o: Record<string, unknown> = {};
|
||||
for (const key of ["fork", "options_hash", "answered_at", "choice", "note", "retracts", "illegal_retraction", "retracted"]) {
|
||||
if ((key === "illegal_retraction" || key === "retracted") && d.input[key] === null) continue;
|
||||
o[key] = d.input[key];
|
||||
}
|
||||
const json = JSON.stringify(o);
|
||||
assert.equal(json, d.canonical, d.name);
|
||||
assert.equal(sha(Buffer.from(json, "utf8")), d.sha256, d.name);
|
||||
}
|
||||
assert.ok(byName.get("plain_answer_choice_A").sha256.startsWith("8a4d5b2e"));
|
||||
assert.ok(byName.get("legal_retraction").sha256.startsWith("9f807538"));
|
||||
assert.ok(byName.get("legal_retraction_door_form").sha256.startsWith("84576840"));
|
||||
assert.ok(byName.get("legal_retraction_door_form").input.retracts.endsWith(".n1"), "the door's form: the wire n<seq> converted to the full key");
|
||||
assert.ok(byName.get("illegal_i_retraction_with_an_answer_retracted_true").sha256.startsWith("935cab1a"));
|
||||
assert.ok(byName.get("illegal_iii_plain_answer_with_a_target_retracted_false").sha256.startsWith("82b24ccc"));
|
||||
assert.ok(byName.get("illegal_ii_no_answer_no_target_retracted_true").sha256.startsWith("34ef86ed"));
|
||||
assert.equal(new Set(fork.bodyDigests.map((d: { sha256: string }) => d.sha256)).size, fork.bodyDigests.length, "no two vectors share a digest");
|
||||
});
|
||||
|
||||
test("fork card: mutating any shown member, an option id included, changes the hash", () => {
|
||||
const card = fork.card.valid[0].message as Card;
|
||||
const base = optionsHash(card);
|
||||
const mutations: ((c: Card) => void)[] = [
|
||||
(c) => { c.title += "x"; }, (c) => { c.context += "x"; }, (c) => { c.options[0].id += "x"; }, (c) => { c.options[0].text += "x"; },
|
||||
(c) => { c.options.reverse(); }, (c) => { c.recommendation = c.options[0].id; }, (c) => { c.depends = [...c.depends, "a:b:c"]; }, (c) => { c.depends = []; },
|
||||
];
|
||||
for (const [i, mutate] of mutations.entries()) {
|
||||
const copy = structuredClone(card);
|
||||
mutate(copy);
|
||||
assert.notEqual(optionsHash(copy), base, `mutation ${i}`);
|
||||
}
|
||||
const hidden = structuredClone(card);
|
||||
hidden.fork_id = "other:question:id"; hidden.leans = "yes"; hidden.deciders = ["x"]; hidden.status = "closed"; hidden.ruled_from = ["n1"];
|
||||
assert.equal(optionsHash(hidden), base, "fork_id and the unshown members stay outside the hash");
|
||||
});
|
||||
|
||||
test("fork card: documents validate and encode to the vector bytes", () => {
|
||||
for (const v of fork.card.valid) {
|
||||
assert.ok(check(forkSchema, "card", v.message), v.name);
|
||||
assert.deepEqual(cardRuleErrors(v.message), [], v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
for (const [section, def] of [["request", "answerRequest"], ["deskAnswer", "deskAnswer"]] as const) {
|
||||
for (const v of fork[section].valid) {
|
||||
assert.ok(check(forkSchema, def, v.message), v.name);
|
||||
assert.deepEqual(noteRuleErrors(def, v.message), [], v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
assert.deepEqual(decode(Buffer.from(v.messagePackHex, "hex")), v.message, v.name);
|
||||
}
|
||||
}
|
||||
const resultCodes = (forkSchema.$defs.deskAnswerRefused.properties.code as { enum: string[] }).enum;
|
||||
assert.deepEqual(fork.results.map((r: { name: string }) => r.name).sort(), [...resultCodes].sort());
|
||||
for (const v of fork.results) {
|
||||
assert.ok(check(forkSchema, "answerResult", v.message), v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
assert.equal(v.message.card !== null, v.name === "stale_fork", "only a stale refusal shows the new fork");
|
||||
}
|
||||
for (const v of fork.unavailable) {
|
||||
assert.ok(check(forkSchema, "unavailable", v.message), v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
assert.equal(v.httpStatus, 503, v.name);
|
||||
}
|
||||
const doorCodes = (forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum;
|
||||
assert.ok(!doorCodes.includes("unavailable"), "unavailable is kept apart from the refusal set");
|
||||
assert.deepEqual(fork.doorRefusals.map((r: { name: string }) => r.name).sort(), [...doorCodes].sort());
|
||||
for (const v of fork.doorRefusals) {
|
||||
assert.ok(check(forkSchema, "refusal", v.message), v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
assert.equal(fork.doorRefusals.find((r: { name: string }) => r.name === "fork_too_large").httpStatus, 422);
|
||||
});
|
||||
|
||||
test("fork card: every negative is refused by the schema or a stated rule", () => {
|
||||
for (const v of fork.card.invalid) {
|
||||
assert.ok(!check(forkSchema, "card", v.message) || cardRuleErrors(v.message).length > 0, `${v.name}: ${v.why}`);
|
||||
}
|
||||
for (const [section, def] of [["request", "answerRequest"], ["deskAnswer", "deskAnswer"]] as const) {
|
||||
for (const v of fork[section].invalid) {
|
||||
assert.ok(!check(forkSchema, def, v.message) || noteRuleErrors(def, v.message).length > 0, `${v.name}: ${v.why}`);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Rules a JSON Schema cannot say: a note is at most 4000 UTF-8 bytes (maxLength counts code points and is only the looser bound).
|
||||
const noteRuleErrors = (def: string, message: any): string[] => {
|
||||
const note = def === "answerRequest" ? message?.answer?.note : def === "deskAnswer" ? message?.note : null;
|
||||
return note != null && Buffer.byteLength(note, "utf8") > 4000 ? ["note is over 4000 UTF-8 bytes"] : [];
|
||||
};
|
||||
|
||||
const TAILS: Record<string, string> = {
|
||||
unauthenticated: "a verified session is required",
|
||||
body_forbidden_field: "answerer and authority are set by the receiver",
|
||||
body_unknown_field: "the body is a closed shape",
|
||||
body_invalid: "the body does not satisfy gamecult.fork.answer_request.v0",
|
||||
fork_unknown: "no such fork",
|
||||
forbidden: "only Active members may use the desk",
|
||||
request_id_conflict: "this request_id was used for a different body",
|
||||
};
|
||||
const REQUEST_KEYS = ["schemaVersion", "fork_id", "options_hash", "answer", "retracted", "retracts", "answered_at", "request_id"];
|
||||
const hasKey = (v: unknown, names: string[]): boolean =>
|
||||
typeof v === "object" && v !== null && Object.entries(v).some(([k, x]) => names.includes(k) || hasKey(x, names));
|
||||
const SECONDS = (iso: string): number => Date.parse(iso);
|
||||
|
||||
// The reference judge: Huginn's half behind the second door as fork-card.md states it. Server-set fields come from the step.
|
||||
function judge(start: Card, steps: { kind: string; body: Record<string, any> | null; server: Record<string, any> | null; card?: Card }[]) {
|
||||
let card = start;
|
||||
const answers: Record<string, any>[] = [];
|
||||
const responses: unknown[] = [];
|
||||
const seen = new Map<string, { body: Record<string, any>; response: unknown }>(); // (answerer, request_id) -> the first stored outcome
|
||||
const counted = (): string | null => {
|
||||
const hash = optionsHash(card);
|
||||
const live = answers.find((a) => (a.outcome === "Counted" || a.outcome === "Revision") && a.options_hash === hash
|
||||
&& !answers.some((r) => r.outcome === "Retraction" && r.retracts === a.answer_id));
|
||||
return live ? live.answer_id : null;
|
||||
};
|
||||
for (const step of steps) {
|
||||
if (step.kind === "matrix_event") { responses.push(null); continue; } // rule 9: Matrix never changes a decision
|
||||
if (step.kind === "card") { card = step.card!; responses.push(null); continue; }
|
||||
const s = step.server!, b = step.body!;
|
||||
const door = (code: string) => ({ schemaVersion: "gamecult.fork.refusal.v0", code, error: `${code}: ${TAILS[code]}` });
|
||||
const store = (outcome: string, link: string | null, code: string | null) => {
|
||||
const a = b.answer ?? { choice: null, note: null };
|
||||
const doc = { schemaVersion: "gamecult.desk_answer.v0", answer_id: s.answer_id, fork_id: b.fork_id, outcome,
|
||||
of: outcome === "Revision" ? link : null, retracts: outcome === "Retraction" ? link : outcome === "Refused" ? b.retracts : null, code: outcome === "Refused" ? code : null,
|
||||
options_hash: b.options_hash, choice: a.choice, note: a.note, answered_at: b.answered_at, received_at: s.received_at,
|
||||
claim_in_future: SECONDS(b.answered_at) - SECONDS(s.received_at) > 300_000, answerer: s.answerer, authority: s.authority, request_id: b.request_id };
|
||||
answers.push(doc);
|
||||
const stored = { schemaVersion: "gamecult.fork.answer_result.v0", answer: doc, card: code === "stale_fork" ? card : null, counted_answer_id: counted() };
|
||||
seen.set(`${s.answerer}|${b.request_id}`, { body: structuredClone(b), response: structuredClone(stored) });
|
||||
return stored;
|
||||
};
|
||||
const refuse = (code: string) => store("Refused", null, code);
|
||||
const down = s.door_down === true, reached = s.stored === true; // the second door failed to answer; "stored" says whether Huginn had the request by then
|
||||
const unavailable = () => ({ schemaVersion: "gamecult.fork.unavailable.v0", code: "unavailable", error: "unavailable: the outcome is not confirmed, try again with the same request_id", retryAfterSeconds: s.retry_after_seconds });
|
||||
// a stored desk_answer has no card, so a replayed stale_fork carries the CURRENT card (fork-card.md, request_id)
|
||||
const replay = (stored: any) => { const copy = structuredClone(stored); if (copy.answer.code === "stale_fork") copy.card = structuredClone(card); copy.counted_answer_id = counted(); return copy; };
|
||||
let response: unknown;
|
||||
if (s.answerer === null) response = door("unauthenticated");
|
||||
else if (s.active === false) response = door("forbidden");
|
||||
else if (hasKey(b, ["answerer", "authority"])) response = door("body_forbidden_field");
|
||||
else if (Object.keys(b).some((k) => !REQUEST_KEYS.includes(k)) || (b.answer && Object.keys(b.answer).some((k) => k !== "choice" && k !== "note"))) response = door("body_unknown_field");
|
||||
else if (!check(forkSchema, "answerRequest", b) || noteRuleErrors("answerRequest", b).length > 0 || (!b.retracted && b.answer === null)) response = door("body_invalid");
|
||||
else if (down && !reached) response = unavailable();
|
||||
else if (seen.has(`${s.answerer}|${b.request_id}`)) { const prior = seen.get(`${s.answerer}|${b.request_id}`)!; response = isDeepStrictEqual(prior.body, b) ? replay(prior.response) : door("request_id_conflict"); }
|
||||
else if (b.fork_id !== card.fork_id) response = door("fork_unknown");
|
||||
else if (b.options_hash !== optionsHash(card)) response = refuse("stale_fork");
|
||||
else if (!card.deciders.includes(s.answerer)) response = refuse("not_a_decider");
|
||||
else if (card.status === "closed") response = refuse("fork_closed");
|
||||
else if (b.retracted || b.retracts !== null) {
|
||||
const target = answers.find((a) => a.answer_id === b.retracts && (a.outcome === "Counted" || a.outcome === "Revision") && a.options_hash === b.options_hash);
|
||||
if (!b.retracted || b.answer !== null || b.retracts === null || !target) response = refuse("bad_retraction");
|
||||
else if (answers.some((a) => a.outcome === "Retraction" && a.retracts === target.answer_id)) response = refuse("already_retracted");
|
||||
else response = store("Retraction", target.answer_id, null);
|
||||
} else if (b.answer.choice !== null && !card.options.some((o) => o.id === b.answer.choice)) response = refuse("choice_invalid");
|
||||
else {
|
||||
const first = counted();
|
||||
response = first === null ? store("Counted", null, null) : store("Revision", first, null);
|
||||
}
|
||||
responses.push(down && reached ? unavailable() : response);
|
||||
}
|
||||
return { responses, record: { schemaVersion: "gamecult.fork.record.v0", fork_id: card.fork_id, options_hash: optionsHash(card), counted_answer_id: counted(), answers } };
|
||||
}
|
||||
|
||||
test("fork card: every scenario replays to the vector's responses and record, bytes included", () => {
|
||||
for (const sc of fork.scenarios) {
|
||||
assert.equal(optionsHash(sc.card), sc.optionsHash, sc.name);
|
||||
assert.deepEqual(cardRuleErrors(sc.card), [], sc.name);
|
||||
const { responses, record } = judge(sc.card, sc.steps);
|
||||
assert.deepEqual(responses, sc.steps.map((s: { response: unknown }) => s.response), sc.name);
|
||||
assert.deepEqual(record, sc.record, sc.name);
|
||||
assert.ok(check(forkSchema, "record", record), sc.name);
|
||||
assert.equal(hexOf(record), sc.recordHex, sc.name);
|
||||
for (const s of sc.steps) if (s.response) assert.ok(check(forkSchema, "answerResult", s.response) || check(forkSchema, "refusal", s.response) || check(forkSchema, "unavailable", s.response), sc.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("fork card: a request id belongs to its account; another account's identical body gets its own answer, never the first account's outcome", () => {
|
||||
let crossed = 0;
|
||||
for (const sc of fork.scenarios) {
|
||||
const first = new Map<string, { answerer: string; result: any }>(); // request_id -> the account that first got a stored result and what it got
|
||||
for (const s of sc.steps) {
|
||||
if (s.kind !== "answer" || !s.response?.answer) continue;
|
||||
assert.equal(s.response.answer.answerer, s.server.answerer, `${sc.name}: a result is stamped with the account that asked`);
|
||||
const prior = first.get(s.body.request_id);
|
||||
if (!prior) { first.set(s.body.request_id, { answerer: s.server.answerer, result: s.response }); continue; }
|
||||
if (prior.answerer === s.server.answerer) continue;
|
||||
crossed++;
|
||||
assert.notEqual(s.response.answer.answer_id, prior.result.answer.answer_id, `${sc.name}: its own answer id`);
|
||||
assert.notDeepEqual(s.response, prior.result, `${sc.name}: the first account's outcome is never returned`);
|
||||
}
|
||||
}
|
||||
assert.ok(crossed >= 3, "the vectors carry the cross-account cases");
|
||||
});
|
||||
|
||||
test("fork card: a body refused at the door or naming an unknown fork is never stored", () => {
|
||||
const door = new Set(["unauthenticated", "body_forbidden_field", "body_unknown_field", "body_invalid", "fork_unknown", "forbidden", "request_id_conflict"]);
|
||||
let doorRefusals = 0;
|
||||
for (const sc of fork.scenarios) {
|
||||
for (const s of sc.steps) {
|
||||
// unavailable promises nothing: the outcome is not confirmed, so the answer is stored exactly when Huginn had it
|
||||
if (s.response?.code === "unavailable") assert.equal(sc.record.answers.some((a: { answer_id: string }) => a.answer_id === s.server.answer_id), s.server.stored === true, `${sc.name} unavailable`);
|
||||
if (s.response && door.has(s.response.code)) {
|
||||
doorRefusals++;
|
||||
assert.ok(!sc.record.answers.some((a: { answer_id: string }) => a.answer_id === s.server.answer_id), `${sc.name} stored a door refusal`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(doorRefusals >= 8);
|
||||
});
|
||||
|
||||
test("fork card: the scenarios cover every outcome and every refusal code", () => {
|
||||
const seen = new Set<string>();
|
||||
for (const sc of fork.scenarios) {
|
||||
for (const s of sc.steps) if (s.response) seen.add(s.response.code ?? s.response.answer.code ?? s.response.answer.outcome);
|
||||
for (const a of sc.record.answers) seen.add(a.outcome);
|
||||
}
|
||||
for (const want of ["Counted", "Revision", "Retraction", "Refused", "unauthenticated", "body_forbidden_field", "body_unknown_field", "body_invalid", "fork_unknown", "unavailable", "forbidden", "request_id_conflict", "stale_fork", "not_a_decider",
|
||||
"fork_closed", "choice_invalid", "bad_retraction", "already_retracted"]) {
|
||||
assert.ok(seen.has(want), want);
|
||||
}
|
||||
});
|
||||
|
||||
// A ruling may carry authority Operator only if it cites a Counted desk_answer stamped Operator (binding file 1.3d).
|
||||
const admitRuling = (cite: { authority: string; answer_id: string | null }, answers: { answer_id: string; outcome: string; authority: string }[]): boolean => {
|
||||
if (cite.authority !== "Operator") return true;
|
||||
const cited = answers.find((a) => a.answer_id === cite.answer_id);
|
||||
return cited !== undefined && cited.outcome === "Counted" && cited.authority === "Operator";
|
||||
};
|
||||
|
||||
test("fork card: operator authority needs a cited Counted answer record", () => {
|
||||
for (const c of fork.rulingCites) {
|
||||
assert.ok(check(forkSchema, "rulingCite", c.message), c.name);
|
||||
assert.equal(hexOf(c.message), c.messagePackHex, c.name);
|
||||
assert.equal(admitRuling(c.message, c.answers), c.admit, c.name);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- transport: list, read, auth (fork-card.md "Transport") ----
|
||||
const entryRuleErrors = (e: { card: Card; record: { fork_id: string; options_hash: string; counted_answer_id: string | null; answers: Record<string, any>[] } }): string[] => {
|
||||
const errors = cardRuleErrors(e.card);
|
||||
if (e.record.fork_id !== e.card.fork_id) errors.push("record of another fork");
|
||||
if (e.record.options_hash !== e.card.options_hash) errors.push("record under another hash");
|
||||
const counted = e.record.answers.find((a) => (a.outcome === "Counted" || a.outcome === "Revision") && a.options_hash === e.card.options_hash
|
||||
&& !e.record.answers.some((r) => r.outcome === "Retraction" && r.retracts === a.answer_id));
|
||||
if ((counted?.answer_id ?? null) !== e.record.counted_answer_id) errors.push("counted_answer_id is not derived");
|
||||
return errors;
|
||||
};
|
||||
|
||||
// Order is a unique rank per Mind: the refusal names both fork ids, the first holder and the second.
|
||||
const orderErrors = (cards: { fork_id: string; order: number }[]): string[] => {
|
||||
const holder = new Map<number, string>();
|
||||
const errors: string[] = [];
|
||||
for (const c of cards) {
|
||||
if (holder.has(c.order)) errors.push(`order ${c.order} is shared by ${holder.get(c.order)} and ${c.fork_id}`);
|
||||
else holder.set(c.order, c.fork_id);
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
// The reference list judge: newest first by card order, status filter, cursor c<N> = cards below order N, a page of limit, counts over the whole set.
|
||||
const judgeList = (entries: { card: Card }[], ordinal: number, rq: { mind: string; status: string | null; cursor: string | null; limit: number }) => {
|
||||
const rows = [...entries].sort((a, b) => (b.card as any).order - (a.card as any).order);
|
||||
let want = rows.filter((e) => rq.status === null || (e.card.status === "open") === (rq.status === "open"));
|
||||
if (rq.cursor !== null) want = want.filter((e) => (e.card as any).order < Number(rq.cursor!.slice(1)));
|
||||
const page = want.slice(0, rq.limit);
|
||||
const more = want.length > page.length && page.length > 0;
|
||||
return { schemaVersion: "gamecult.fork.list.v0", mind: rq.mind, stamp: `s${ordinal}`, open_count: rows.filter((e) => e.card.status === "open").length,
|
||||
recorded_count: rows.filter((e) => e.card.status === "closed").length, forks: page, next_cursor: more ? `c${(page[page.length - 1].card as any).order}` : null };
|
||||
};
|
||||
|
||||
// Bearer, audience, scope, in that order: a votes.* token fails at the audience and a desk token without the route's scope fails at the scope.
|
||||
const authJudge = (route: string, token: { audience: string; scopes: string[]; active: boolean } | null, huginnDown: boolean): string => {
|
||||
if (token === null || token.audience !== "bifrost-desk") return "unauthenticated";
|
||||
if (!token.scopes.includes(route === "answer" ? "desk.answer" : "desk.read")) return "insufficient_scope";
|
||||
if (!token.active) return "forbidden";
|
||||
return huginnDown ? "unavailable" : "ok";
|
||||
};
|
||||
|
||||
// The {mind} segment is decoded once and compared, exactly, only with the configured list; nothing is opened from it (fork-card.md, "The {mind} segment").
|
||||
type MindResult = { code: string; opened: string | null; storedIn: string | null };
|
||||
const mindRoute = (config: any, route: string, segment: string, fork: string | null, token: any): MindResult => {
|
||||
const auth = authJudge(route, token, false);
|
||||
if (auth !== "ok") return { code: auth, opened: null, storedIn: null };
|
||||
let mind: string;
|
||||
try { mind = decodeURIComponent(segment); } catch { return { code: "fork_unknown", opened: null, storedIn: null }; }
|
||||
if (!Object.prototype.hasOwnProperty.call(config.minds, mind)) return { code: "fork_unknown", opened: null, storedIn: null };
|
||||
const opened: string = config.minds[mind];
|
||||
if (route !== "list" && !config.forks[mind].includes(fork)) return { code: "fork_unknown", opened, storedIn: null };
|
||||
return { code: "ok", opened, storedIn: route === "answer" ? mind : null };
|
||||
};
|
||||
|
||||
test("fork transport: the mind segment is only compared with the configured list; an unknown mind is the unknown fork refusal", () => {
|
||||
const config = fork.transport.mindRoutes;
|
||||
const refusals = new Map<string, any>(fork.doorRefusals.map((r: { name: string }) => [r.name, r]));
|
||||
const byName = new Map<string, MindResult>();
|
||||
for (const c of config.cases) {
|
||||
const got = mindRoute(config, c.route, c.mind, c.fork_id, c.token);
|
||||
byName.set(c.name, got);
|
||||
assert.equal(got.code, c.expect, c.name);
|
||||
assert.equal(got.opened, c.opened, `${c.name}: the socket opened`);
|
||||
assert.equal(got.storedIn, c.stored_in, `${c.name}: where an answer was stored`);
|
||||
assert.equal(c.httpStatus, got.code === "ok" ? 200 : refusals.get(got.code).httpStatus, c.name);
|
||||
if (got.code === "fork_unknown") {
|
||||
const error: string = refusals.get("fork_unknown").message.error;
|
||||
assert.ok(!error.includes(c.mind) && !(c.fork_id !== null && error.includes(c.fork_id)), `${c.name}: the refusal names neither the Mind nor the fork`);
|
||||
}
|
||||
}
|
||||
for (const [a, b] of config.sameResponse) {
|
||||
const x = byName.get(a)!, y = byName.get(b)!;
|
||||
assert.equal(x.code, y.code);
|
||||
const built = (code: string) => hexOf({ schemaVersion: "gamecult.fork.refusal.v0", code, error: `${code}: ${TAILS[code]}` });
|
||||
assert.equal(built(x.code), built(y.code), `${a} and ${b} answer with the same bytes, built from the spec's rule`);
|
||||
assert.equal(built(x.code), refusals.get(x.code).messagePackHex, a);
|
||||
}
|
||||
const all = [...byName.values()];
|
||||
assert.ok(all.filter((v) => v.code === "fork_unknown" && v.opened === null).length >= 39, "every bad segment opens nothing");
|
||||
assert.equal(new Set(all.filter((v) => v.storedIn !== null).map((v) => v.storedIn)).size, 2, "an answer is stored in the route's Mind only, and both Minds take one");
|
||||
});
|
||||
|
||||
test("fork transport: requests and entries validate and encode to the vector bytes, and every negative is refused", () => {
|
||||
const t = fork.transport;
|
||||
for (const [section, def] of [["listRequest", "listRequest"], ["readRequest", "readRequest"], ["entry", "entry"]] as const) {
|
||||
for (const v of t[section].valid) {
|
||||
assert.ok(check(forkSchema, def, v.message), v.name);
|
||||
if (def === "entry") assert.deepEqual(entryRuleErrors(v.message), [], v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
for (const v of t[section].invalid) {
|
||||
assert.ok(!check(forkSchema, def, v.message) || (def === "entry" && entryRuleErrors(v.message).length > 0), `${v.name}: ${v.why}`);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
}
|
||||
for (const v of t.listInvalid) {
|
||||
const refused = !check(forkSchema, "list", v.message) || orderErrors(v.message.forks.map((f: any) => f.card)).length > 0;
|
||||
assert.ok(refused, `${v.name}: ${v.why}`);
|
||||
if (v.refusal !== undefined) assert.deepEqual(orderErrors(v.message.forks.map((f: any) => f.card)), [v.refusal], v.name);
|
||||
assert.equal(hexOf(v.message), v.messagePackHex, v.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("fork transport: every list request replays to the vector's response, newest first, paged by the cursor", () => {
|
||||
for (const sc of fork.transport.listScenarios) {
|
||||
for (const e of sc.entries) assert.deepEqual(entryRuleErrors(e), [], sc.name);
|
||||
assert.deepEqual(orderErrors(sc.entries.map((e: any) => e.card)), [], sc.name);
|
||||
let previous: { next_cursor: string | null } | null = null;
|
||||
for (const step of sc.steps) {
|
||||
const response = judgeList(sc.entries, sc.ordinal, step.request);
|
||||
assert.deepEqual(response, step.response, sc.name);
|
||||
assert.ok(check(forkSchema, "list", response), sc.name);
|
||||
assert.equal(hexOf(response), step.responseHex, sc.name);
|
||||
const orders = response.forks.map((f: any) => f.card.order);
|
||||
assert.deepEqual(orders, [...orders].sort((a: number, b: number) => b - a), sc.name);
|
||||
if (previous !== null) assert.equal(step.request.cursor, previous.next_cursor, `${sc.name}: the second request follows the first page's cursor`);
|
||||
previous = response;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("fork transport: order 0 is legal, no cursor names it, and the page ending at order 1 hands on to it", () => {
|
||||
const sc = fork.transport.listScenarios.find((x: any) => x.name === "order_zero_is_legal_but_no_cursor_names_it");
|
||||
assert.deepEqual(sc.steps.map((s: any) => s.response.next_cursor), ["c1", null]);
|
||||
assert.deepEqual(sc.steps[1].response.forks.map((f: any) => f.card.order), [0]);
|
||||
assert.equal(check(forkSchema, "cursor", "c0"), false);
|
||||
});
|
||||
|
||||
test("fork transport: a page may stop short of limit, and a first entry past the budget is fork_too_large (422) continued below its order", () => {
|
||||
const sc = fork.transport.listShortPages;
|
||||
assert.deepEqual(sc.map((x: any) => x.name), ["a_short_page_resumes_below_its_last_entry", "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", "fork_too_large_on_order_zero_leaves_nothing_below"]);
|
||||
assert.ok((forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum.includes("fork_too_large"));
|
||||
const covered: number[] = [];
|
||||
for (const s of sc) {
|
||||
let last: string | null | undefined = undefined;
|
||||
s.steps.forEach((step: any, i: number) => {
|
||||
if (last !== undefined) assert.equal(step.request.cursor, last, `${s.name}: the step follows the cursor or the refused order`);
|
||||
const full = judgeList(s.entries, s.ordinal, step.request);
|
||||
if (step.response !== undefined) {
|
||||
const r = step.response as any, n = r.forks.length;
|
||||
assert.ok(check(forkSchema, "list", r), s.name);
|
||||
assert.equal(hexOf(r), step.responseHex, s.name);
|
||||
assert.deepEqual(r.forks, full.forks.slice(0, n), `${s.name}: a prefix of the unbudgeted page`);
|
||||
assert.ok(n >= 1, s.name);
|
||||
assert.equal(r.next_cursor, full.next_cursor !== null || n < full.forks.length ? `c${r.forks[n - 1].card.order}` : null, s.name);
|
||||
assert.equal(r.stamp, full.stamp); assert.equal(r.open_count, full.open_count); assert.equal(r.recorded_count, full.recorded_count);
|
||||
covered.push(...r.forks.map((f: any) => f.card.order));
|
||||
last = r.next_cursor;
|
||||
} else {
|
||||
const r = step.refusal, first = full.forks[0].card as any;
|
||||
assert.ok(check(forkSchema, "refusal", r), s.name);
|
||||
assert.equal(hexOf(r), step.refusalHex, s.name);
|
||||
assert.equal(r.code, "fork_too_large");
|
||||
assert.equal(step.httpStatus, 422);
|
||||
assert.equal(step.fork_id, first.fork_id); assert.equal(step.order, first.order);
|
||||
assert.ok(r.error.includes(first.fork_id) && new RegExp(`\\b${first.order}\\b`).test(r.error.replace(first.fork_id, "")), `${s.name}: the text names the fork id and the order`);
|
||||
assert.ok(step.request.limit >= 1, "limit 0 never refuses");
|
||||
if (first.order >= 1) { assert.ok(i + 1 < s.steps.length, s.name); last = `c${first.order}`; assert.ok(check(forkSchema, "cursor", last)); }
|
||||
else { assert.equal(i, s.steps.length - 1, `${s.name}: order 0 is the end`); assert.equal(check(forkSchema, "cursor", "c0"), false); last = undefined; }
|
||||
}
|
||||
});
|
||||
}
|
||||
assert.deepEqual(sc[0].steps.map((x: any) => x.response.next_cursor), ["c2", null]);
|
||||
assert.deepEqual(sc[0].steps.map((x: any) => x.response.forks.length), [2, 2]);
|
||||
assert.deepEqual([sc[1].steps[0].order, sc[2].steps[1].order], [3, 0]);
|
||||
assert.deepEqual(covered, [3, 2, 1, 0, 2, 1, 0, 1]);
|
||||
});
|
||||
|
||||
test("fork transport: a read whose entry is past the cap is fork_too_large (422), named by fork id alone", () => {
|
||||
const doorStatus = fork.doorRefusals.find((r: any) => r.name === "fork_too_large").httpStatus;
|
||||
assert.equal(fork.transport.readTooLarge.length, 1);
|
||||
for (const c of fork.transport.readTooLarge) {
|
||||
const held = c.entries.find((e: any) => e.card.fork_id === c.request.fork_id);
|
||||
assert.ok(held !== undefined && check(forkSchema, "readRequest", c.request), c.name);
|
||||
assert.ok(check(forkSchema, "refusal", c.refusal), c.name);
|
||||
assert.equal(c.refusal.code, "fork_too_large");
|
||||
assert.ok(c.refusal.error.includes(held.card.fork_id), "the text names the fork id");
|
||||
assert.equal(hexOf(c.refusal), c.refusalHex, c.name);
|
||||
assert.equal(c.httpStatus, 422); assert.equal(c.httpStatus, doorStatus);
|
||||
}
|
||||
});
|
||||
|
||||
test("fork transport: a stale answer whose card re-read is past the cap is the read-form fork_too_large (422), over a stored Refused(stale_fork)", () => {
|
||||
assert.equal(fork.transport.answerTooLarge.length, 1);
|
||||
const read = fork.transport.readTooLarge[0];
|
||||
for (const c of fork.transport.answerTooLarge) {
|
||||
const held = c.entries.find((e: any) => e.card.fork_id === c.request.fork_id);
|
||||
assert.ok(held !== undefined && check(forkSchema, "answerRequest", c.request), c.name);
|
||||
assert.notEqual(c.request.options_hash, held.card.options_hash, "the card moved: the answer is stale");
|
||||
assert.ok(check(forkSchema, "refusal", c.refusal), c.name);
|
||||
assert.equal(c.refusal.code, "fork_too_large");
|
||||
assert.ok(c.refusal.error.includes(held.card.fork_id), "the text names the fork id");
|
||||
assert.equal(hexOf(c.refusal), c.refusalHex, c.name);
|
||||
assert.equal(c.refusal.code, read.refusal.code, "the same refusal as the read form");
|
||||
assert.equal(c.httpStatus, 422); assert.equal(c.httpStatus, fork.doorRefusals.find((r: any) => r.name === "fork_too_large").httpStatus);
|
||||
assert.equal(c.outcome, "Refused"); assert.equal(c.code, "stale_fork"); assert.equal(c.counted, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("fork transport: a door request_id_conflict is a fault on read and list (503 unavailable) and a refusal only on the answer", () => {
|
||||
assert.deepEqual(fork.transport.doorFaults.map((c: any) => c.route), ["read", "list", "answer"]);
|
||||
for (const c of fork.transport.doorFaults) {
|
||||
const source = c.route === "answer" ? fork.doorRefusals.find((r: any) => r.name === c.doorCode) : fork.unavailable[0];
|
||||
assert.equal(c.responseHex, source.messagePackHex, c.name);
|
||||
assert.equal(hexOf(c.response), c.responseHex, c.name);
|
||||
assert.equal(c.httpStatus, source.httpStatus, c.name);
|
||||
assert.ok(check(forkSchema, c.route === "answer" ? "refusal" : "unavailable", c.response), c.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("fork transport: auth is bearer, then audience, then scope, and a votes token touches nothing", () => {
|
||||
const codes = (forkSchema.$defs.refusal.properties.code as { enum: string[] }).enum;
|
||||
const seen = new Set<string>();
|
||||
for (const c of fork.transport.authCases) {
|
||||
const got = authJudge(c.route, c.token, c.huginn_down);
|
||||
assert.equal(got, c.expect, c.name);
|
||||
if (got !== "ok" && got !== "unavailable") assert.ok(codes.includes(got), c.name);
|
||||
seen.add(`${c.route}:${got}`);
|
||||
}
|
||||
for (const want of ["list:insufficient_scope", "answer:insufficient_scope", "read:forbidden", "list:unavailable", "read:unavailable", "answer:unavailable", "read:unauthenticated", "answer:ok"]) assert.ok(seen.has(want), want);
|
||||
});
|
||||
|
|
@ -367,6 +367,10 @@ async function bind(): Promise<Socket> {
|
|||
return socket;
|
||||
}
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
// Wait for a datagram-driven condition rather than guessing how long loopback delivery takes on a loaded runner.
|
||||
const until = async (condition: () => boolean, limitMs = 2_000) => {
|
||||
for (let waited = 0; !condition() && waited < limitMs; waited += 10) await sleep(10);
|
||||
};
|
||||
|
||||
test("server mode admits a pinned client that restarts on a new port", async () => {
|
||||
const serverSocket = await bind();
|
||||
|
|
@ -385,24 +389,24 @@ test("server mode admits a pinned client that restarts on a new port", async ()
|
|||
try {
|
||||
const first = session(1);
|
||||
a.send(encodeRudpPacket(first.createConnect(0, enc("join"))), port, "127.0.0.1");
|
||||
await sleep(100);
|
||||
await until(() => atA.some((packet) => packet.packetType === "accept"));
|
||||
const accept = atA.find((packet) => packet.packetType === "accept");
|
||||
assert.ok(accept);
|
||||
first.receive(accept, 0);
|
||||
a.send(encodeRudpPacket(first.createAckForReceived(accept.sequence)), port, "127.0.0.1");
|
||||
a.send(encodeRudpPacket(send(first, "hello")), port, "127.0.0.1");
|
||||
await sleep(100);
|
||||
await until(() => frames.length === 1);
|
||||
assert.deepEqual(frames, ["hello"]);
|
||||
|
||||
const second = session(1); // the process restarted on a new port, pinned
|
||||
b.send(encodeRudpPacket(second.createConnect(0, enc("join"))), port, "127.0.0.1");
|
||||
await sleep(100);
|
||||
await until(() => atB.some((packet) => packet.packetType === "accept"));
|
||||
const acceptB = atB.find((packet) => packet.packetType === "accept");
|
||||
assert.ok(acceptB, `the restarted client on a new port was not admitted: ${JSON.stringify(atB.map((packet) => packet.packetType))}`);
|
||||
second.receive(acceptB, 1);
|
||||
assert.ok(second.connected);
|
||||
b.send(encodeRudpPacket(send(second, "after")), port, "127.0.0.1");
|
||||
await sleep(100);
|
||||
await until(() => frames.length === 2);
|
||||
assert.deepEqual(frames, ["hello", "after"]);
|
||||
} finally {
|
||||
server.close();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { decode } from "@msgpack/msgpack";
|
||||
import {
|
||||
CULTNET_ERROR_CODES,
|
||||
CULTNET_ERROR_V1_CODES,
|
||||
|
|
@ -280,3 +281,59 @@ test("a blank messageId, record key or tag is exactly Unicode White_Space, not t
|
|||
parseCultNetMessage(put({ tags: [named] }));
|
||||
}
|
||||
});
|
||||
|
||||
// `admittedMessageId` vectors: a write_admitted messageId needs a letter or number and no Default_Ignorable code point,
|
||||
// compared raw (never trimmed). The D800 vector is the frame's bytes (wireHex), decoded by the msgpack library like the others.
|
||||
test("write_admitted messageId: every admittedMessageId vector is refused or accepted raw", () => {
|
||||
const vectorsPath = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json");
|
||||
const vectors = JSON.parse(readFileSync(vectorsPath, "utf8"));
|
||||
assert.equal(vectors.counts.admittedMessageIdCases, vectors.admittedMessageId.cases.length);
|
||||
const failures: string[] = [];
|
||||
for (const vector of vectors.admittedMessageId.cases as { name: string; codePoints: string[]; repeat?: number; wireHex?: string; expect: "refuse" | "accept" }[]) {
|
||||
const id = vector.codePoints.map((cp) => String.fromCodePoint(parseInt(cp, 16))).join("").repeat(vector.repeat ?? 1);
|
||||
let decodedId: string | undefined;
|
||||
try {
|
||||
const frame = vector.wireHex ? decode(Uint8Array.from(Buffer.from(vector.wireHex, "hex"))) : admitted(id);
|
||||
decodedId = (parseCultNetMessage(frame) as CultNetWriteAdmittedMessage).messageId;
|
||||
} catch {
|
||||
decodedId = undefined;
|
||||
}
|
||||
if ((decodedId === undefined) !== (vector.expect === "refuse")) failures.push(`${vector.name}: expected ${vector.expect}`);
|
||||
else if (decodedId !== undefined && decodedId !== id) failures.push(`${vector.name}: id not echoed raw`);
|
||||
}
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
// The generated ranges file is the independent copy of the embedded tables: first, last and the points either side of every range.
|
||||
test("write_admitted messageId: every range of the Unicode 16.0 tables is pinned at both ends", () => {
|
||||
const ranges = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-admitted-message-id-ranges.json"), "utf8"));
|
||||
assert.ok(ranges.letterOrNumber.length > 700);
|
||||
assert.equal(ranges.defaultIgnorable.length, 17);
|
||||
const accepted = (id: string) => { try { parseCultNetMessage(admitted(id)); return true; } catch { return false; } };
|
||||
const failures: string[] = [];
|
||||
for (const [low, high] of ranges.letterOrNumber as [number, number][]) {
|
||||
for (const [point, want] of [[low, true], [high, true], [low - 1, false], [high + 1, false]] as [number, boolean][]) {
|
||||
if (accepted(String.fromCodePoint(point)) !== want) failures.push(`U+${point.toString(16)} alone should be ${want ? "accepted" : "refused"}`);
|
||||
}
|
||||
}
|
||||
for (const [low, high] of ranges.defaultIgnorable as [number, number][]) {
|
||||
for (const [point, want] of [[low, false], [high, false], [low - 1, true], [high + 1, true]] as [number, boolean][]) {
|
||||
if (accepted("a" + String.fromCodePoint(point)) !== want) failures.push(`a + U+${point.toString(16)} should be ${want ? "accepted" : "refused"}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
// TypeScript decodes the frame to an object, so it cannot see a duplicate key: it judges the last one (admittedMessageIdDuplicates).
|
||||
// This pins @msgpack/msgpack's last-key behaviour plus the id check on the decoded object, not a first-key or duplicate path in
|
||||
// cultnet-ts source: there is none, so a refuse-on-first mutation in src would not be killed.
|
||||
test("write_admitted messageId: a repeated key follows the last-key-wins policy", () => {
|
||||
const vectors = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json"), "utf8"));
|
||||
const section = vectors.admittedMessageIdDuplicates;
|
||||
assert.equal(vectors.counts.admittedMessageIdDuplicateCases, section.cases.length);
|
||||
for (const vector of section.cases as { name: string; wireHex: string; lastKeyWins: "accept" | "refuse"; lastKeyWinsEcho?: string }[]) {
|
||||
const frame = decode(Uint8Array.from(Buffer.from(vector.wireHex, "hex")));
|
||||
if (vector.lastKeyWins === "refuse") assert.throws(() => parseCultNetMessage(frame), /./s, vector.name);
|
||||
else assert.equal((parseCultNetMessage(frame) as CultNetWriteAdmittedMessage).messageId, vector.lastKeyWinsEcho, vector.name);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
660
scripts/gen-governance-vectors.py
Normal file
660
scripts/gen-governance-vectors.py
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Writes contracts/cultnet/governance/{vote-api,fork-card}-vectors.json.
|
||||
|
||||
The bytes are spec-authored with msgpack-python (minimal ints, str8 only past 31 bytes, map keys in the order written here);
|
||||
each runtime's conformance test re-encodes the `message` and must produce the same hex. Run: python3 scripts/gen-governance-vectors.py
|
||||
Needs: pip install msgpack.
|
||||
"""
|
||||
import hashlib, json, pathlib, unicodedata
|
||||
import msgpack
|
||||
|
||||
OUT = pathlib.Path(__file__).resolve().parent.parent / "contracts" / "cultnet" / "governance"
|
||||
def hx(m): return msgpack.packb(m, use_bin_type=True).hex().upper()
|
||||
def sha(b): return hashlib.sha256(b).hexdigest()
|
||||
|
||||
M1 = "5b1c2e9a-3f4d-4c8e-9a61-0d7e2f4b8c13"
|
||||
M2 = "a7d3f0c2-6b15-4e29-8f3a-91c4d5e6f708"
|
||||
V1 = "0c9f6a4e-7d2b-4a35-b8e1-3f5a6c7d8e90"
|
||||
T0 = "2026-11-12T18:00:00Z"; T1 = "2026-11-15T18:00:00Z"; T2 = "2026-11-18T18:00:00Z"
|
||||
SALT = "d41d8cd98f00b204e9800998ecf8427e5f1c0a7b3e92d6a48b15c7f0e3a9d2b6"
|
||||
|
||||
# ---------- vote API ----------
|
||||
def commitment_bytes(motion, voter, persona, rnd, candidate, reason, salt):
|
||||
return f"gamecult.sealed_ballot.v0\n{motion}\n{voter}\n{persona}\n{rnd}\n{candidate}\n{reason}\n{salt}".encode("utf-8")
|
||||
|
||||
def ballot(motion=M1, rnd=1, candidate="Adopt the ledger", reason="It keeps the books honest.", salt=SALT, persona=""):
|
||||
return {"schema": "gamecult.sealed_ballot.v0", "motionId": motion, "round": rnd, "candidate": candidate,
|
||||
"reason": reason, "salt": salt, "persona": persona}
|
||||
def digest(b, voter=V1):
|
||||
return sha(commitment_bytes(b["motionId"], voter, b["persona"], b["round"], b["candidate"], b["reason"], b["salt"]))
|
||||
|
||||
B_MEMBER = ballot()
|
||||
B_PERSONA = ballot(persona="libby", candidate="Defer to round two", reason="The sealed window is too short to weigh both.")
|
||||
C_MEMBER = digest(B_MEMBER); C_PERSONA = digest(B_PERSONA)
|
||||
|
||||
def receipt(commitment, revision=1, revealed=False, motion=M1):
|
||||
return {"schemaVersion": "gamecult.vote.receipt.v0", "motionId": motion, "voterUserAccountId": V1,
|
||||
"commitment": commitment, "revision": revision, "committedAtUtc": "2026-11-12T19:04:11.1234567Z", "revealed": revealed}
|
||||
def motion(phase, your=None, mid=M1, high=False):
|
||||
return {"schemaVersion": "gamecult.vote.motion.v0", "id": mid, "title": "Ledger or lantern", "summary": "Pick one.",
|
||||
"round": 1, "priorMotionId": None, "ballotSchema": "gamecult.sealed_ballot.v0", "phase": phase,
|
||||
"candidates": ["Adopt the ledger", "Defer to round two"], "commitClosesAtUtc": T0, "closesAtUtc": T1, "highStakes": high,
|
||||
"yourVoterId": V1, "yourCommitment": your, "announcement": None}
|
||||
def cast_commit(c, motion=M1): return {"schemaVersion": "gamecult.vote.cast.v0", "motionId": motion, "verb": "commit", "commitment": c, "ballot": None}
|
||||
def cast_reveal(b): return {"schemaVersion": "gamecult.vote.cast.v0", "motionId": b["motionId"], "verb": "reveal", "commitment": None, "ballot": b}
|
||||
def refusal(code, error, retry=None): return {"schemaVersion": "gamecult.vote.refusal.v0", "code": code, "error": error, "retryAfterSeconds": retry}
|
||||
def item(i, title, phase, high=False): return {"id": i, "title": title, "round": 1, "phase": phase, "commitClosesAtUtc": T0, "closesAtUtc": T1, "highStakes": high}
|
||||
|
||||
valid = [
|
||||
("motion_list_request_all", "motionListRequest", {"schemaVersion": "gamecult.vote.motion_list_request.v0", "phase": None}),
|
||||
("motion_list_request_reveal", "motionListRequest", {"schemaVersion": "gamecult.vote.motion_list_request.v0", "phase": "Reveal"}),
|
||||
("motion_list_two", "motionList", {"schemaVersion": "gamecult.vote.motion_list.v0", "motions": [item(M1, "Ledger or lantern", "Commit"), item(M2, "Second thoughts", "Closed", True)]}),
|
||||
("motion_list_empty", "motionList", {"schemaVersion": "gamecult.vote.motion_list.v0", "motions": []}),
|
||||
("motion_read_request", "motionReadRequest", {"schemaVersion": "gamecult.vote.motion_read_request.v0", "motionId": M1}),
|
||||
("motion_commit_phase_no_receipt", "motion", motion("Commit")),
|
||||
("motion_high_stakes_commit_phase", "motion", motion("Commit", high=True)),
|
||||
("motion_reveal_phase_committed", "motion", motion("Reveal", receipt(C_MEMBER))),
|
||||
("motion_closed_revealed", "motion", motion("Closed", receipt(C_MEMBER, 2, True))),
|
||||
("cast_reveal_reason_140_astral_280_units", "cast", cast_reveal(ballot(reason="\U0001F600" * 140))),
|
||||
("cast_commit", "cast", cast_commit(C_MEMBER)),
|
||||
("cast_reveal_member", "cast", cast_reveal(B_MEMBER)),
|
||||
("cast_reveal_persona", "cast", cast_reveal(B_PERSONA)),
|
||||
("receipt_committed", "receipt", receipt(C_MEMBER)),
|
||||
("receipt_revealed_echoes_no_ballot", "receipt", receipt(C_MEMBER, 2, True)),
|
||||
]
|
||||
refusals = [
|
||||
("unauthenticated", 401, "no bearer, or one Heimdall does not know", refusal("unauthenticated", "A bearer token is required.")),
|
||||
("reauthentication_required", 401, "a commit on a high-stakes motion with no sign-in fresh enough (Bifrost PR #37, D2); the voter signs in again at the top level and retries", refusal("reauthentication_required", "Sign in again to commit on this motion.")),
|
||||
("forbidden", 403, "authenticated, not an Active member, or a Mind key used on a motion route it may not call", refusal("forbidden", "Only Active members may vote.")),
|
||||
("not_found", 404, "unknown motion id, a standard (non-sealed) motion id, or reveal without a prior commit", refusal("not_found", "No such sealed-ballot motion.")),
|
||||
("wrong_phase", 409, "commit outside Commit, reveal outside Reveal", refusal("wrong_phase", "The commit window is not open.")),
|
||||
("contended", 409, "reveal raced the close fence; send it again", refusal("contended", "The reveal is contended; send it again.")),
|
||||
("invalid", 400, "body fails vote-api.schema.json or an in-document rule (untrimmed candidate or reason, candidate not in the motion, round or motionId mismatch, persona wrong for the credential)", refusal("invalid", "The cast does not satisfy gamecult.vote.cast.v0.")),
|
||||
("commitment_mismatch", 400, "reveal whose canonical bytes do not hash to the stored commitment", refusal("commitment_mismatch", "The ballot does not match the stored commitment.")),
|
||||
("payload_too_large", 413, "body over 4096 bytes", refusal("payload_too_large", "The body exceeds 4096 bytes.")),
|
||||
("held", 503, "the Ratatoskr voting brake is set (Bifrost PR #37, D5): no cast is read or stored while it is", refusal("held", "Voting is held.")),
|
||||
("rate_limited", 429, "more than the per-account cast rate; Retry-After mirrors retryAfterSeconds", refusal("rate_limited", "Too many casts.", 30)),
|
||||
]
|
||||
def tweak(d, **kw):
|
||||
d = json.loads(json.dumps(d));
|
||||
for k, v in kw.items():
|
||||
if k.startswith("b_"): d["ballot"][k[2:]] = v
|
||||
else: d[k] = v
|
||||
return d
|
||||
rev = cast_reveal(B_MEMBER)
|
||||
invalid = [
|
||||
("cast_commit_carries_a_ballot", "cast", tweak(cast_commit(C_MEMBER), ballot=B_MEMBER), "a commit names exactly the commitment; a ballot beside it would put content on the wire before the reveal"),
|
||||
("cast_reveal_carries_a_commitment", "cast", tweak(rev, commitment=C_MEMBER), "a reveal names exactly the ballot"),
|
||||
("cast_commit_without_commitment", "cast", tweak(cast_commit(C_MEMBER), commitment=None), "commit needs its hash"),
|
||||
("cast_verb_unknown", "cast", tweak(rev, verb="recast"), "verb is commit or reveal"),
|
||||
("cast_commitment_uppercase", "cast", cast_commit(C_MEMBER.upper()), "commitment is lowercase hex"),
|
||||
("cast_commitment_63_chars", "cast", cast_commit(C_MEMBER[:63]), "commitment is 64 hex characters"),
|
||||
("cast_unknown_key", "cast", tweak(cast_commit(C_MEMBER), note="hi"), "closed document"),
|
||||
("cast_motion_id_uppercase", "cast", cast_commit(C_MEMBER, M1.upper()), "ids are lowercase D-format uuids"),
|
||||
("ballot_reason_empty", "cast", tweak(rev, b_reason=""), "reason is 1 to 280 characters"),
|
||||
("ballot_reason_281", "cast", tweak(rev, b_reason="x" * 281), "reason is at most 280 characters"),
|
||||
("ballot_reason_141_astral_282_units", "cast", tweak(rev, b_reason="\U0001F600" * 141), "reason is at most 280 UTF-16 code units (vote-api.md); the schema counts code points and accepts this, so a rule refuses it"),
|
||||
("ballot_reason_multiline", "cast", tweak(rev, b_reason="one\ntwo"), "reason is a single line"),
|
||||
("ballot_reason_leading_space", "cast", tweak(rev, b_reason=" because"), "the contract refuses untrimmed text; it never trims for the sender, so the hashed bytes are the sent bytes"),
|
||||
("ballot_candidate_trailing_nbsp", "cast", tweak(rev, b_candidate="Adopt the ledger "), "NO-BREAK SPACE is White_Space; trimming is judged by the same list as write-receipt Required fields"),
|
||||
("ballot_salt_31_hex", "cast", tweak(rev, b_salt=SALT[:31]), "salt is 32 to 128 lowercase hex characters"),
|
||||
("ballot_salt_uppercase", "cast", tweak(rev, b_salt=SALT.upper()), "salt is lowercase hex"),
|
||||
("ballot_schema_wrong", "cast", tweak(rev, b_schema="gamecult.sealed_ballot.v1"), "ballot schema is gamecult.sealed_ballot.v0"),
|
||||
("ballot_persona_with_space", "cast", tweak(rev, b_persona="my mind"), "persona is printable ASCII without space, at most 64"),
|
||||
("ballot_round_zero", "cast", tweak(rev, b_round=0), "rounds start at 1"),
|
||||
("receipt_echoes_candidate", "receipt", dict(receipt(C_MEMBER, 2, True), candidate="Adopt the ledger"), "sealed casts never echo ballot content; the receipt is closed"),
|
||||
("receipt_echoes_salt", "receipt", dict(receipt(C_MEMBER, 2, True), salt=SALT), "the salt never comes back before close"),
|
||||
("receipt_revision_zero", "receipt", receipt(C_MEMBER, 0), "revision starts at 1"),
|
||||
("receipt_time_without_zone", "receipt", dict(receipt(C_MEMBER), committedAtUtc="2026-11-12T19:04:11"), "instants are UTC with a Z"),
|
||||
("motion_one_candidate", "motion", dict(motion("Commit"), candidates=["Only"]), "a motion has two or more candidates"),
|
||||
("motion_phase_unknown", "motion", dict(motion("Commit"), phase="Open"), "phase is Commit, Reveal or Closed"),
|
||||
("motion_high_stakes_not_boolean", "motion", dict(motion("Commit"), highStakes="yes"), "highStakes is a boolean, fixed at open"),
|
||||
("motion_without_high_stakes", "motion", {k: v for k, v in motion("Commit").items() if k != "highStakes"}, "map-mode writes every key; a motion states whether it is high-stakes"),
|
||||
("motion_list_item_without_high_stakes", "motionListItem", {k: v for k, v in item(M1, "x", "Commit").items() if k != "highStakes"}, "every list item states whether the motion is high-stakes"),
|
||||
("motion_leaks_tally", "motion", dict(motion("Reveal"), tally=[]), "mid-vote a client sees no tallies; the document is closed"),
|
||||
("motion_list_item_without_phase", "motionList", {"schemaVersion": "gamecult.vote.motion_list.v0", "motions": [{k: v for k, v in item(M1, "x", "Commit").items() if k != "phase"}]}, "every item names its phase"),
|
||||
("refusal_code_unknown", "refusal", refusal("teapot", "no"), "the code set is closed"),
|
||||
("refusal_without_error", "refusal", dict(refusal("invalid", "x"), error=""), "error is non-empty"),
|
||||
]
|
||||
pairs = [
|
||||
{"name": "member_commit_matches_reveal", "voterUserAccountId": V1, "commit": cast_commit(C_MEMBER), "reveal": cast_reveal(B_MEMBER), "matches": True},
|
||||
{"name": "persona_commit_matches_reveal", "voterUserAccountId": V1, "commit": cast_commit(C_PERSONA), "reveal": cast_reveal(B_PERSONA), "matches": True},
|
||||
{"name": "reveal_with_changed_reason_does_not_match", "voterUserAccountId": V1, "commit": cast_commit(C_MEMBER), "reveal": cast_reveal(ballot(reason="It keeps the books honest!")), "matches": False, "refusal": "commitment_mismatch"},
|
||||
{"name": "reveal_under_another_voter_does_not_match", "voterUserAccountId": M2, "commit": cast_commit(C_MEMBER), "reveal": cast_reveal(B_MEMBER), "matches": False, "refusal": "commitment_mismatch"},
|
||||
{"name": "persona_dropped_does_not_match", "voterUserAccountId": V1, "commit": cast_commit(C_PERSONA), "reveal": cast_reveal(ballot(persona="", candidate=B_PERSONA["candidate"], reason=B_PERSONA["reason"])), "matches": False, "refusal": "commitment_mismatch"},
|
||||
]
|
||||
commitments = []
|
||||
for name, b, voter in [("member", B_MEMBER, V1), ("persona", B_PERSONA, V1), ("non_ascii", ballot(candidate="Café ☕", reason="naïve – but fine"), V1),
|
||||
("decomposed_e_is_another_ballot", ballot(candidate="Café ☕", reason="naïve – but fine"), V1)]:
|
||||
cb = commitment_bytes(b["motionId"], voter, b["persona"], b["round"], b["candidate"], b["reason"], b["salt"])
|
||||
commitments.append({"name": name, "voterUserAccountId": voter, "ballot": b, "canonicalHex": cb.hex().upper(), "sha256": sha(cb)})
|
||||
|
||||
vote = {
|
||||
"provenance": "Spec-authored with msgpack-python (scripts/gen-governance-vectors.py); every runtime re-encodes `message` and must match messagePackHex. Commitment hashes follow vote-api.md 'Commitment'.",
|
||||
"commitment": commitments,
|
||||
"valid": [{"name": n, "def": d, "message": m, "messagePackHex": hx(m)} for n, d, m in valid],
|
||||
"invalid": [{"name": n, "def": d, "message": m, "why": w, "messagePackHex": hx(m)} for n, d, m, w in invalid],
|
||||
"pairs": pairs,
|
||||
"refusals": [{"name": n, "httpStatus": s, "trigger": t, "message": m, "messagePackHex": hx(m)} for n, s, t, m in refusals],
|
||||
}
|
||||
(OUT / "vote-api-vectors.json").write_text(json.dumps(vote, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
# ---------- fork card ----------
|
||||
# Rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md (Heimdall), sections 1.1 to 1.5 and 1.3a. The seven hash
|
||||
# vectors A to G below are that file's, with its expected digests asserted.
|
||||
def jstr(x):
|
||||
out = ['"']
|
||||
for ch in x:
|
||||
o = ord(ch)
|
||||
if ch == '"': out.append('\\"')
|
||||
elif ch == "\\": out.append("\\\\")
|
||||
elif ch == "\b": out.append("\\b")
|
||||
elif ch == "\t": out.append("\\t")
|
||||
elif ch == "\n": out.append("\\n")
|
||||
elif ch == "\f": out.append("\\f")
|
||||
elif ch == "\r": out.append("\\r")
|
||||
elif o < 0x20: out.append("\\u%04x" % o)
|
||||
else: out.append(ch)
|
||||
out.append('"')
|
||||
return "".join(out)
|
||||
def nfc(x): return unicodedata.normalize("NFC", x)
|
||||
def canonical_json(title, context, options, recommendation, depends):
|
||||
"""RFC 8785 over {context, depends, options:[{id,text}], recommendation, title} after NFC on every string; keys sorted; no numbers. (None is accepted only to check Heimdall's null digests.)"""
|
||||
opts = ",".join('{"id":%s,"text":%s}' % (jstr(nfc(o["id"])), jstr(nfc(o["text"]))) for o in options)
|
||||
dep = ",".join(jstr(nfc(d)) for d in depends)
|
||||
rec = "null" if recommendation is None else jstr(nfc(recommendation))
|
||||
return ('{"context":%s,"depends":[%s],"options":[%s],"recommendation":%s,"title":%s}' % (jstr(nfc(context)), dep, opts, rec, jstr(nfc(title)))).encode("utf-8")
|
||||
|
||||
OP = "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f"; ST = "11111111-2222-4333-8444-555555555555"; HM = "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a"
|
||||
FID = "gamecult-chat:question:slot-mark-consent"
|
||||
OPT = [{"id": "yes", "text": "Share it."}, {"id": "no", "text": "Keep it local."}]
|
||||
DEP = ["gamecult-chat:question:file-zone-audience"]
|
||||
def chash(c): return sha(canonical_json(c["title"], c["context"], c["options"], c["recommendation"], c["depends"]))
|
||||
def card(**kw):
|
||||
c = {"schemaVersion": "gamecult.fork.card.v0", "fork_id": FID, "title": "Slot mark consent", "context": "Should a slot mark be shared?",
|
||||
"options": OPT, "recommendation": "no", "depends": DEP, "leans": None, "flag": None,
|
||||
"raised_in": {"kind": "CutSpec", "id": "cultlib-gaps:cut_spec:cut-slot-tolerance.r4"}, "deciders": [OP],
|
||||
"status": "open", "ruling": None, "ruled_from": [], "order": 3, "options_hash": ""}
|
||||
c.update(kw); c["options_hash"] = chash(c); return c
|
||||
CARD = card(); H = CARD["options_hash"]
|
||||
|
||||
hashes = []
|
||||
CLAIMED = {"A": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", "B": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc",
|
||||
"C": "ac67b41539f869f6203ac0908819df046e4328b27795743a919bd4ab390596b5", "D": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272",
|
||||
"E": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272", "F": "fb53aa3a9f7e74029f755d774064eedd240ab90d8fc64723f97c31b3d2846d40",
|
||||
"G": "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86"}
|
||||
def hv(name, c, heimdall=None):
|
||||
b = canonical_json(c["title"], c["context"], c["options"], c["recommendation"], c["depends"])
|
||||
if heimdall: print("heimdall", heimdall, "same" if CLAIMED[heimdall] == sha(b) else f"DIFFERS mine={sha(b)} his={CLAIMED[heimdall]}")
|
||||
hashes.append({"name": name, "heimdall": heimdall, "title": c["title"], "context": c["context"], "options": c["options"],
|
||||
"recommendation": c["recommendation"], "depends": c["depends"], "canonical": b.decode("utf-8"),
|
||||
"canonicalHex": b.hex().upper(), "sha256": sha(b)})
|
||||
CD = dict(title="Caf\u00e9 \u201cdesk\u201d", context="Line one\nLine two \u2014 \U0001F600", options=[{"id": "a", "text": "x"}, {"id": "b", "text": "y"}], recommendation="a", depends=[])
|
||||
hv("heimdall_A_five_members", CARD, "A")
|
||||
hv("heimdall_B_recommendation_changed", card(recommendation="yes"), "B")
|
||||
hv("heimdall_C_option_order_changed", card(options=[OPT[1], OPT[0]]), "C")
|
||||
hv("heimdall_D_nfc_escapes_astral", card(**CD), "D")
|
||||
hv("heimdall_E_decomposed_equals_D", card(**dict(CD, title="Cafe\u0301 \u201cdesk\u201d")), "E")
|
||||
hv("heimdall_F_option_id_changed", card(options=[{"id": "share", "text": "Share it."}, OPT[1]]), "F")
|
||||
hv("heimdall_G_depends_emptied", card(depends=[]), "G")
|
||||
hv("option_text_changed", card(options=[dict(OPT[0], text="Share it!"), OPT[1]]))
|
||||
hv("title_changed", card(title="Slot mark consent?"))
|
||||
hv("context_changed", card(context="Should slot marks be shared?"))
|
||||
hv("depends_order_changed", card(depends=DEP + ["x:question:y"]))
|
||||
hv("depends_order_changed_reversed", card(depends=["x:question:y"] + DEP))
|
||||
hv("hidden_members_do_not_move_it", card(leans="yes", flag="provisional", deciders=[OP, HM], status="closed", ruling="r:1", ruled_from=["rc-1"], order=9, raised_in=None, fork_id="other:question:id"))
|
||||
hv("control_characters_and_markup_are_escaped_minimally", card(title='q " b \\ t \t n \n bell \u0007 del \u007f', context="</script> \u2028 &"))
|
||||
hv("astral_option_id_and_text", card(options=[{"id": "\U0001F600", "text": "\U0001D11E"}, OPT[1]]))
|
||||
assert [h["sha256"] for h in hashes if h["heimdall"] == "D"] == [h["sha256"] for h in hashes if h["heimdall"] == "E"]
|
||||
assert len({h["sha256"] for h in hashes if h["name"] in ("heimdall_A_five_members", "hidden_members_do_not_move_it")}) == 1
|
||||
|
||||
RID = [0]
|
||||
def next_rid():
|
||||
RID[0] += 1; n = RID[0]
|
||||
return f"{n:08x}-0000-4000-8000-{n:012x}"
|
||||
def body(choice=None, note=None, h=None, at="2026-11-12T19:02:00Z", fork=FID, retracts=None, answer="auto", rid=None):
|
||||
a = {"choice": choice, "note": note} if answer == "auto" else answer
|
||||
if answer == "auto" and choice is None and note is None: a = None
|
||||
return {"schemaVersion": "gamecult.fork.answer_request.v0", "fork_id": fork, "options_hash": h or H, "answer": a,
|
||||
"retracted": retracts is not None, "retracts": retracts, "answered_at": at, "request_id": rid or next_rid()}
|
||||
def retraction(target, **kw): return body(retracts=target, **kw)
|
||||
NOTE = "Keep it local: a slot mark is the author's call, and sharing it is a separate act."
|
||||
def srv(aid, who, at, auth="Operator"): return {"answer_id": aid, "answerer": who, "authority": auth, "received_at": at}
|
||||
TAIL = {"unauthenticated": "a verified session is required", "body_forbidden_field": "answerer and authority are set by the receiver", "body_unknown_field": "the body is a closed shape",
|
||||
"body_invalid": "the body does not satisfy gamecult.fork.answer_request.v0", "fork_unknown": "no such fork",
|
||||
"insufficient_scope": "the token lacks the scope this route needs", "forbidden": "only Active members may use the desk", "request_id_conflict": "this request_id was used for a different body", "request_invalid": "the request does not satisfy its document"}
|
||||
DOOR = set(TAIL)
|
||||
def refusal_doc(code): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": code, "error": f"{code}: {TAIL[code]}"}
|
||||
def unavailable_doc(retry=30): return {"schemaVersion": "gamecult.fork.unavailable.v0", "code": "unavailable", "error": "unavailable: the outcome is not confirmed, try again with the same request_id", "retryAfterSeconds": retry}
|
||||
def future(a, r):
|
||||
import datetime as dt
|
||||
p = lambda x: dt.datetime.fromisoformat(x.replace("Z", "+00:00"))
|
||||
return (p(a) - p(r)).total_seconds() > 300
|
||||
def da(outcome, link, code, b, server):
|
||||
a = b["answer"] or {"choice": None, "note": None}
|
||||
return {"schemaVersion": "gamecult.desk_answer.v0", "answer_id": server["answer_id"], "fork_id": b["fork_id"], "outcome": outcome,
|
||||
"of": link if outcome == "Revision" else None, "retracts": link if outcome == "Retraction" else (b["retracts"] if outcome == "Refused" else None), "code": code if outcome == "Refused" else None,
|
||||
"options_hash": b["options_hash"], "choice": a["choice"], "note": a["note"], "answered_at": b["answered_at"], "received_at": server["received_at"],
|
||||
"claim_in_future": future(b["answered_at"], server["received_at"]), "answerer": server["answerer"], "authority": server["authority"], "request_id": b["request_id"]}
|
||||
def result(answer, c=None): return {"schemaVersion": "gamecult.fork.answer_result.v0", "answer": answer, "card": c, "counted_answer_id": None} # sc() fills counted_answer_id
|
||||
def record(counted, answers, h=H, fork=FID): return {"schemaVersion": "gamecult.fork.record.v0", "fork_id": fork, "options_hash": h, "counted_answer_id": counted, "answers": answers}
|
||||
T = lambda m, sec=5: f"2026-11-12T19:{m:02d}:{sec:02d}Z"
|
||||
def step(kind, body_=None, server=None, response=None, **kw):
|
||||
d = {"kind": kind, "body": body_, "server": server, "response": response}; d.update(kw); return d
|
||||
def ok(b, server, outcome, of):
|
||||
a = da(outcome, of, None, b, server); return step("answer", b, server, result(a)), a
|
||||
def rf(b, server, code, c=None):
|
||||
a = da("Refused", None, code, b, server); return step("answer", b, server, result(a, c)), a
|
||||
def door(b, server, code): return step("answer", b, server, refusal_doc(code)), None
|
||||
def sc(name, c, items, counted, h=None):
|
||||
# answer_result.counted_answer_id: the counted answer after the step, derived from the answers stored so far under the card's current hash
|
||||
steps, answers, cur = [], [], c["options_hash"]
|
||||
def live(): return next((a["answer_id"] for a in answers if a["outcome"] in ("Counted", "Revision") and a["options_hash"] == cur
|
||||
and not any(r["outcome"] == "Retraction" and r["retracts"] == a["answer_id"] for r in answers)), None)
|
||||
for i in items:
|
||||
st = i[0] if isinstance(i, tuple) else i
|
||||
if isinstance(i, tuple) and i[1] is not None: answers.append(i[1])
|
||||
if st["kind"] == "card": cur = st["card"]["options_hash"]
|
||||
if st["kind"] == "answer" and (st["response"] or {}).get("schemaVersion") == "gamecult.fork.answer_result.v0":
|
||||
st = dict(st, response=dict(st["response"], counted_answer_id=live()))
|
||||
steps.append(st)
|
||||
assert live() == counted, (name, live(), counted)
|
||||
return (name, c, steps, record(counted, answers, h or c["options_hash"]))
|
||||
|
||||
x1 = body("no", NOTE); sx1 = srv("n1", OP, T(2))
|
||||
x2 = body("yes", "On reflection, share it.", at="2026-11-12T19:06:00Z"); sx2 = srv("n2", OP, T(6))
|
||||
y1 = body("yes", "Share it, the audience is settled.", at="2026-11-12T19:05:00Z"); sy1 = srv("n3", HM, T(5), auth="Steward")
|
||||
z1 = body("yes", "Share, please."); sz = srv("n4", ST, T(3), auth="Member")
|
||||
pick = body("no", None); words = body(None, NOTE)
|
||||
STALE_H = chash(card(recommendation="yes"))
|
||||
def stale_body(**kw): return body("no", NOTE, h=STALE_H, **kw)
|
||||
FORKC = card(status="closed", ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1")
|
||||
RULED = card(status="closed", ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1", ruled_from=["n1"])
|
||||
Y_CARD = card(deciders=[OP, HM])
|
||||
fork_edit = card(title="Slot mark consent (revised)"); H2 = fork_edit["options_hash"]
|
||||
RET = lambda t: retraction(t, at="2026-11-12T19:09:00Z")
|
||||
def sr(aid, who=OP, m=9, auth="Operator"): return srv(aid, who, T(m), auth)
|
||||
MATRIX = [step("matrix_event", None, None, None, matrix_kind=k, choice=c) for k, c in (("m.replace", "yes"), ("m.redaction", None), ("m.reaction", "yes"))]
|
||||
X1 = ok(x1, sx1, "Counted", None)
|
||||
REPLAY1 = (step("answer", x1, dict(sx1, answer_id="n9"), X1[0]["response"]), None)
|
||||
LOST = [(step("answer", x1, dict(sx1, door_down=True, stored=True, retry_after_seconds=30), unavailable_doc(30)), X1[1])]
|
||||
STALE_BODY = stale_body(); STALE_RF = rf(STALE_BODY, sx1, "stale_fork", c=CARD)
|
||||
OFFLIST = rf(x1, srv("n1", ST, T(2), "Member"), "not_a_decider")
|
||||
OFFLIST_REPLAY = (step("answer", x1, srv("n9", ST, T(4), "Member"), OFFLIST[0]["response"]), None)
|
||||
scen = [
|
||||
sc("operator_answers_and_is_counted", CARD, [X1], "n1"),
|
||||
sc("choice_alone_is_counted", CARD, [ok(pick, sx1, "Counted", None)], "n1"),
|
||||
sc("note_alone_is_counted_a_clear_pick", CARD, [ok(words, sx1, "Counted", None)], "n1"),
|
||||
sc("V1_answerer_in_the_body_is_refused_at_the_door_and_stores_nothing", CARD, [door(dict(x1, answerer="x"), sx1, "body_forbidden_field")], None),
|
||||
sc("V1_authority_nested_in_answer_is_refused", CARD, [door(dict(x1, answer={"choice": "no", "note": None, "authority": "Operator"}), sx1, "body_forbidden_field")], None),
|
||||
sc("V1_both_are_refused_not_stripped", CARD, [door(dict(x1, answerer=OP, authority="Operator"), sx1, "body_forbidden_field")], None),
|
||||
sc("M5_a_body_naming_deciders_is_an_unknown_key_the_decider_list_is_never_the_bodys", CARD, [door(dict(x1, deciders=[ST]), sx1, "body_unknown_field")], None),
|
||||
sc("M1_a_body_naming_a_mind_is_an_unknown_key_the_mind_is_the_routes", CARD, [door(dict(x1, mind="nibu"), sx1, "body_unknown_field")], None),
|
||||
sc("an_unknown_key_is_refused_the_body_is_closed", CARD, [door(dict(x1, mood="happy"), sx1, "body_unknown_field")], None),
|
||||
sc("an_answer_with_neither_choice_nor_note_is_invalid", CARD, [door(body(answer={"choice": None, "note": None}), sx1, "body_invalid")], None),
|
||||
sc("a_non_retraction_without_an_answer_is_invalid", CARD, [door(body(answer=None), sx1, "body_invalid")], None),
|
||||
sc("unavailable_before_huginn_stored_nothing_is_retried_into_a_Counted_answer", CARD, [step("answer", x1, dict(sx1, answer_id="n9", door_down=True, retry_after_seconds=30), unavailable_doc(30)), ok(x1, sx1, "Counted", None)], "n1"),
|
||||
sc("unavailable_after_huginn_stored_the_retry_with_the_same_request_id_returns_the_stored_outcome", CARD, LOST + [REPLAY1], "n1"),
|
||||
sc("a_retry_never_adds_a_Revision", CARD, [X1, REPLAY1], "n1"),
|
||||
sc("Q1_a_replayed_stale_fork_returns_the_CURRENT_card_and_stores_nothing_new", CARD, [STALE_RF, step("card", None, None, None, card=fork_edit),
|
||||
(step("answer", STALE_BODY, dict(sx1, answer_id="n9"), result(STALE_RF[1], fork_edit)), None)], None, H2),
|
||||
sc("V13_only_answered_at_changed_is_request_id_conflict", CARD, [X1, door(dict(x1, answered_at="2026-11-12T19:03:00Z"), dict(sx1, answer_id="n9"), "request_id_conflict")], "n1"),
|
||||
sc("V14_an_identical_body_from_another_account_gets_its_own_Counted_and_never_the_first_accounts_outcome", Y_CARD, [OFFLIST, ok(x1, srv("n2", HM, T(3), "Steward"), "Counted", None), OFFLIST_REPLAY], "n2"),
|
||||
sc("V14_an_identical_body_from_another_decider_is_its_own_Revision_and_the_first_retry_still_returns_n1", Y_CARD, [X1, ok(x1, sy1, "Revision", "n1"), (step("answer", x1, dict(sx1, answer_id="n9"), X1[0]["response"]), None)], "n1"),
|
||||
sc("V16_Q5_a_forbidden_request_binds_no_request_id", CARD, [door(x1, dict(sx1, answer_id="n9", active=False), "forbidden"), ok(x1, sx1, "Counted", None)], "n1"),
|
||||
sc("V17_Q6_a_body_refused_at_the_door_binds_no_request_id", CARD, [door(dict(x1, mood="happy"), dict(sx1, answer_id="n9"), "body_unknown_field"), ok(x1, sx1, "Counted", None)], "n1"),
|
||||
sc("V17_Q6_an_unknown_fork_binds_no_request_id", CARD, [door(dict(x1, fork_id="gamecult-chat:question:nope"), dict(sx1, answer_id="n9"), "fork_unknown"), ok(x1, sx1, "Counted", None)], "n1"),
|
||||
sc("the_same_request_id_with_another_body_is_request_id_conflict", CARD, [X1, door(dict(x1, answer={"choice": "yes", "note": None}), dict(sx1, answer_id="n9"), "request_id_conflict")], "n1"),
|
||||
sc("the_same_request_id_from_another_account_is_another_request", Y_CARD, [X1, ok(dict(y1, request_id=x1["request_id"]), sy1, "Revision", "n1")], "n1"),
|
||||
sc("a_non_active_member_is_forbidden_before_the_body_is_read", CARD, [door(dict(x1, answerer="x"), dict(sx1, active=False), "forbidden"), door(x1, dict(sx1, active=False), "forbidden"),
|
||||
door(dict(x1, mood="happy"), dict(sx1, active=False, door_down=True, retry_after_seconds=30), "forbidden")], None),
|
||||
sc("no_session_is_unauthenticated", CARD, [door(x1, srv("n1", None, T(2), None), "unauthenticated")], None),
|
||||
sc("an_unknown_fork_is_refused_at_the_door", CARD, [door(body("no", NOTE, fork="gamecult-chat:question:nope"), sx1, "fork_unknown")], None),
|
||||
sc("V2_stale_hash_is_refused_not_counted_and_stored_as_Refused", CARD, [STALE_RF], None),
|
||||
sc("V2_a_stale_answer_then_a_fresh_one_the_fresh_one_is_counted", CARD, [rf(stale_body(), sx1, "stale_fork", c=CARD), ok(x1, sx2, "Counted", None)], "n2"),
|
||||
sc("V3_an_answer_off_the_decider_list_is_Refused_not_a_decider_and_stored", CARD, [rf(z1, sz, "not_a_decider")], None),
|
||||
sc("a_non_decider_with_a_stale_hash_is_stale_first", CARD, [rf(dict(z1, options_hash=STALE_H), sz, "stale_fork", c=CARD)], None),
|
||||
sc("V4_a_closed_fork_refuses_and_stores_the_refusal", FORKC, [rf(x1, sx1, "fork_closed")], None),
|
||||
sc("V5_second_answer_from_one_decider_is_a_Revision_the_first_stays_counted", CARD, [X1, ok(x2, sx2, "Revision", "n1")], "n1"),
|
||||
sc("V5_receiver_order_decides_not_answered_at", CARD, [ok(x2, srv("n1", OP, T(2)), "Counted", None), ok(x1, srv("n2", OP, T(3)), "Revision", "n1")], "n1"),
|
||||
sc("V5_reversed_arrival_reverses_the_result", CARD, [ok(x1, srv("n1", OP, T(2)), "Counted", None), ok(x2, srv("n2", OP, T(3)), "Revision", "n1")], "n1"),
|
||||
sc("a_second_decider_answer_is_recorded_as_a_Revision", Y_CARD, [X1, ok(y1, sy1, "Revision", "n1")], "n1"),
|
||||
sc("a_claim_exactly_five_minutes_ahead_is_not_flagged", CARD, [ok(body("no", NOTE, at="2026-11-12T19:07:05Z"), sx1, "Counted", None)], "n1"),
|
||||
sc("a_claim_five_minutes_and_half_a_second_ahead_is_flagged_from_the_stored_received_at", CARD, [ok(body("no", NOTE, at="2026-11-12T19:07:05.500Z"), sx1, "Counted", None)], "n1"),
|
||||
sc("a_claim_more_than_five_minutes_ahead_is_accepted_and_flagged", CARD, [ok(body("no", NOTE, at="2026-11-12T19:09:00Z"), sx1, "Counted", None)], "n1"),
|
||||
sc("V6_a_matrix_edit_redaction_or_reaction_changes_nothing", CARD, [X1] + MATRIX, "n1"),
|
||||
sc("V7_a_choice_that_is_not_an_option_is_Refused_choice_invalid", CARD, [rf(body("maybe", None), sx1, "choice_invalid")], None),
|
||||
sc("a_changed_card_stops_the_old_answers_counting_and_a_new_answer_is_counted", CARD,
|
||||
[X1, step("card", None, None, None, card=fork_edit), ok(body("no", NOTE, h=H2), sx2, "Counted", None)], "n2", H2),
|
||||
sc("a_changed_card_leaves_no_counted_answer_until_one_arrives", CARD, [X1, step("card", None, None, None, card=fork_edit)], None, H2),
|
||||
sc("V8_retraction_with_an_answer_is_bad", CARD, [X1, rf(dict(retraction("n1"), answer={"choice": "no", "note": None}), sr("n9"), "bad_retraction")], "n1"),
|
||||
sc("V8_retraction_without_retracts_is_bad", CARD, [X1, rf(dict(retraction("n1"), retracts=None), sr("n9"), "bad_retraction")], "n1"),
|
||||
sc("V8_retracts_on_a_plain_answer_is_bad", CARD, [X1, rf(dict(x2, retracts="n1"), sr("n9"), "bad_retraction")], "n1"),
|
||||
sc("V8_retraction_naming_an_unknown_answer_is_bad", CARD, [X1, rf(retraction("n99"), sr("n9"), "bad_retraction")], "n1"),
|
||||
sc("V8_retraction_naming_a_Refused_answer_is_bad", CARD, [rf(stale_body(), sx1, "stale_fork", c=CARD), rf(retraction("n1"), sr("n9"), "bad_retraction")], None),
|
||||
sc("V8_retracting_twice_is_already_retracted", CARD, [X1, ok(RET("n1"), sr("n8"), "Retraction", "n1"), rf(RET("n1"), sr("n9", m=10), "already_retracted")], None),
|
||||
sc("V8_retraction_under_a_stale_hash_is_stale_fork", CARD, [X1, rf(retraction("n1", h=STALE_H), sr("n9"), "stale_fork", c=CARD)], "n1"),
|
||||
sc("V9_another_decider_retracts_and_the_answer_stops_counting", Y_CARD, [X1, ok(RET("n1"), sr("n7", HM, 9, "Steward"), "Retraction", "n1")], None),
|
||||
sc("V9_an_account_off_the_list_cannot_retract", CARD, [X1, rf(RET("n1"), sr("n6", ST, 9, "Member"), "not_a_decider")], "n1"),
|
||||
sc("V10a_a_ruling_that_closed_the_fork_makes_a_retraction_fork_closed", CARD, [X1, step("card", None, None, None, card=RULED), rf(RET("n1"), sr("n9"), "fork_closed")], "n1"),
|
||||
sc("V11_retracting_the_counted_answer_makes_the_next_decider_answer_counted", Y_CARD, [X1, ok(y1, sy1, "Revision", "n1"), ok(RET("n1"), sr("n8"), "Retraction", "n1")], "n3"),
|
||||
sc("V11_retracting_an_answer_that_was_not_counted_changes_nothing", Y_CARD, [X1, ok(y1, sy1, "Revision", "n1"), ok(RET("n3"), sr("n8"), "Retraction", "n3")], "n1"),
|
||||
sc("after_every_answer_is_retracted_a_fresh_answer_is_Counted_again", CARD, [X1, ok(RET("n1"), sr("n8"), "Retraction", "n1"), ok(x2, sx2, "Counted", None)], "n2"),
|
||||
]
|
||||
# The three illegal forms (R1) under fixed request ids (a fresh next_rid() here would move every later vector). A and C differ only in `retracted`.
|
||||
IL_A = dict(retraction("n1", rid="0000a001-0000-4000-8000-00000000a001"), answer={"choice": "no", "note": None}) # a retraction carrying an answer
|
||||
IL_B = dict(retraction("n1", rid="0000b001-0000-4000-8000-00000000b001"), retracts=None) # a retraction naming no target
|
||||
IL_C = dict(body("no", None, rid="0000a001-0000-4000-8000-00000000a001"), retracts="n1") # a plain answer carrying a target
|
||||
assert {k: v for k, v in IL_A.items() if k != "retracted"} == {k: v for k, v in IL_C.items() if k != "retracted"} and IL_A["retracted"] and not IL_C["retracted"]
|
||||
def replayed(first, server): return (step("answer", first[0]["body"], dict(server, answer_id="n9"), first[0]["response"]), None)
|
||||
for _n, _f in (("a_retraction_with_an_answer", IL_A), ("a_retraction_with_no_target", IL_B), ("a_plain_answer_with_a_target", IL_C)):
|
||||
_r = rf(_f, sr("n2"), "bad_retraction")
|
||||
scen.append(sc(f"R1_{_n}_is_a_stored_bad_retraction_and_a_retry_replays_it", CARD, [X1, _r, replayed(_r, sr("n2"))], "n1"))
|
||||
scen += [
|
||||
sc("a_replay_returns_the_counted_answer_id_current_at_the_retry_not_the_one_stored", CARD, [X1, ok(RET("n1"), sr("n8"), "Retraction", "n1"), REPLAY1], None),
|
||||
sc("R1_retracted_true_with_an_answer_and_no_target_is_a_stored_bad_retraction", CARD, [X1, rf(dict(IL_A, retracts=None), sr("n2"), "bad_retraction")], "n1"),
|
||||
sc("R1_retracted_true_then_false_under_one_request_id_is_request_id_conflict", CARD, [X1, rf(IL_A, sr("n2"), "bad_retraction"), door(IL_C, sr("n9"), "request_id_conflict")], "n1"),
|
||||
sc("R1_retracted_false_then_true_under_one_request_id_is_request_id_conflict", CARD, [X1, rf(IL_C, sr("n2"), "bad_retraction"), door(IL_A, sr("n9"), "request_id_conflict")], "n1"),
|
||||
sc("R1_a_target_less_retraction_with_retracted_flipped_is_body_invalid_before_any_request_id_lookup", CARD, [X1, rf(IL_B, sr("n2"), "bad_retraction"), door(dict(IL_B, retracted=False), sr("n9"), "body_invalid")], "n1"),
|
||||
]
|
||||
REASON_CODES = ["stale_fork", "not_a_decider", "fork_closed", "choice_invalid", "bad_retraction", "already_retracted"]
|
||||
HTTP = {"unauthenticated": 401, "body_forbidden_field": 400, "body_unknown_field": 400, "body_invalid": 400, "fork_unknown": 404, "insufficient_scope": 403, "forbidden": 403, "request_id_conflict": 409, "request_invalid": 400, "fork_too_large": 422,
|
||||
"stale_fork": 409, "not_a_decider": 403, "fork_closed": 409, "choice_invalid": 400, "bad_retraction": 400, "already_retracted": 409}
|
||||
STORED_REFUSALS = []
|
||||
for reason in REASON_CODES:
|
||||
a = da("Refused", None, reason, x1, sx1)
|
||||
STORED_REFUSALS.append({"name": reason, "httpStatus": HTTP[reason], "message": result(a, CARD if reason == "stale_fork" else None)})
|
||||
# ruling citation: authority Operator must cite a Counted answer record whose answerer holds Operator authority
|
||||
CNT = da("Counted", None, None, x1, sx1); REV = da("Revision", "n1", None, x2, sx2)
|
||||
REF = da("Refused", None, "fork_closed", x1, srv("n5", OP, T(7))); NONOP = da("Counted", None, None, z1, sz)
|
||||
def cite(authority, ans, ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1"):
|
||||
return {"schemaVersion": "gamecult.fork.ruling_cite.v0", "ruling_id": ruling, "authority": authority, "answer_id": ans}
|
||||
cites = [
|
||||
{"name": "operator_cites_a_counted_operator_answer", "answers": [CNT], "message": cite("Operator", "n1"), "admit": True},
|
||||
{"name": "operator_cites_nothing", "answers": [CNT], "message": cite("Operator", None), "admit": False},
|
||||
{"name": "operator_cites_a_revision", "answers": [CNT, REV], "message": cite("Operator", "n2"), "admit": False},
|
||||
{"name": "operator_cites_a_refused_answer", "answers": [REF], "message": cite("Operator", "n5"), "admit": False},
|
||||
{"name": "operator_cites_a_counted_answer_by_a_non_operator", "answers": [NONOP], "message": cite("Operator", "n4"), "admit": False},
|
||||
{"name": "operator_cites_an_answer_that_does_not_exist", "answers": [CNT], "message": cite("Operator", "n99"), "admit": False},
|
||||
{"name": "steward_needs_no_citation", "answers": [], "message": cite("Steward", None), "admit": True},
|
||||
]
|
||||
for cz in cites: cz["messagePackHex"] = hx(cz["message"])
|
||||
# ---------- transport: list, read, auth ----------
|
||||
def fcard(n, status="open", **kw):
|
||||
c = card(fork_id=f"gamecult-chat:question:q{n}", title=f"Fork {n}", order=n, status=status, **kw)
|
||||
return c
|
||||
def fbody(c, choice, aid_note):
|
||||
return body(choice, aid_note, h=c["options_hash"], fork=c["fork_id"])
|
||||
def fentry(c, answers=(), counted=None):
|
||||
return {"schemaVersion": "gamecult.fork.entry.v0", "card": c, "record": record(counted, list(answers), c["options_hash"], c["fork_id"])}
|
||||
q5 = fcard(5); q4 = fcard(4); q2 = fcard(2)
|
||||
q3 = fcard(3, "closed", ruling="cultlib-gaps:resolution:question.q3.n1", ruled_from=["n1"]); q1 = fcard(1, "closed", ruling="cultlib-gaps:resolution:question.q1.n1")
|
||||
a5 = da("Counted", None, None, fbody(q5, "no", NOTE), srv("n1", OP, T(2)))
|
||||
a3 = da("Counted", None, None, fbody(q3, "yes", NOTE), srv("n1", OP, T(2)))
|
||||
r3 = da("Refused", None, "fork_closed", fbody(q3, "no", NOTE), srv("n2", HM, T(4), "Steward"))
|
||||
ENTRIES = [fentry(q5, [a5], "n1"), fentry(q4), fentry(q3, [a3, r3], "n1"), fentry(q2), fentry(q1)]
|
||||
q0 = fcard(0)
|
||||
ORDER0 = [fentry(fcard(3)), fentry(fcard(2)), fentry(fcard(1)), fentry(q0)] # a Mind whose oldest card has order 0
|
||||
q4b = dict(fcard(4), fork_id="gamecult-chat:question:q4b") # a second card on order 4 in the same Mind
|
||||
def order_shared(entries):
|
||||
seen, out = {}, []
|
||||
for e in entries:
|
||||
c = e["card"]
|
||||
if c["order"] in seen: out.append(f"order {c['order']} is shared by {seen[c['order']]} and {c['fork_id']}")
|
||||
else: seen[c["order"]] = c["fork_id"]
|
||||
return out
|
||||
def lreq(mind="gamecult-chat", status=None, cursor=None, limit=10): return {"schemaVersion": "gamecult.fork.list_request.v0", "mind": mind, "status": status, "cursor": cursor, "limit": limit}
|
||||
def judge_list(entries, ordinal, rq):
|
||||
rows = sorted(entries, key=lambda e: -e["card"]["order"])
|
||||
want = [e for e in rows if rq["status"] is None or (e["card"]["status"] == "open") == (rq["status"] == "open")]
|
||||
if rq["cursor"] is not None: want = [e for e in want if e["card"]["order"] < int(rq["cursor"][1:])]
|
||||
page = want[:rq["limit"]]
|
||||
more = len(want) > len(page) and len(page) > 0
|
||||
return {"schemaVersion": "gamecult.fork.list.v0", "mind": rq["mind"], "stamp": f"s{ordinal}",
|
||||
"open_count": sum(e["card"]["status"] == "open" for e in rows), "recorded_count": sum(e["card"]["status"] == "closed" for e in rows),
|
||||
"forks": page, "next_cursor": f"c{page[-1]['card']['order']}" if more else None}
|
||||
def lstep(entries, ordinal, rq): r = judge_list(entries, ordinal, rq); return {"request": rq, "response": r, "responseHex": hx(r)}
|
||||
LISTS = [
|
||||
{"name": "newest_first_paged_open_only", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(status="open", limit=2)), lstep(ENTRIES, 412, lreq(status="open", cursor="c4", limit=2))]},
|
||||
{"name": "all_forks_one_page", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(limit=50))]},
|
||||
{"name": "recorded_only_paged", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(status="recorded", limit=1)), lstep(ENTRIES, 412, lreq(status="recorded", cursor="c3", limit=1))]},
|
||||
{"name": "limit_zero_is_the_count_and_stamp_only", "ordinal": 412, "entries": ENTRIES, "steps": [lstep(ENTRIES, 412, lreq(limit=0))]},
|
||||
{"name": "order_zero_is_legal_but_no_cursor_names_it", "ordinal": 9, "entries": ORDER0, "steps": [lstep(ORDER0, 9, lreq(limit=3)), lstep(ORDER0, 9, lreq(cursor="c1", limit=3))]},
|
||||
{"name": "a_mind_with_no_forks", "ordinal": 7, "entries": [], "steps": [lstep([], 7, lreq(mind="nibu", limit=10))]},
|
||||
]
|
||||
# A page may stop short of `limit` when the next entry would pass the receiver's response budget; a list whose first
|
||||
# entry alone passes it is refused `fork_too_large` (422). The budget is not in the vector: a step records what the receiver answered, and the
|
||||
# judges check it against the unbudgeted page (a nonempty strict prefix, resumed below its last entry) or against the entry the page would open with.
|
||||
def too_large_doc(c): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", "error": f"fork_too_large: fork {c['fork_id']} (order {c['order']}) alone is past the response budget"}
|
||||
def read_too_large_doc(c): return {"schemaVersion": "gamecult.fork.refusal.v0", "code": "fork_too_large", "error": f"fork_too_large: fork {c['fork_id']} alone is past the response budget"}
|
||||
READ_TOO_LARGE = [{"name": "a_read_whose_entry_is_past_the_cap_is_fork_too_large_without_an_order", "ordinal": 9, "entries": ORDER0,
|
||||
"request": {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": ORDER0[0]["card"]["fork_id"]},
|
||||
"refusal": read_too_large_doc(ORDER0[0]["card"]), "refusalHex": hx(read_too_large_doc(ORDER0[0]["card"])), "httpStatus": HTTP["fork_too_large"], "fork_id": ORDER0[0]["card"]["fork_id"]}]
|
||||
# An answer on a stale card whose current-card re-read is past the cap: the same refusal document as the read form over a stored Refused(stale_fork), never counted.
|
||||
_big = ORDER0[0]["card"]
|
||||
ANSWER_TOO_LARGE = [{"name": "a_stale_answer_whose_card_reread_is_past_the_cap_is_fork_too_large_over_a_stored_stale_refusal",
|
||||
"entries": ORDER0, "request": body("yes", NOTE, h=STALE_H, fork=_big["fork_id"], rid="0000a001-0000-4000-8000-00000000a001"),
|
||||
"refusal": read_too_large_doc(_big), "refusalHex": hx(read_too_large_doc(_big)), "httpStatus": HTTP["fork_too_large"], "outcome": "Refused", "code": "stale_fork", "counted": False, "fork_id": _big["fork_id"]}]
|
||||
def short_step(entries, ordinal, rq, keep):
|
||||
full = judge_list(entries, ordinal, rq); page = full["forks"][:keep]
|
||||
assert 0 < keep < len(full["forks"])
|
||||
r = dict(full, forks=page, next_cursor=f"c{page[-1]['card']['order']}"); return {"request": rq, "response": r, "responseHex": hx(r)}
|
||||
def large_step(entries, ordinal, rq):
|
||||
c = judge_list(entries, ordinal, rq)["forks"][0]["card"]; r = too_large_doc(c)
|
||||
return {"request": rq, "refusal": r, "refusalHex": hx(r), "httpStatus": HTTP["fork_too_large"], "fork_id": c["fork_id"], "order": c["order"]}
|
||||
SHORT_PAGES = [
|
||||
{"name": "a_short_page_resumes_below_its_last_entry", "ordinal": 9, "entries": ORDER0, "steps": [short_step(ORDER0, 9, lreq(limit=3), 2), lstep(ORDER0, 9, lreq(cursor="c2", limit=3))]},
|
||||
{"name": "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", "ordinal": 9, "entries": ORDER0,
|
||||
"steps": [large_step(ORDER0, 9, lreq(limit=3)), lstep(ORDER0, 9, lreq(cursor="c3", limit=3))]},
|
||||
{"name": "fork_too_large_on_order_zero_leaves_nothing_below", "ordinal": 9, "entries": ORDER0[2:],
|
||||
"steps": [short_step(ORDER0[2:], 9, lreq(limit=5), 1), large_step(ORDER0[2:], 9, lreq(cursor="c1", limit=5))]},
|
||||
]
|
||||
DUP_LIST = judge_list(ENTRIES + [fentry(q4b)], 412, lreq(limit=50))
|
||||
assert order_shared(DUP_LIST["forks"]) == ["order 4 is shared by gamecult-chat:question:q4 and gamecult-chat:question:q4b"]
|
||||
TOK = lambda aud, *sc, active=True: {"audience": aud, "scopes": list(sc), "active": active}
|
||||
AUTH = [
|
||||
("list_with_desk_read", "list", TOK("bifrost-desk", "desk.read"), "ok"), ("read_with_desk_read", "read", TOK("bifrost-desk", "desk.read"), "ok"),
|
||||
("answer_with_desk_answer", "answer", TOK("bifrost-desk", "desk.answer"), "ok"), ("answer_with_both_scopes", "answer", TOK("bifrost-desk", "desk.read", "desk.answer"), "ok"),
|
||||
("no_bearer_list", "list", None, "unauthenticated"), ("no_bearer_answer", "answer", None, "unauthenticated"),
|
||||
("the_MAS_token_is_refused", "list", TOK("matrix-authentication-service", "openid", "profile"), "unauthenticated"),
|
||||
("a_votes_token_cannot_list", "list", TOK("bifrost-votes", "votes.read"), "unauthenticated"),
|
||||
("a_votes_token_cannot_read", "read", TOK("bifrost-votes", "votes.read"), "unauthenticated"),
|
||||
("a_votes_token_cannot_answer", "answer", TOK("bifrost-votes", "votes.cast"), "unauthenticated"),
|
||||
("desk_audience_with_a_votes_scope_only", "answer", TOK("bifrost-desk", "votes.cast"), "insufficient_scope"),
|
||||
("desk_read_cannot_answer", "answer", TOK("bifrost-desk", "desk.read"), "insufficient_scope"),
|
||||
("desk_answer_cannot_list", "list", TOK("bifrost-desk", "desk.answer"), "insufficient_scope"),
|
||||
("a_non_active_member_cannot_list", "list", TOK("bifrost-desk", "desk.read", active=False), "forbidden"),
|
||||
("a_non_active_member_cannot_read", "read", TOK("bifrost-desk", "desk.read", active=False), "forbidden"),
|
||||
("a_non_active_member_cannot_answer", "answer", TOK("bifrost-desk", "desk.answer", active=False), "forbidden"),
|
||||
("scope_is_judged_before_membership", "answer", TOK("bifrost-desk", "desk.read", active=False), "insufficient_scope"),
|
||||
("huginn_down_list", "list", TOK("bifrost-desk", "desk.read"), "unavailable", True),
|
||||
("huginn_down_read", "read", TOK("bifrost-desk", "desk.read"), "unavailable", True),
|
||||
("huginn_down_answer", "answer", TOK("bifrost-desk", "desk.answer"), "unavailable", True),
|
||||
("auth_is_judged_before_huginn_is_asked", "list", None, "unauthenticated", True),
|
||||
("membership_is_judged_before_huginn_is_asked", "read", TOK("bifrost-desk", "desk.read", active=False), "forbidden", True),
|
||||
]
|
||||
# ---------- transport: the {mind} segment (Heimdall's file 4.3a, M1 to M4) ----------
|
||||
import urllib.parse
|
||||
MINDS = {"gamecult-chat": "huginn-gamecult-chat.sock", "nibu": "huginn-nibu.sock"}
|
||||
MIND_FORKS = {"gamecult-chat": ["gamecult-chat:question:q1", "gamecult-chat:question:q2", FID], "nibu": ["nibu:question:glow-order"]}
|
||||
def auth_code(route, tok):
|
||||
if tok is None or tok["audience"] != "bifrost-desk": return "unauthenticated"
|
||||
if ("desk.answer" if route == "answer" else "desk.read") not in tok["scopes"]: return "insufficient_scope"
|
||||
return "forbidden" if not tok["active"] else "ok"
|
||||
def mind_route(route, seg, fork, tok):
|
||||
code = auth_code(route, tok)
|
||||
if code != "ok": return code, None, None
|
||||
try: mind = urllib.parse.unquote(seg, errors="strict")
|
||||
except UnicodeDecodeError: mind = None
|
||||
if mind not in MINDS: return "fork_unknown", None, None # never joined into a path; no socket is opened
|
||||
if route != "list" and fork not in MIND_FORKS[mind]: return "fork_unknown", MINDS[mind], None
|
||||
return "ok", MINDS[mind], mind if route == "answer" else None
|
||||
DESK_READ, DESK_ANSWER = TOK("bifrost-desk", "desk.read"), TOK("bifrost-desk", "desk.answer")
|
||||
def mcase(name, route, seg, fork=None, tok=None):
|
||||
tok = None if tok == "none" else tok or (DESK_ANSWER if route == "answer" else DESK_READ)
|
||||
code, opened, stored = mind_route(route, seg, fork, tok)
|
||||
return {"name": name, "route": route, "mind": seg, "fork_id": fork, "token": tok, "expect": code, "httpStatus": 200 if code == "ok" else HTTP[code], "opened": opened, "stored_in": stored}
|
||||
BAD_SEGMENTS = [("dot_dot", "../x"), ("slash", "a/b"), ("encoded_slash", "a%2fb"), ("encoded_dot_dot", "%2e%2e%2fx"), ("nul", "eureka%00"), ("four_kb", "a" * 4096),
|
||||
("other_case", "GAMECULT-CHAT"), ("trailing_space", "nibu%20"), ("invalid_utf8", "%ff"), ("dot", "."), ("dots", ".."), ("double_encoded", "%256eibu")]
|
||||
MINDCASES = [
|
||||
mcase("list_a_percent_encoded_configured_mind_is_decoded_once", "list", "%6eibu"), mcase("list_a_configured_mind", "list", "gamecult-chat"), mcase("list_another_configured_mind", "list", "nibu"),
|
||||
mcase("read_a_fork_the_mind_holds", "read", "gamecult-chat", FID), mcase("answer_a_fork_the_mind_holds_is_stored_in_that_mind", "answer", "gamecult-chat", FID),
|
||||
mcase("answer_on_the_other_mind", "answer", "nibu", "nibu:question:glow-order"),
|
||||
] + [mcase(f"M1_{route}_{n}_is_unknown_and_opens_nothing", route, seg, FID if route != "list" else None) for route in ("list", "read", "answer") for n, seg in BAD_SEGMENTS] + [
|
||||
mcase("M2_read_unknown_mind_known_fork", "read", "ghost", FID), mcase("M2_read_known_mind_unknown_fork", "read", "gamecult-chat", "gamecult-chat:question:ghost"),
|
||||
mcase("M2_answer_unknown_mind_known_fork", "answer", "ghost", FID), mcase("M2_answer_known_mind_unknown_fork", "answer", "gamecult-chat", "gamecult-chat:question:ghost"),
|
||||
mcase("M2_list_unknown_mind", "list", "ghost"),
|
||||
mcase("M3_answer_on_nibu_with_a_fork_gamecult_chat_holds", "answer", "nibu", FID), mcase("M3_answer_on_gamecult_chat_with_a_fork_nibu_holds", "answer", "gamecult-chat", "nibu:question:glow-order"),
|
||||
mcase("M4_desk_read_lists_every_mind", "list", "nibu", tok=DESK_READ), mcase("M4_desk_read_reads_every_mind", "read", "nibu", "nibu:question:glow-order", tok=DESK_READ),
|
||||
mcase("M4_desk_answer_cannot_list_any_mind", "list", "gamecult-chat", tok=DESK_ANSWER), mcase("M4_desk_answer_cannot_list_the_other_mind", "list", "nibu", tok=DESK_ANSWER),
|
||||
mcase("M4_desk_read_cannot_answer_on_any_mind", "answer", "nibu", "nibu:question:glow-order", tok=DESK_READ),
|
||||
mcase("auth_comes_before_the_mind_lookup_no_bearer", "list", "ghost", tok="none"), mcase("auth_comes_before_the_mind_lookup_scope", "answer", "ghost", FID, tok=DESK_READ),
|
||||
mcase("auth_comes_before_the_mind_lookup_non_active", "read", "ghost", FID, tok=TOK("bifrost-desk", "desk.read", active=False)),
|
||||
]
|
||||
SAME_RESPONSE = [["M2_read_unknown_mind_known_fork", "M2_read_known_mind_unknown_fork"], ["M2_answer_unknown_mind_known_fork", "M2_answer_known_mind_unknown_fork"],
|
||||
["M2_list_unknown_mind", "M2_read_known_mind_unknown_fork"], ["M3_answer_on_nibu_with_a_fork_gamecult_chat_holds", "M2_answer_known_mind_unknown_fork"]]
|
||||
def bad(name, why, m, **kw): return dict({"name": name, "why": why, "message": m}, **kw)
|
||||
DECIDERS_64 = [f"{i:08x}-0000-4000-8000-{i:012x}" for i in range(1, 65)] # the card's `deciders` holds at most 64 (Huginn's bound)
|
||||
# body_sha256: Huginn's private "same body" digest (fork-card.md, "The mind's record"); no wire document carries it. Input per
|
||||
# /opt/commons/handoff/desk-body-digest-order-20261110.md (Eureka): SHA-256 of compact serde_json UTF-8, keys in this order, null for missing,
|
||||
# `retracts` the full stored key, request_id out. `illegal_retraction` and `retracted` are written only when the input has them (non-null).
|
||||
DIGEST_KEYS = ["fork", "options_hash", "answered_at", "choice", "note", "retracts", "illegal_retraction", "retracted"]
|
||||
def digest_bytes(i): return json.dumps({k: i[k] for k in DIGEST_KEYS if k in ("fork", "options_hash", "answered_at", "choice", "note", "retracts") or i.get(k) is not None},
|
||||
separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
DQ = {"fork": "eureka-state:question:Q1", "options_hash": "a" * 64, "answered_at": "2026-11-10T12:00:00Z"}
|
||||
DT = "eureka-state:desk_answer:Q1.n1"
|
||||
def dg(name, status, sha_expected, why, **kw):
|
||||
i = dict(DQ, choice=None, note=None, retracts=None, illegal_retraction=None, retracted=None); i.update(kw)
|
||||
b = digest_bytes(i); got = sha(b)
|
||||
assert got == sha_expected, (name, got)
|
||||
return {"name": name, "status": status, "why": why, "input": i, "canonical": b.decode("utf-8"), "sha256": got}
|
||||
MAIN, FIX = "main_7ea11b4_must_not_move", "reproduced_on_huginn_main_f0ca504_reported_by_eureka_not_rerun_here"
|
||||
BODY_DIGESTS = [
|
||||
dg("plain_answer_choice_A", MAIN, "8a4d5b2ed0b3452bfed2f3a276b2828974e6824a3edcc643cfe9cfcdda34173a", "a plain answer: choice and note as sent, retracts null; no illegal or retracted key", choice="A"),
|
||||
dg("legal_retraction", MAIN, "9f8075382556898c0bea364ed7c6deb2a86d8c78288a1f34e06da6e9bf5f58a3", "a legal retraction as Huginn main pins it: retracts is `Q1.1`, a string no door produces (the key grammar is .n<seq>); kept for the frozen-form test", retracts="eureka-state:desk_answer:Q1.1"),
|
||||
dg("illegal_i_and_iii_alike_on_main", MAIN, "69074cc60829ef5edb12358746f7549ad08da97653d59359c945cea102f53565", "main writes `illegal_retraction: true` and no `retracted`, so (i) and (iii) collide: the defect the fix removes", choice="A", retracts=DT, illegal_retraction=True),
|
||||
dg("legal_retraction_door_form", MAIN, "84576840247830b9b75754b460dc4ea52883ca1e410f9f011521e0f69b99eee1", "a legal retraction as the door produces it: the wire's n1 converted to the full stored key Q1.n1 before the digest; the golden that stands for the real form (digest unaffected by .n<seq> vs .1)", retracts=DT),
|
||||
dg("illegal_ii_on_main", MAIN, "1a36c4aba0d1c30d0736f20243ebfdb84fe84a5eabd4f69709a096a32c3cc6e3", "(ii) no answer, no target, on main", illegal_retraction=True),
|
||||
dg("illegal_i_retraction_with_an_answer_retracted_true", FIX, "935cab1a0ae34606b809d8ebca7cffa2889d20b3585d2622d32153a4d6898af1", "(i) answer present, retracted true, target Q1.n1", choice="A", retracts=DT, illegal_retraction=True, retracted=True),
|
||||
dg("illegal_iii_plain_answer_with_a_target_retracted_false", FIX, "82b24cccb4ed2535a549f95f844fcd935074c3712b5b832f0b2cbd89c71fd9a6", "(iii) the same bytes as (i) with retracted false: another body, so request_id_conflict", choice="A", retracts=DT, illegal_retraction=True, retracted=False),
|
||||
dg("illegal_i_retraction_with_an_answer_and_no_target_retracted_true", "reproduced_on_huginn_main_f0ca504_reported_by_eureka_not_rerun_here", "79eee623fd6712db6537a4867cedf54ca49ee4885416c2c8e4acec59f811b894", "(i) with the target null (the order doc: retracts set or null); Eureka re-derived it with sha256sum (order doc addendum)", choice="A", illegal_retraction=True, retracted=True),
|
||||
dg("illegal_ii_no_answer_no_target_retracted_true", FIX, "34ef86ed63b9318459965d4b85e794d2636ffc15736da866036b087c9c4a30eb", "(ii) no answer, no target, retracted true", illegal_retraction=True, retracted=True),
|
||||
]
|
||||
fork = {
|
||||
"provenance": "Rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md (Heimdall), amended by the Persona briefs of 2026-11-10 (Eureka's fit check). Bytes spec-authored with msgpack-python (scripts/gen-governance-vectors.py); every runtime re-encodes `message` and must match messagePackHex. options_hash follows fork-card.md 'Options hash'; the vectors named heimdall_* are that file's, with its digests.",
|
||||
"optionsHash": hashes,
|
||||
"bodyDigests": BODY_DIGESTS,
|
||||
"card": {"valid": [{"name": "operator_fork", "message": CARD, "messagePackHex": hx(CARD)},
|
||||
{"name": "leans_flag_closed_two_deciders", "message": card(leans="yes", flag="provisional: Self expects this to move", deciders=[OP, HM], depends=[], raised_in=None, status="closed", ruling="x:resolution:y", ruled_from=["n1"], order=0),
|
||||
"messagePackHex": hx(card(leans="yes", flag="provisional: Self expects this to move", deciders=[OP, HM], depends=[], raised_in=None, status="closed", ruling="x:resolution:y", ruled_from=["n1"], order=0))},
|
||||
{"name": "depends_at_both_caps", "message": card(depends=["a"] * 7 + ["\u00e9" * 500]), "messagePackHex": hx(card(depends=["a"] * 7 + ["\u00e9" * 500]))},
|
||||
{"name": "closed_without_a_ruling_the_question_was_withdrawn", "message": card(status="closed"), "messagePackHex": hx(card(status="closed"))},
|
||||
{"name": "deciders_at_the_cap_of_64", "message": card(deciders=DECIDERS_64), "messagePackHex": hx(card(deciders=DECIDERS_64))}],
|
||||
"invalid": [bad(n, w, m) for n, w, m in [
|
||||
("depends_nine_lines", "depends holds at most 8 lines (the stored question's limit)", card(depends=["a"] * 9)),
|
||||
("depends_line_1001_code_points", "a depends line is at most 1000 characters", card(depends=["a" * 1001])),
|
||||
("depends_line_1001_bytes", "rule: one byte over the limit (500 two-byte characters and one more; not expressible in the schema)", card(depends=["\u00e9" * 500 + "a"])),
|
||||
("depends_line_over_1000_bytes", "rule: a depends line is at most 1000 UTF-8 bytes (the stored Line limit; not expressible in the schema)", card(depends=["\u00e9" * 501])),
|
||||
("one_option", "two or more options", card(options=[OPT[0]], recommendation="yes")),
|
||||
("no_deciders", "a fork with no decider cannot be answered", card(deciders=[])),
|
||||
("null_recommendation", "every card recommends an option", dict(card(), recommendation=None)),
|
||||
("unknown_key", "closed document", dict(card(), note="x")),
|
||||
("option_with_a_label", "options are {id,text}; the old format's display label is not a member (fork-card.md, Old format)", card(options=[dict(OPT[0], label="Yes"), OPT[1]])),
|
||||
("status_unknown", "status is open or closed", card(status="provisional")),
|
||||
("options_hash_not_hex", "options_hash is 64 lowercase hex", dict(card(), options_hash="XYZ")),
|
||||
("duplicate_option_ids", "rule: option ids are unique (not expressible in the schema)", card(options=[OPT[0], dict(OPT[1], id=OPT[0]["id"])])),
|
||||
("recommendation_not_an_option", "rule: recommendation is an option id (not expressible in the schema)", card(recommendation="maybe")),
|
||||
("leans_not_an_option", "rule: leans is nil or an option id (not expressible in the schema)", card(leans="maybe")),
|
||||
("duplicate_deciders", "rule: deciders are unique (not expressible in the schema)", card(deciders=[OP, OP])),
|
||||
("ruled_from_on_an_open_card", "rule: a ruling that answers a fork closes it, so an open card cites no answer in ruled_from (not expressible in the schema)", card(ruled_from=["n1"])),
|
||||
("open_card_names_a_ruling", "rule: only a closed card names a ruling (not expressible in the schema)", card(ruling="cultlib-gaps:resolution:question.slot-mark-consent.n1")),
|
||||
("ruled_from_without_a_ruling", "rule: ruled_from lists answers a ruling was admitted from, so it needs the ruling (not expressible in the schema)", card(status="closed", ruled_from=["n1"])),
|
||||
("options_hash_of_another_card", "rule: options_hash equals the hash of the shown members (not expressible in the schema)", dict(card(), options_hash=STALE_H)),
|
||||
("deciders_65", "deciders holds at most 64 (Huginn's bound)", card(deciders=DECIDERS_64 + ["00000041-0000-4000-8000-000000000041"])),
|
||||
]]},
|
||||
"request": {"valid": [{"name": n, "message": m, "messagePackHex": hx(m)} for n, m in
|
||||
[("choice_and_note", x1), ("choice_alone", pick), ("note_alone_clear_pick", words), ("retraction", retraction("n1")),
|
||||
("note_4000_ascii_bytes", body(None, "x" * 4000)), ("note_astral_4000_bytes", body(None, "\U0001F600" * 1000)),
|
||||
("note_combining_4000_bytes", body(None, "e\u0301" * 1333 + "x"))]],
|
||||
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("answer_with_neither", "an answer object holds a choice, a note, or both", body(answer={"choice": None, "note": None})),
|
||||
("blank_note", "a blank note is no answer", body(None, " \u00a0 ")),
|
||||
("request_id_missing", "map-mode writes every key; a retry needs the id", {k: v for k, v in x1.items() if k != "request_id"}),
|
||||
("request_id_uppercase", "request_id is lowercase", dict(x1, request_id="0000000A-0000-4000-8000-00000000000B")),
|
||||
("request_id_not_a_uuid", "request_id is a lowercase D-format UUID", dict(x1, request_id="retry-1")),
|
||||
("note_over_4000", "notes are at most 4000 UTF-8 bytes", body(None, "x" * 4001)),
|
||||
("note_multibyte_4001_bytes", "a note is counted in UTF-8 bytes: 1999 e-acute is 3998 bytes, plus three ASCII is 4001 (rule: not expressible in the schema, 2002 characters)", body(None, "\u00e9" * 1999 + "abc")),
|
||||
("note_astral_4004_bytes", "1001 astral characters are 4004 bytes though only 1001 code points (rule: not expressible in the schema)", body(None, "\U0001F600" * 1001)),
|
||||
("note_combining_4002_bytes", "e plus a combining acute is 3 bytes: 1334 of them are 4002 (rule: not expressible in the schema)", body(None, "e\u0301" * 1334)),
|
||||
("options_hash_uppercase", "hash is lowercase hex", body("no", None, h=H.upper())),
|
||||
("answerer_key", "answerer is server-set; the door answers body_forbidden_field", dict(x1, answerer=OP)),
|
||||
("authority_key", "authority is server-set; the door answers body_forbidden_field", dict(x1, authority="Operator")),
|
||||
("answer_object_with_a_key", "the answer object is closed", body(answer={"choice": "no", "note": None, "weight": 1})),
|
||||
("answered_at_without_zone", "instants are UTC with a Z", body("no", None, at="2026-11-12T19:02:00")),
|
||||
("retracted_not_a_boolean", "retracted is a boolean", dict(x1, retracted="no")),
|
||||
("fork_id_empty", "fork_id names a fork", body("no", None, fork="")),
|
||||
]]},
|
||||
"deskAnswer": {"valid": [{"name": n, "message": m, "messagePackHex": hx(m)} for n, m in
|
||||
[("counted", CNT), ("revision", REV), ("retraction", da("Retraction", "n1", None, retraction("n1"), sr("n8"))),
|
||||
("refused_stale", da("Refused", None, "stale_fork", stale_body(), sx1)),
|
||||
("refused_closed_retraction_names_its_target", da("Refused", None, "fork_closed", retraction("n1"), sr("n9")))]],
|
||||
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("counted_with_a_code", "only Refused carries a code", dict(CNT, code="fork_closed")),
|
||||
("refused_without_a_code", "Refused carries a reason code", dict(REF, code=None)),
|
||||
("refused_with_an_unknown_code", "the code set is closed", dict(REF, code="because")),
|
||||
("counted_naming_another_answer", "Counted names none", dict(CNT, of="n0")),
|
||||
("revision_naming_none", "a Revision names the counted answer", dict(REV, of=None)),
|
||||
("revision_that_also_retracts", "a variant carries only its own link (a retraction with no target, or a target with no retraction, cannot exist)", dict(REV, retracts="n1")),
|
||||
("retraction_naming_no_target", "a Retraction names the answer it retracts", dict(da("Retraction", "n1", None, retraction("n1"), sr("n8")), retracts=None)),
|
||||
("refused_naming_another_answer_as_of", "a Refused answer names no counted answer (`of` is a Revision's)", dict(REF, of="n1")),
|
||||
("refused_with_a_target_that_is_not_an_answer_id", "a Refused answer's retracts is nil or an answer id (n<seq>), never the stored key", dict(REF, retracts="cultlib-gaps:desk_answer:slot-mark-consent.n1")),
|
||||
("retraction_carrying_a_choice", "a Retraction carries no choice or note", dict(da("Retraction", "n1", None, retraction("n1"), sr("n8")), choice="no")),
|
||||
("outcome_lower_case", "outcome tags are Counted, Revision, Retraction, Refused", dict(CNT, outcome="counted")),
|
||||
("no_answerer", "answerer is stamped on every stored answer", dict(CNT, answerer="")),
|
||||
]]},
|
||||
"results": [{"name": r["name"], "httpStatus": r["httpStatus"], "message": r["message"], "messagePackHex": hx(r["message"])} for r in STORED_REFUSALS],
|
||||
"doorRefusals": [{"name": c, "httpStatus": HTTP[c], "message": refusal_doc(c), "messagePackHex": hx(refusal_doc(c))} for c in TAIL] + [{"name": "fork_too_large", "httpStatus": HTTP["fork_too_large"], "message": SHORT_PAGES[1]["steps"][0]["refusal"], "messagePackHex": SHORT_PAGES[1]["steps"][0]["refusalHex"]}],
|
||||
"unavailable": [{"name": "second_door_down", "httpStatus": 503, "message": unavailable_doc(30), "messagePackHex": hx(unavailable_doc(30))},
|
||||
{"name": "second_door_down_no_hint", "httpStatus": 503, "message": unavailable_doc(None), "messagePackHex": hx(unavailable_doc(None))}],
|
||||
"rulingCites": cites,
|
||||
"transport": {
|
||||
"listRequest": {"valid": [{"name": "list_open_page", "message": lreq(status="open", limit=2), "messagePackHex": hx(lreq(status="open", limit=2))},
|
||||
{"name": "list_next_page", "message": lreq(status="open", cursor="c4", limit=2), "messagePackHex": hx(lreq(status="open", cursor="c4", limit=2))},
|
||||
{"name": "list_count_only", "message": lreq(limit=0), "messagePackHex": hx(lreq(limit=0))}],
|
||||
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("limit_51", "a page is at most 50 forks", lreq(limit=51)), ("limit_negative", "limit is 0 to 50", lreq(limit=-1)),
|
||||
("status_all", "status is open, recorded or nil", lreq(status="all")), ("cursor_malformed", "a cursor is c<N>", lreq(cursor="next")),
|
||||
("cursor_zero", "a cursor names a positive order", lreq(cursor="c0")), ("mind_empty", "a list names a Mind", lreq(mind="")),
|
||||
("unknown_key", "closed document", dict(lreq(), sort="oldest"))]]},
|
||||
"readRequest": {"valid": [{"name": "read_one", "message": {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": FID}, "messagePackHex": hx({"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": FID})}],
|
||||
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("fork_id_empty", "a read names a fork", {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "gamecult-chat", "fork_id": ""}),
|
||||
("mind_empty", "a read names a Mind", {"schemaVersion": "gamecult.fork.read_request.v0", "mind": "", "fork_id": FID}),
|
||||
("mind_missing", "map-mode writes every key; the Mind is the route's", {"schemaVersion": "gamecult.fork.read_request.v0", "fork_id": FID})]]},
|
||||
"entry": {"valid": [{"name": n, "message": e, "messagePackHex": hx(e)} for n, e in [("open_no_answers", ENTRIES[1]), ("open_counted", ENTRIES[0]), ("closed_with_ruling_and_a_refusal", ENTRIES[2])]],
|
||||
"invalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("entry_without_record", "an entry holds the card and its record", {k: v for k, v in ENTRIES[0].items() if k != "record"}),
|
||||
("record_of_another_fork", "rule: the record names the card's fork (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], fork_id="gamecult-chat:question:q9"))),
|
||||
("record_under_another_hash", "rule: the record is under the card's options_hash (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], options_hash=STALE_H))),
|
||||
("counted_answer_not_derived", "rule: counted_answer_id is the first live Counted or Revision answer under the card's hash (not expressible in the schema)", dict(ENTRIES[0], record=dict(ENTRIES[0]["record"], counted_answer_id=None))),
|
||||
("card_hash_wrong", "rule: the server computes options_hash and a client recomputes it; a card with another hash is no card", dict(ENTRIES[1], card=dict(ENTRIES[1]["card"], options_hash=STALE_H), record=dict(ENTRIES[1]["record"], options_hash=STALE_H)))]]},
|
||||
"listInvalid": [dict(bad(n, w, m), messagePackHex=hx(m)) for n, w, m in [
|
||||
("next_cursor_malformed", "a cursor is c<N>", dict(LISTS[0]["steps"][0]["response"], next_cursor="more")),
|
||||
("stamp_malformed", "a stamp is s<N>", dict(LISTS[0]["steps"][0]["response"], stamp="412")),
|
||||
("count_negative", "counts are not negative", dict(LISTS[0]["steps"][0]["response"], open_count=-1))]]
|
||||
+ [dict(bad("two_cards_share_one_order", "rule: order is a unique rank per Mind; the refusal names both fork ids (not expressible in the schema)", DUP_LIST),
|
||||
messagePackHex=hx(DUP_LIST), refusal=order_shared(DUP_LIST["forks"])[0])],
|
||||
"listScenarios": [{"name": l["name"], "ordinal": l["ordinal"], "entries": l["entries"], "steps": l["steps"]} for l in LISTS],
|
||||
"listShortPages": SHORT_PAGES,
|
||||
"readTooLarge": READ_TOO_LARGE,
|
||||
"answerTooLarge": ANSWER_TOO_LARGE,
|
||||
"doorFaults": [dict(c, response=r, responseHex=hx(r)) for c, r in
|
||||
[({"name": f"{x}_door_answers_request_id_conflict_the_client_sees_unavailable", "route": x, "doorCode": "request_id_conflict", "httpStatus": 503}, unavailable_doc(30)) for x in ("read", "list")]
|
||||
+ [({"name": "answer_door_answers_request_id_conflict_the_client_sees_it", "route": "answer", "doorCode": "request_id_conflict", "httpStatus": HTTP["request_id_conflict"]}, refusal_doc("request_id_conflict"))]],
|
||||
"mindRoutes": {"minds": MINDS, "forks": MIND_FORKS, "cases": MINDCASES, "sameResponse": SAME_RESPONSE},
|
||||
"authCases": [{"name": a[0], "route": a[1], "token": a[2], "expect": a[3], "huginn_down": len(a) > 4} for a in AUTH],
|
||||
},
|
||||
"scenarios": [{"name": n, "card": c, "optionsHash": c["options_hash"], "steps": steps, "record": r, "recordHex": hx(r)} for n, c, steps, r in scen],
|
||||
}
|
||||
(OUT / "fork-card-vectors.json").write_text(json.dumps(fork, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
print("ok", len(vote["valid"]), len(vote["invalid"]), len(fork["scenarios"]))
|
||||
119
scripts/gen-unicode-messageid-tables.py
Normal file
119
scripts/gen-unicode-messageid-tables.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generate the Unicode 16.0 tables of the write_admitted messageId rule for the five CultNet runtimes.
|
||||
|
||||
The rule (contracts/cultnet/interop/write-receipt.md): a messageId is valid iff it holds a letter (Lu Ll Lt Lm Lo) or a number
|
||||
(Nd Nl No) and no Default_Ignorable_Code_Point, all as of Unicode 16.0. The tables are embedded in each runtime so that no
|
||||
platform's own Unicode version can change a result. Bumping the version is one coordinated change: run this script on the new
|
||||
UCD files, commit the five regenerated files and the vectors that pin the new version, together.
|
||||
|
||||
gen-unicode-messageid-tables.py DerivedGeneralCategory.txt DerivedCoreProperties.txt [--check]
|
||||
|
||||
The inputs are https://www.unicode.org/Public/16.0.0/ucd/extracted/DerivedGeneralCategory.txt and
|
||||
https://www.unicode.org/Public/16.0.0/ucd/DerivedCoreProperties.txt. With --check nothing is written; the exit status is 1 if
|
||||
any generated file differs from what these inputs produce (a stale or hand-edited table). Standard library only.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "16.0.0"
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
LETTER_OR_NUMBER_CATEGORIES = {"Lu", "Ll", "Lt", "Lm", "Lo", "Nd", "Nl", "No"}
|
||||
|
||||
TARGETS = {
|
||||
"ts": ROOT / "packages/cultnet-ts/src/unicode-messageid-tables.ts",
|
||||
"py": ROOT / "packages/cultnet-py/src/cultnet_py/unicode_messageid_tables.py",
|
||||
"kt": ROOT / "packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/UnicodeMessageIdTables.kt",
|
||||
"cs": ROOT / "src/GameCult.Networking/CultNetUnicodeMessageIdTables.cs",
|
||||
"rs": ROOT / "packages/cultnet-rs/src/unicode_messageid_tables.rs",
|
||||
}
|
||||
# The independent copy the runtimes' tests probe (first, last, and the code point either side of every range), so that deleting
|
||||
# or narrowing any range of any embedded table fails a test.
|
||||
RANGES_JSON = ROOT / "contracts/cultnet/interop/write-admitted-message-id-ranges.json"
|
||||
|
||||
|
||||
def parse(path, wanted):
|
||||
"""Ranges (low, high) of the lines `LOW[..HIGH] ; VALUE`, VALUE accepted by [wanted], merged and sorted."""
|
||||
points = []
|
||||
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
||||
m = re.match(r"([0-9A-F]+)(?:\.\.([0-9A-F]+))?\s*;\s*(\S+)", line)
|
||||
if m and wanted(m.group(3)):
|
||||
points.append((int(m.group(1), 16), int(m.group(2) or m.group(1), 16)))
|
||||
points.sort()
|
||||
merged = []
|
||||
for low, high in points:
|
||||
if merged and low <= merged[-1][1] + 1:
|
||||
merged[-1] = (merged[-1][0], max(merged[-1][1], high))
|
||||
else:
|
||||
merged.append((low, high))
|
||||
return merged
|
||||
|
||||
|
||||
def flat(ranges):
|
||||
return [value for pair in ranges for value in pair]
|
||||
|
||||
|
||||
def wrapped(items, indent, render, width=118):
|
||||
lines, current = [], indent
|
||||
for item in items:
|
||||
text = render(item) + ", "
|
||||
if len(current) + len(text) > width:
|
||||
lines.append(current.rstrip())
|
||||
current = indent
|
||||
current += text
|
||||
lines.append(current.rstrip())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def emit(target, letters, ignorable, header):
|
||||
num = lambda v: f"0x{v:X}"
|
||||
if target == "ts":
|
||||
body = lambda name, r: f"export const {name}: readonly number[] = [\n{wrapped(flat(r), ' ', num)}\n];\n"
|
||||
return f"// {header}\n\n// [low, high] pairs, sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
|
||||
if target == "py":
|
||||
body = lambda name, r: f"{name}: tuple[int, ...] = (\n{wrapped(flat(r), ' ', num)}\n)\n"
|
||||
return f"# {header}\n\n# (low, high) pairs, flattened and sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
|
||||
if target == "kt":
|
||||
body = lambda name, r: f"internal val {name}: IntArray = intArrayOf(\n{wrapped(flat(r), ' ', num)}\n)\n"
|
||||
return f"// {header}\n\npackage org.gamecult.cultmesh\n\n// (low, high) pairs, flattened and sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
|
||||
if target == "cs":
|
||||
body = lambda name, r: f" public static readonly int[] {name} =\n {{\n{wrapped(flat(r), ' ', num)}\n }};\n"
|
||||
return f"// {header}\n\nnamespace GameCult.Networking\n{{\n internal static class CultNetUnicodeMessageIdTables\n {{\n // (low, high) pairs, flattened and sorted.\n{body('LetterOrNumber', letters)}\n{body('DefaultIgnorable', ignorable)} }}\n}}\n"
|
||||
body = lambda name, r: f"pub const {name}: &[(u32, u32)] = &[\n{wrapped(r, ' ', lambda p: f'({num(p[0])}, {num(p[1])})')}\n];\n"
|
||||
return f"// {header}\n\n// (low, high) pairs, sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:] if a != "--check"]
|
||||
check = "--check" in sys.argv[1:]
|
||||
if len(args) != 2:
|
||||
sys.exit(__doc__)
|
||||
gc, dcp = args
|
||||
for path, marker in ((gc, f"DerivedGeneralCategory-{VERSION}"), (dcp, f"DerivedCoreProperties-{VERSION}")):
|
||||
if marker not in Path(path).read_text(encoding="utf-8").splitlines()[0]:
|
||||
sys.exit(f"{path} is not {marker}.txt")
|
||||
letters = parse(gc, lambda v: v in LETTER_OR_NUMBER_CATEGORIES)
|
||||
ignorable = parse(dcp, lambda v: v == "Default_Ignorable_Code_Point")
|
||||
digest = hashlib.sha256(b"".join(Path(p).read_bytes() for p in (gc, dcp))).hexdigest()[:16]
|
||||
header = f"generated, Unicode {VERSION}, by scripts/gen-unicode-messageid-tables.py (inputs sha256 {digest}); do not edit"
|
||||
stale = []
|
||||
texts = {path: emit(target, letters, ignorable, header) for target, path in TARGETS.items()}
|
||||
texts[RANGES_JSON] = json.dumps(
|
||||
{"generated": header, "unicodeVersion": VERSION, "letterOrNumber": [list(r) for r in letters], "defaultIgnorable": [list(r) for r in ignorable]},
|
||||
separators=(",", ":"),
|
||||
) + "\n"
|
||||
for path, text in texts.items():
|
||||
if check:
|
||||
if not path.exists() or path.read_text(encoding="utf-8") != text:
|
||||
stale.append(str(path.relative_to(ROOT)))
|
||||
else:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
if stale:
|
||||
sys.exit("stale or hand-edited: " + ", ".join(stale))
|
||||
print(f"{'checked' if check else 'wrote'} {len(texts)} files: {len(letters)} letter-or-number ranges, {len(ignorable)} default-ignorable ranges")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -59,6 +59,10 @@ await cache.PullAllBackingStoresAsync();
|
|||
- The raw CultNet document lane should treat these payload bytes as already
|
||||
blessed, not decode and re-encode them for sport.
|
||||
|
||||
## Store format and rollback
|
||||
|
||||
A store whose elements hold an id is marked `cultcache.store.v3` (a directory store `v5`); the single-file and directory stores here write the mark from content. See the Store format and rollback section of the GameCult.Caching README. Refusal by name depends on the store kind and the pin. A **directory** store (`v5`) is refused by every Unity pin from `cultlib-unity-v1.0.57` and `caching-unity-v1.2.0` on (the earliest tags of each line whose `DirectoryMessagePackBackingStore` reads only the `v4` stamp: "only ... is readable"). A **single-file** store (`v3`) is refused only from `cultlib-unity-v2.0.0` and `caching-unity-v2.0.0`: `cultlib-unity-v1.0.61` and `caching-unity-v1.4.0` read the header string and never compare it. The NuGet line (`cultlib-v1.0.32`, the last `cultlib-v` tag) refuses neither: its directory store writes `cultcache.store.v1.directory` and never checks the stamp on read. Rolling back a single-file store to any of those pins reads it silently, drops the ids, and can rewrite the elements without them. That is the dangerous case. The only carry-back is narrow and lossy (a whole-store write that leaves no id in the store drops the mark and the ids); otherwise restore from a copy taken before upgrading, and upgrade every reader before any writer.
|
||||
|
||||
## MessagePack source generator
|
||||
|
||||
NuGet consumers get `[assembly: MessagePackAssumedFormattable(typeof(CultRecordRef<>))]` from the package, so a
|
||||
|
|
|
|||
|
|
@ -110,7 +110,10 @@ which route wrote it.
|
|||
already agree when publication starts.
|
||||
- An admission runs a record's `[CultName]` and `[CultIndex]` getters once per
|
||||
landing record, before the store is written and before any `Sequence` is
|
||||
minted. A conditional commit on a clean file store is judged a second time
|
||||
minted. An admission is refused when it would leave two records of one type
|
||||
holding one `[CultIndex]` value (blank is no value), and a load of a store
|
||||
holding such a pair refuses naming both keys and the index. Concurrent writers on a
|
||||
directory store are not covered by this rule. A conditional commit on a clean file store is judged a second time
|
||||
against the file it merges onto whenever the cache holds variants in any store,
|
||||
the batch lands a variant, or the file holds one. That second read runs the
|
||||
getters again for the records the batch lands, for any variant it re-resolves,
|
||||
|
|
|
|||
|
|
@ -885,6 +885,10 @@ namespace GameCult.Caching
|
|||
return indexes.BySchemaId.ContainsKey(schemaId) || indexes.ByCompatibleSchemaId.ContainsKey(schemaId);
|
||||
}
|
||||
|
||||
// Whether the type registered under this schema id declares a [CultIndex]: its records are written through a cache, which judges uniqueness.
|
||||
internal bool HoldsUniqueIndex(string schemaId) =>
|
||||
_indexes.BySchemaId.TryGetValue(schemaId, out var descriptor) && descriptor.IndexAccessors.Count > 0;
|
||||
|
||||
// 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) ||
|
||||
|
|
@ -1006,10 +1010,10 @@ namespace GameCult.Caching
|
|||
throw new InvalidOperationException(ids.Problems[0]);
|
||||
var nameMember = members.FirstOrDefault(member => member.IsName);
|
||||
var indexAccessors = members
|
||||
.Where(member => member.IndexAlias != null)
|
||||
.Where(member => member.IsUnique)
|
||||
.ToDictionary(
|
||||
member => member.IndexAlias!,
|
||||
member => member.Getter,
|
||||
member => member.IndexValue,
|
||||
StringComparer.Ordinal);
|
||||
var descriptorMembers = members
|
||||
.Select(member => new CultDocumentMemberDescriptor
|
||||
|
|
@ -1481,6 +1485,13 @@ namespace GameCult.Caching
|
|||
rejections.Add(HiddenMemberMessage(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member)));
|
||||
}
|
||||
|
||||
// A member names its role once: [CultIndex] already is a role.
|
||||
foreach (var entry in keyed.OrderBy(entry => entry.Member.Name, StringComparer.Ordinal))
|
||||
{
|
||||
if (entry.Member.GetCustomAttribute<CultIndexAttribute>() != null && entry.Member.GetCustomAttribute<CultRoleAttribute>() != null)
|
||||
rejections.Add($"Cult document {type.Name} member {Qualified(entry.Member)} declares both [CultIndex] and [CultRole]; [CultIndex] already names its role.");
|
||||
}
|
||||
|
||||
// D10: two differently-named members that independently declare the same index alias. A
|
||||
// hiding member sharing one slot is already caught above; this is two distinct members whose
|
||||
// alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently.
|
||||
|
|
@ -1580,14 +1591,19 @@ namespace GameCult.Caching
|
|||
|
||||
private static string Qualified(MemberInfo member) => member.DeclaringType!.Name.Split('`')[0] + "." + member.Name;
|
||||
|
||||
// The member's role alias, from [CultIndex] or [CultRole] (the member name when the alias is blank).
|
||||
// Shared by DiscoverMembers' D10/D11 rejections and PersistedMember.FromMember, so registration
|
||||
// refuses exactly the shapes the accessor it builds afterward could not have walked anyway.
|
||||
private static string? ResolveIndexAlias(MemberInfo member)
|
||||
{
|
||||
var indexAttribute = member.GetCustomAttribute<CultIndexAttribute>();
|
||||
if (indexAttribute == null)
|
||||
string? alias;
|
||||
if (member.GetCustomAttribute<CultIndexAttribute>() is { } index)
|
||||
alias = index.Alias;
|
||||
else if (member.GetCustomAttribute<CultRoleAttribute>() is { } role)
|
||||
alias = role.Alias;
|
||||
else
|
||||
return null;
|
||||
return string.IsNullOrWhiteSpace(indexAttribute.Alias) ? member.Name : indexAttribute.Alias;
|
||||
return string.IsNullOrWhiteSpace(alias) ? member.Name : alias;
|
||||
}
|
||||
|
||||
private static bool IsCultRecordRefType(Type type) =>
|
||||
|
|
@ -1854,6 +1870,7 @@ namespace GameCult.Caching
|
|||
public int Slot { get; set; }
|
||||
public bool IsName { get; set; }
|
||||
public string? IndexAlias { get; set; }
|
||||
public bool IsUnique { get; set; }
|
||||
public bool IsReference { get; set; }
|
||||
public bool IsMany { get; set; }
|
||||
public string? TargetSchemaName { get; set; }
|
||||
|
|
@ -1866,6 +1883,10 @@ namespace GameCult.Caching
|
|||
public Func<object, string> Getter { get; set; } = default!;
|
||||
public Func<object, string?> GetterNullable { get; set; } = default!;
|
||||
|
||||
// What a [CultIndex] value is: culture-invariant, and a number is its canonical decimal (1.0m and 1.00m are one value,
|
||||
// 0.0 and -0.0 are one value), so the value that decides a refusal is the value a lookup names.
|
||||
public Func<object, string> IndexValue { get; set; } = default!;
|
||||
|
||||
// D8/Q-J (revised 2026-09-22): non-null exactly for the closed CLR numeric set (Nullable<T>
|
||||
// unwrapped), rendered as the member's exact canonical decimal string - never a double, so
|
||||
// no CLR numeric type's range or precision is lost rendering it. docs/cultnet-selection-cut.md
|
||||
|
|
@ -1896,6 +1917,7 @@ namespace GameCult.Caching
|
|||
Slot = slot,
|
||||
IsName = member.GetCustomAttribute<CultNameAttribute>() != null,
|
||||
IndexAlias = ResolveIndexAlias(member),
|
||||
IsUnique = member.GetCustomAttribute<CultIndexAttribute>() != null,
|
||||
IsReference = isReference,
|
||||
IsMany = many,
|
||||
TargetSchemaName = targetSchemaName,
|
||||
|
|
@ -1903,6 +1925,13 @@ namespace GameCult.Caching
|
|||
IsNumeric = IsNumericClrType(memberType),
|
||||
Getter = document => getValue(document)?.ToString() ?? string.Empty,
|
||||
GetterNullable = document => getValue(document)?.ToString(),
|
||||
IndexValue = document => getValue(document) switch
|
||||
{
|
||||
null => string.Empty,
|
||||
var raw when IsNumericClrType(raw.GetType()) => RenderCanonicalNumber(raw) ?? string.Empty,
|
||||
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
|
||||
var raw => raw.ToString() ?? string.Empty
|
||||
},
|
||||
NumberOf = document =>
|
||||
{
|
||||
var raw = getValue(document);
|
||||
|
|
@ -2200,8 +2229,8 @@ namespace GameCult.Caching
|
|||
private readonly List<(CacheBackingStore Store, Type[] Homes)> _stores = new();
|
||||
private readonly Dictionary<string, CultStoredDocument> _entries = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<Type, Dictionary<string, string>> _names = new();
|
||||
// Every key holding a value (unordered): the index is truthful about duplicates, so removing one holder never hides another.
|
||||
private readonly Dictionary<(Type Type, string Alias), Dictionary<string, List<string>>> _indexes = new();
|
||||
// One key per value: RefuseIndexSharing decides it, Apply only records it.
|
||||
private readonly Dictionary<(Type Type, string Alias), Dictionary<string, string>> _indexes = new();
|
||||
private readonly Dictionary<Type, string> _globals = new();
|
||||
private readonly CultCodec? _codec;
|
||||
// Keys holding a variant: derived from the entries by Apply, never decided anywhere else.
|
||||
|
|
@ -2671,16 +2700,12 @@ namespace GameCult.Caching
|
|||
.SelectMany(pair => pair.Value.TryGetValue(name, out var key) ? new[] { key } : Array.Empty<string>()));
|
||||
}
|
||||
|
||||
// A value held by one record finds it. Records of one type sharing a value (plain duplicates) find the holder with the
|
||||
// smallest key (ordinal), whatever order they were written or loaded in: the choice is a function of the held set, so
|
||||
// it is the same live, after a flush and after a reopen, and removing that holder finds the next.
|
||||
// A [CultIndex] value names one record of its type; blank values are not indexed.
|
||||
public T? GetByIndex<T>(string alias, string value) where T : class
|
||||
{
|
||||
return Single<T>($"index {alias}='{value}'", () => _indexes
|
||||
.Where(pair => pair.Key.Alias == alias && typeof(T).IsAssignableFrom(pair.Key.Type))
|
||||
.SelectMany(pair => pair.Value.TryGetValue(value, out var keys)
|
||||
? new[] { keys.Aggregate((best, next) => string.CompareOrdinal(next, best) < 0 ? next : best) }
|
||||
: Array.Empty<string>()));
|
||||
.SelectMany(pair => pair.Value.TryGetValue(value, out var key) ? new[] { key } : Array.Empty<string>()));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
@ -3270,12 +3295,10 @@ namespace GameCult.Caching
|
|||
{
|
||||
var plan = ResolveVariants(admitted, evicted, source);
|
||||
Project(plan);
|
||||
// The variant rules are judged only when a variant is involved.
|
||||
RefuseIndexSharing(plan, evicted);
|
||||
// The variant name rule is judged only when a variant is involved.
|
||||
if (_variantKeys.Count != 0 || admitted.Any(stored => stored.Variant != null))
|
||||
{
|
||||
RefuseVariantNameSharing(plan);
|
||||
RefuseVariantIndexSharing(plan, evicted);
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
|
@ -3472,63 +3495,60 @@ namespace GameCult.Caching
|
|||
}
|
||||
}
|
||||
|
||||
// The one index rule, judged on the commit's result whatever the write order: a variant may not share an indexed
|
||||
// value with another record. It reads the truthful held index (every holder of a value), minus the records this
|
||||
// commit replaces or removes, plus the records it lands. Plain-vs-plain sharing is not this rule's business.
|
||||
// A unique index maps a value to one key at lookup; a variant sharing it would be found in place of its
|
||||
// holder or lose the value, and the store would refuse to reopen.
|
||||
private void RefuseVariantIndexSharing(VariantPlan plan, IReadOnlyList<CultStoredDocument> evicted)
|
||||
// The one index rule, a function of the state the admission would leave and never of staging, file or pull order:
|
||||
// within one document type and one [CultIndex] alias, no two records hold one non-blank value. The holders of a
|
||||
// value are the held key (unless this admission replaces or evicts it) plus every landing key projecting it. Over
|
||||
// every (type, alias, value) held at two or more distinct keys, the refusal names the least by (alias, its least
|
||||
// key, its second-least key, schema name), each compared by UTF-16 code unit, and never names the value.
|
||||
private void RefuseIndexSharing(VariantPlan plan, IReadOnlyList<CultStoredDocument> evicted)
|
||||
{
|
||||
var landing = plan.Admitted.Concat(plan.Dependents).ToList();
|
||||
if (landing.All(stored => stored.Descriptor.IndexAccessors.Count == 0))
|
||||
return;
|
||||
var replaced = new HashSet<string>(
|
||||
plan.Admitted.Concat(evicted).Concat(plan.Dependents).Select(stored => stored.Key.Value), StringComparer.Ordinal);
|
||||
var landingByKey = landing.ToDictionary(stored => stored.Key.Value, StringComparer.Ordinal);
|
||||
|
||||
bool IsVariant(string key) =>
|
||||
landingByKey.TryGetValue(key, out var landed) ? landed.Variant != null : _variantKeys.Contains(key);
|
||||
|
||||
// One pass over what lands, one lookup per (index, value): the rule is linear in the records landing.
|
||||
var landedValues = new List<(CultStoredDocument Stored, (Type Type, string Alias) Index, string Value)>();
|
||||
var landed = new Dictionary<((Type Type, string Alias) Index, string Value), List<string>>();
|
||||
var schemaNames = landing.Select(stored => stored.Descriptor).GroupBy(descriptor => descriptor.DocumentType)
|
||||
.ToDictionary(group => group.Key, group => group.First().SchemaName);
|
||||
foreach (var stored in landing)
|
||||
{
|
||||
foreach (var (alias, value) in plan.Projections[stored.Key.Value].Indexes)
|
||||
{
|
||||
var index = (stored.Descriptor.DocumentType, alias);
|
||||
landedValues.Add((stored, index, value));
|
||||
if (!landed.TryGetValue((index, value), out var landedKeys))
|
||||
landed[(index, value)] = landedKeys = new List<string>();
|
||||
landedKeys.Add(stored.Key.Value);
|
||||
var slot = ((stored.Descriptor.DocumentType, alias), value);
|
||||
if (!landed.TryGetValue(slot, out var keys))
|
||||
landed[slot] = keys = new List<string>();
|
||||
keys.Add(stored.Key.Value);
|
||||
}
|
||||
}
|
||||
|
||||
var holdersOf = new Dictionary<((Type Type, string Alias) Index, string Value), (string[] All, string[] Variants)>();
|
||||
foreach (var (stored, index, value) in landedValues)
|
||||
(string Alias, string First, string Second, string Schema)? least = null;
|
||||
foreach (var ((index, value), landedKeys) in landed)
|
||||
{
|
||||
if (!holdersOf.TryGetValue((index, value), out var known))
|
||||
{
|
||||
var all = (_indexes.TryGetValue(index, out var held) && held.TryGetValue(value, out var heldKeys)
|
||||
? heldKeys.Where(heldKey => !replaced.Contains(heldKey))
|
||||
: Enumerable.Empty<string>())
|
||||
.Concat(landed[(index, value)])
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
holdersOf[(index, value)] = known = (all, all.Where(IsVariant).ToArray());
|
||||
}
|
||||
|
||||
var self = stored.Key.Value;
|
||||
var holder = known.All.FirstOrDefault(candidate => candidate != self);
|
||||
if (stored.Variant != null && holder != null)
|
||||
throw new InvalidOperationException(
|
||||
$"Variant {self} and {holder} both hold index {index.Alias} '{value}'; a variant must override its indexed member.");
|
||||
var variantHolder = known.Variants.FirstOrDefault(candidate => candidate != self);
|
||||
if (stored.Variant == null && variantHolder != null)
|
||||
throw new InvalidOperationException(
|
||||
$"Record {self} and variant {variantHolder} both hold index {index.Alias} '{value}'; " +
|
||||
"a variant may not share an indexed value with another record.");
|
||||
var holders = landedKeys.AsEnumerable();
|
||||
if (_indexes.TryGetValue(index, out var held) && held.TryGetValue(value, out var heldKey) && !replaced.Contains(heldKey))
|
||||
holders = holders.Append(heldKey);
|
||||
var distinct = holders.Distinct(StringComparer.Ordinal).OrderBy(key => key, StringComparer.Ordinal).Take(2).ToArray();
|
||||
if (distinct.Length < 2)
|
||||
continue;
|
||||
var candidate = (index.Alias, distinct[0], distinct[1], schemaNames[index.Type]);
|
||||
if (least == null || CompareRefusals(candidate, least.Value) < 0)
|
||||
least = candidate;
|
||||
}
|
||||
|
||||
if (least is { } pair)
|
||||
throw new InvalidOperationException(
|
||||
$"Records {pair.First} and {pair.Second} ({pair.Schema}) " +
|
||||
$"both hold unique index '{pair.Alias}'; an index value names one record.");
|
||||
}
|
||||
|
||||
private static int CompareRefusals(
|
||||
(string Alias, string First, string Second, string Schema) left,
|
||||
(string Alias, string First, string Second, string Schema) right)
|
||||
{
|
||||
var order = StringComparer.Ordinal.Compare(left.Alias, right.Alias);
|
||||
if (order == 0) order = StringComparer.Ordinal.Compare(left.First, right.First);
|
||||
if (order == 0) order = StringComparer.Ordinal.Compare(left.Second, right.Second);
|
||||
return order != 0 ? order : StringComparer.Ordinal.Compare(left.Schema, right.Schema);
|
||||
}
|
||||
|
||||
private static InvalidOperationException CycleRefusal(IEnumerable<string> keys) =>
|
||||
|
|
@ -3633,7 +3653,7 @@ namespace GameCult.Caching
|
|||
if (projection.Name != null)
|
||||
MapOf(_names, type)[projection.Name] = stored.Key.Value;
|
||||
foreach (var (alias, value) in projection.Indexes)
|
||||
AddHolder(_indexes, (type, alias), value, stored.Key.Value);
|
||||
MapOf(_indexes, (type, alias))[value] = stored.Key.Value;
|
||||
}
|
||||
|
||||
// Documents are mutable, so a name or index value may have changed since it was indexed: drop by key.
|
||||
|
|
@ -3645,26 +3665,7 @@ namespace GameCult.Caching
|
|||
if (_names.TryGetValue(type, out var names))
|
||||
RemoveKey(names, stored.Key.Value);
|
||||
foreach (var pair in _indexes.Where(pair => pair.Key.Type == type))
|
||||
RemoveHolder(pair.Value, stored.Key.Value);
|
||||
}
|
||||
|
||||
private static void AddHolder(Dictionary<(Type Type, string Alias), Dictionary<string, List<string>>> indexes, (Type, string) index, string value, string key)
|
||||
{
|
||||
if (!indexes.TryGetValue(index, out var map))
|
||||
indexes[index] = map = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
if (!map.TryGetValue(value, out var keys))
|
||||
map[value] = keys = new List<string>();
|
||||
keys.Remove(key);
|
||||
keys.Add(key);
|
||||
}
|
||||
|
||||
private static void RemoveHolder(Dictionary<string, List<string>> map, string key)
|
||||
{
|
||||
foreach (var pair in map.ToArray())
|
||||
{
|
||||
if (pair.Value.Remove(key) && pair.Value.Count == 0)
|
||||
map.Remove(pair.Key);
|
||||
}
|
||||
RemoveKey(pair.Value, stored.Key.Value);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> MapOf<TKey>(Dictionary<TKey, Dictionary<string, string>> maps, TKey key)
|
||||
|
|
@ -4648,13 +4649,17 @@ namespace GameCult.Caching
|
|||
/// <param name="holdsIds">Whether the record's payload holds element ids, or null when the caller cannot tell.</param>
|
||||
/// <param name="registry">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. <paramref name="catalogEntry"/> 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.</param>
|
||||
/// <remarks>The call holds the store's gate while it runs. User code it runs must not enter a cache or another store; that throws <see cref="InvalidOperationException"/>.</remarks>
|
||||
/// <exception cref="InvalidOperationException">The store is attached to a cache; write through the cache.</exception>
|
||||
/// <exception cref="InvalidOperationException">The store is attached to a cache; write through the cache. Also thrown when the record's type declares a <see cref="CultIndexAttribute"/>, and when the catalog entry declares an index alias and the registry lent for the call does not hold the type (none, or another): the call cannot tell a unique index from a role, so such records are written through a cache.</exception>
|
||||
/// <exception cref="CultStoreUnreadableException">The file at the path is not a store this runtime can read.</exception>
|
||||
public void WriteRecord(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds = null, CultDocumentRegistry? registry = null)
|
||||
{
|
||||
if (record == null) throw new ArgumentNullException(nameof(record));
|
||||
if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry));
|
||||
Cacheless(registry, () => WriteOne(record, catalogEntry, holdsIds));
|
||||
Cacheless(registry, () =>
|
||||
{
|
||||
RefuseUniqueIndexedCacheless(AttachedRegistry, catalogEntry);
|
||||
WriteOne(record, catalogEntry, holdsIds);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -4671,7 +4676,7 @@ namespace GameCult.Caching
|
|||
/// <param name="storedAt">The stored-at stamp, or null for now.</param>
|
||||
/// <param name="registry">The registry that describes and encodes the document, or null for <see cref="CultDocumentRegistry.Shared"/>. 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.</param>
|
||||
/// <remarks>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 <see cref="InvalidOperationException"/>, carried by the encoder's wrapper when the code ran inside it.</remarks>
|
||||
/// <exception cref="InvalidOperationException">The store is attached to a cache; write through the cache.</exception>
|
||||
/// <exception cref="InvalidOperationException">The store is attached to a cache; write through the cache. Also thrown when the record's type declares a <see cref="CultIndexAttribute"/>, and when the catalog entry declares an index alias and the registry lent for the call does not hold the type (none, or another): the call cannot tell a unique index from a role, so such records are written through a cache.</exception>
|
||||
/// <exception cref="CultStoreUnreadableException">The file at the path is not a store this runtime can read.</exception>
|
||||
/// <exception cref="CultElementIdException">The document holds an element id or list a cache write refuses.</exception>
|
||||
public void WriteDocument<TDocument>(CultRecordKey key, TDocument document, string? storedAt = null, CultDocumentRegistry? registry = null)
|
||||
|
|
@ -4680,6 +4685,7 @@ namespace GameCult.Caching
|
|||
if (document == null) throw new ArgumentNullException(nameof(document));
|
||||
var resolvedRegistry = registry ?? CultDocumentRegistry.Shared;
|
||||
var catalogEntry = resolvedRegistry.GetRequired<TDocument>().ToCatalogEntry();
|
||||
RefuseUniqueIndexedCacheless(resolvedRegistry, catalogEntry);
|
||||
Cacheless(resolvedRegistry, () =>
|
||||
{
|
||||
var ids = CultElementIds.Plan(typeof(TDocument), document, key.Value, deterministic: false);
|
||||
|
|
@ -4705,6 +4711,19 @@ namespace GameCult.Caching
|
|||
});
|
||||
}
|
||||
|
||||
// A unique index is judged against every held record, which a cacheless write does not hold. A write is refused when the
|
||||
// registry lent for the call holds a type with a [CultIndex] under the schema id, and also when the entry declares an index
|
||||
// alias the call cannot classify (no registry, or one not holding the type): the catalog does not say unique from role.
|
||||
private static void RefuseUniqueIndexedCacheless(CultDocumentRegistry? registry, CultSchemaCatalogEntry entry)
|
||||
{
|
||||
var unique = registry != null && registry.HoldsUniqueIndex(entry.SchemaId);
|
||||
var unclassified = entry.Members.Any(member => member.IndexAlias != null) && (registry == null || !registry.Declared(entry.SchemaId));
|
||||
if (unique || unclassified)
|
||||
throw new InvalidOperationException(
|
||||
"A record of a type with a unique index is written through a cache; a cacheless write cannot judge uniqueness" +
|
||||
(unclassified ? " and the registry lent for the call does not say whether this schema's index is unique." : "."));
|
||||
}
|
||||
|
||||
private void WriteOne(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds)
|
||||
{
|
||||
using (AcquireLock(wait: true))
|
||||
|
|
@ -4727,7 +4746,7 @@ namespace GameCult.Caching
|
|||
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);
|
||||
RefuseWriteIntoMovedStore(durable, upserts, removals);
|
||||
var durableRecords = durable?.Snapshot.Records ?? Array.Empty<CultPersistedRecord>();
|
||||
// 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);
|
||||
|
|
@ -4812,10 +4831,11 @@ namespace GameCult.Caching
|
|||
return false;
|
||||
}
|
||||
|
||||
private void RefuseWriteIntoMovedVariantStore(Durable? durable, IReadOnlyCollection<CultStagedRecord> upserts, IReadOnlyCollection<string> removals)
|
||||
private void RefuseWriteIntoMovedStore(Durable? durable, IReadOnlyCollection<CultStagedRecord> upserts, IReadOnlyCollection<string> removals)
|
||||
{
|
||||
var records = durable?.Snapshot.Records ?? Array.Empty<CultPersistedRecord>();
|
||||
if (records.All(record => record.Variant == null) && upserts.All(staged => staged.Record.Variant == null))
|
||||
if (records.All(record => record.Variant == null) && upserts.All(staged => staged.Record.Variant == null) &&
|
||||
!upserts.Any(staged => AttachedRegistry?.HoldsUniqueIndex(staged.Entry.SchemaId) == true))
|
||||
return;
|
||||
var changed = new HashSet<string>(StringComparer.Ordinal);
|
||||
var versions = records.ToDictionary(record => record.Key, record => durable!.Version(record.Key), StringComparer.Ordinal);
|
||||
|
|
@ -4852,9 +4872,9 @@ namespace GameCult.Caching
|
|||
},
|
||||
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. " +
|
||||
? $"{this} holds a variant or a unique index 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. " +
|
||||
: $"{this} changed since this cache last read it, and it holds a variant or a record with a unique index, which resolve 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."
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ namespace GameCult.Caching
|
|||
{
|
||||
}
|
||||
|
||||
// A unique lookup: within one document type, a non-blank value names one record. Also filterable by CultNet selection.
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
|
||||
public sealed class CultIndexAttribute : Attribute
|
||||
{
|
||||
|
|
@ -49,6 +50,19 @@ namespace GameCult.Caching
|
|||
public string? Alias { get; }
|
||||
}
|
||||
|
||||
// Names the member's selection and reference role (the alias, or the member name). Implies nothing about uniqueness:
|
||||
// many records may hold one value, and a role member is never indexed for lookup.
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
|
||||
public sealed class CultRoleAttribute : Attribute
|
||||
{
|
||||
public CultRoleAttribute(string? alias = null)
|
||||
{
|
||||
Alias = alias;
|
||||
}
|
||||
|
||||
public string? Alias { get; }
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)]
|
||||
public sealed class CultReferenceAttribute : Attribute
|
||||
{
|
||||
|
|
|
|||
|
|
@ -399,7 +399,7 @@ namespace GameCult.Caching
|
|||
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))].",
|
||||
$"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. To keep an existing readable id, leave that member as it is and add a trailing keyed string member marked [CultElementId(nameof(Member))], where Member is the existing one; the element id is derived from it once and then persisted.",
|
||||
rootPath, path, id);
|
||||
if (!taken.Add(id!))
|
||||
throw Duplicate(id!, path);
|
||||
|
|
|
|||
|
|
@ -22,9 +22,12 @@ public sealed class ItemData
|
|||
|
||||
[Key(1)]
|
||||
[CultIndex]
|
||||
public string Category = string.Empty;
|
||||
public string Sku = string.Empty;
|
||||
|
||||
[Key(2)]
|
||||
public string Category = string.Empty;
|
||||
|
||||
[Key(3)]
|
||||
public int Value;
|
||||
}
|
||||
```
|
||||
|
|
@ -33,7 +36,8 @@ Available cache attributes:
|
|||
|
||||
- `CultDocument(schemaName, schemaVersion)`
|
||||
- `CultName`
|
||||
- `CultIndex(alias?)`
|
||||
- `CultIndex(alias?)`: a unique lookup. Within one document type, a non-blank value names one record; a commit that would leave two records holding one is refused, and a store holding such a pair refuses to load. Uniqueness is judged by a `CultCache` over the single-file store; a cacheless `WriteDocument`/`WriteRecord` of such a type is refused (also when the catalog declares an index alias the call cannot classify), and concurrent writers on a directory store are not covered (`unique-index-directory-store`). A value is culture-invariant, and a number is its canonical decimal (`1.0m` and `1.00m`, `0.0` and `-0.0` are one value).
|
||||
- `CultRole(alias?)`: a selection and reference role. Many records may share a value; it is never a lookup.
|
||||
- `CultGlobal`
|
||||
- `CultReference(targetType?, many: false)`
|
||||
|
||||
|
|
@ -53,6 +57,7 @@ var cache = new CultCache();
|
|||
var handle = await cache.AddAsync(new ItemData
|
||||
{
|
||||
Name = "Potion",
|
||||
Sku = "POT-1",
|
||||
Category = "Consumable",
|
||||
Value = 50
|
||||
});
|
||||
|
|
@ -66,7 +71,7 @@ Name and index lookups are driven from attributes, not manual registration.
|
|||
|
||||
```csharp
|
||||
var byName = cache.GetByName<ItemData>("Potion");
|
||||
var byIndex = cache.GetByIndex<ItemData>("Category", "Consumable");
|
||||
var byIndex = cache.GetByIndex<ItemData>("Sku", "POT-1");
|
||||
```
|
||||
|
||||
### Explicit references
|
||||
|
|
@ -130,6 +135,7 @@ var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack");
|
|||
var handle = await cache.UpsertAsync(new ItemData
|
||||
{
|
||||
Name = "Potion",
|
||||
Sku = "POT-1",
|
||||
Category = "Consumable",
|
||||
Value = 50
|
||||
});
|
||||
|
|
@ -222,6 +228,60 @@ 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.
|
||||
|
||||
### A type that already has a readable id
|
||||
|
||||
If the element type already has a readable string id (`Name`, `Slug`, a catalog
|
||||
key), leave it as it is. Add a trailing keyed string member marked
|
||||
`[CultElementId(nameof(ExistingId))]`; the element id is derived from the
|
||||
existing member on load or first write, and then persisted:
|
||||
|
||||
```csharp
|
||||
[MessagePackObject]
|
||||
public sealed class Recipe
|
||||
{
|
||||
[Key(0)] public string Name = string.Empty; // existing readable id
|
||||
[Key(1)] public int Yield;
|
||||
[Key(2)] [CultElementId(nameof(Name))] public string ElementId = string.Empty; // new, last
|
||||
}
|
||||
```
|
||||
|
||||
Marking `Name` itself `[CultElementId]` is refused (`Name` is not 12 lowercase
|
||||
hex), and that is deliberate:
|
||||
|
||||
- Minted and hand-written ids share one grammar, 12 lowercase hex, so they can
|
||||
never collide; a derived id is the one readable form and is checked for
|
||||
uniqueness within its list.
|
||||
- An id the consumer can edit is not a stable override address. Variants address
|
||||
an element by its id, so a field that is renamed in place would silently move
|
||||
the overrides to a different element, or to none.
|
||||
- A derived id is persisted once. Renaming `Name` later does not change
|
||||
`ElementId`, so the element and every override that addresses it stay put.
|
||||
`ElementId` is the address; `Name` goes back to being ordinary data.
|
||||
|
||||
Adopting the recipe on data that already exists: the existing member becomes an
|
||||
id source, so it must be non-empty and unique within its list. A record with a
|
||||
duplicate value and an empty one both refuse to load
|
||||
(`CultElementIdException`), so clean such data first.
|
||||
|
||||
Take the next free `[Key]` slot, after the highest key on the type (and above
|
||||
the subclasses' keys on a union base).
|
||||
|
||||
## Store format and rollback
|
||||
|
||||
A store that holds an element id is written as `cultcache.store.v3` (a directory
|
||||
store as `cultcache.store.v5.directory-content-addressed-pages`, `v4` otherwise).
|
||||
A single-file store that holds a variant but no id is `v2`, and a store with
|
||||
neither stays `v1`. The mark follows content: a whole-store write that leaves no
|
||||
element holding an id drops it.
|
||||
|
||||
Refusal by name depends on the store kind and the pin. A **directory** store (`v5`) is refused by every Unity pin from `cultlib-unity-v1.0.57` and `caching-unity-v1.2.0` on (the earliest tags of each line whose `DirectoryMessagePackBackingStore` reads only the `v4` stamp: "only ... is readable"). A **single-file** store (`v3`) is refused only from `cultlib-unity-v2.0.0` and `caching-unity-v2.0.0`: `cultlib-unity-v1.0.61` and `caching-unity-v1.4.0` read the header string and never compare it. The NuGet line (`cultlib-v1.0.32`, the last `cultlib-v` tag) refuses neither: its directory store writes `cultcache.store.v1.directory` and never checks the stamp on read. Rolling back a single-file store to any of those pins reads it silently, drops the ids, and can rewrite the elements without them. That is the dangerous case.
|
||||
|
||||
Rolling a consumer back after it has written a v3 or v5 store is therefore
|
||||
one-way. The only carry-back is narrow and lossy: a whole-store write that leaves
|
||||
no id anywhere in the store (every record rewritten without list elements) writes
|
||||
the store unmarked again, and the ids are gone. Otherwise restore from a copy
|
||||
taken before upgrading. Upgrade every reader before any writer.
|
||||
|
||||
## Persistence discipline
|
||||
|
||||
Persistence stays manual by default. Mutations mark the cache and attached
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ namespace GameCult.Geometry
|
|||
public string DomainId { get; set; } = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string RootKey { get; set; } = string.Empty;
|
||||
|
||||
[Key(2)]
|
||||
|
|
@ -164,7 +164,7 @@ namespace GameCult.Geometry
|
|||
public CultRecordRef<CultGeometryDomainDocument> DomainKey { get; set; }
|
||||
|
||||
[Key(2)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string WorkerGroup { get; set; } = string.Empty;
|
||||
|
||||
[Key(3)]
|
||||
|
|
@ -326,7 +326,7 @@ namespace GameCult.Geometry
|
|||
public CultRecordRef<CultGeometrySelectedCutManifest> CutKey { get; set; }
|
||||
|
||||
[Key(2)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string SelectedCutId { get; set; } = string.Empty;
|
||||
|
||||
[Key(3)]
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ namespace GameCult.Mesh
|
|||
public sealed class CultMeshBodyPublicationDocument
|
||||
{
|
||||
[Key(0), CultName] public string BodyId { get; set; } = string.Empty;
|
||||
[Key(1), CultIndex] public string ProducerId { get; set; } = string.Empty;
|
||||
[Key(1), CultRole] public string ProducerId { get; set; } = string.Empty;
|
||||
[Key(2)] public string SchemaId { get; set; } = string.Empty;
|
||||
[Key(3)] public int LayoutVersion { get; set; }
|
||||
[Key(4)] public long ByteSize { get; set; }
|
||||
|
|
|
|||
|
|
@ -142,17 +142,17 @@ namespace GameCult.Mesh
|
|||
|
||||
/// <summary>Artifact kind, such as asset, build, or package.</summary>
|
||||
[Key(1)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string Kind { get; set; } = CultMeshCdnArtifactKinds.Asset;
|
||||
|
||||
/// <summary>Caller-defined artifact version.</summary>
|
||||
[Key(2)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>SHA-256 hash of the full materialized artifact.</summary>
|
||||
[Key(3)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Full materialized artifact size.</summary>
|
||||
|
|
|
|||
|
|
@ -193,12 +193,12 @@ namespace GameCult.Mesh
|
|||
|
||||
/// <summary>Caller-defined package version.</summary>
|
||||
[Key(1)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Tool that authored the package, such as Brokkr Blender.</summary>
|
||||
[Key(2)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string SourceTool { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Source scene name or path.</summary>
|
||||
|
|
@ -207,12 +207,12 @@ namespace GameCult.Mesh
|
|||
|
||||
/// <summary>Source Blender collection name.</summary>
|
||||
[Key(4)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string SourceCollection { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Stable hash of graph structure and referenced assets.</summary>
|
||||
[Key(5)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string ContentHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Package creation timestamp in round-trip UTC format.</summary>
|
||||
|
|
|
|||
|
|
@ -20,14 +20,14 @@ namespace GameCult.Mesh
|
|||
/// Stable fact identifier.
|
||||
/// </summary>
|
||||
[Key(0)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string FactId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Shard containing the simulation.
|
||||
/// </summary>
|
||||
[Key(1)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string ShardId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -40,21 +40,21 @@ namespace GameCult.Mesh
|
|||
/// Simulation frame or tick.
|
||||
/// </summary>
|
||||
[Key(3)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public long Frame { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject the fact is about.
|
||||
/// </summary>
|
||||
[Key(4)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string SubjectId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Fact kind, such as hit, collision, or ownership.
|
||||
/// </summary>
|
||||
[Key(5)]
|
||||
[CultIndex]
|
||||
[CultRole]
|
||||
public string ClaimKind { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -321,15 +321,16 @@ var bySchema = documents.DocumentBySchema("gamecult.aetheria.station_refit.v1");
|
|||
Typed collections use the same one-call style:
|
||||
|
||||
```csharp
|
||||
var allies = CultMesh.CollectionByIndex<PlayerShipDocument>(
|
||||
// A [CultIndex] value is unique within its type, so the handle holds at most one record.
|
||||
var flagship = CultMesh.CollectionByIndex<PlayerShipDocument>(
|
||||
node,
|
||||
"Faction",
|
||||
"au");
|
||||
"CallSign",
|
||||
"au-1");
|
||||
|
||||
var currentAllies = await allies.LatestAsync();
|
||||
using var changes = allies.WatchChanges(change => UpdateRoster(change));
|
||||
var currentFlagship = await flagship.LatestAsync();
|
||||
using var changes = flagship.WatchChanges(change => UpdateRoster(change));
|
||||
|
||||
var collections = CultMesh.Collections(allies);
|
||||
var collections = CultMesh.Collections(flagship);
|
||||
var roster = await collections.LatestAsync<PlayerShipDocument>();
|
||||
using var rosterChanges = collections.WatchChanges<PlayerShipDocument>(RenderRoster);
|
||||
```
|
||||
|
|
|
|||
|
|
@ -315,6 +315,22 @@ namespace GameCult.Networking
|
|||
public CultNetSelectionCursorKey? CursorKey { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The typed changes (<see cref="CultNetDatabaseChange{T}"/> boxed) one cache commit made, in the order the cache admitted them.
|
||||
/// <see cref="Id"/> is opaque and unique among the commits of one database instance.
|
||||
/// </summary>
|
||||
public sealed class CultNetDatabaseCommit
|
||||
{
|
||||
public CultNetDatabaseCommit(string id, IReadOnlyList<object> changes)
|
||||
{
|
||||
Id = id;
|
||||
Changes = changes;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public IReadOnlyList<object> Changes { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A typed domain change emitted by a CultNet database surface.
|
||||
/// </summary>
|
||||
|
|
@ -587,6 +603,7 @@ namespace GameCult.Networking
|
|||
private readonly Dictionary<(string SchemaId, string RecordKey), long> _lastWriteSequence =
|
||||
new();
|
||||
private readonly Subject<object> _changes = new();
|
||||
private readonly Subject<CultNetDatabaseCommit> _commits = new();
|
||||
private readonly IDisposable _cacheChanges;
|
||||
private readonly IDisposable _cacheJournal;
|
||||
// What a write door of this database knows about the change it is admitting and the cache does not: the wire
|
||||
|
|
@ -1148,6 +1165,17 @@ namespace GameCult.Networking
|
|||
return _changes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watches changes as the cache commits that made them: one event per commit, after every change of it was published to
|
||||
/// <see cref="WatchAllChanges"/>. A consumer that forwards changes to a replica forwards a commit as a unit, so the replica
|
||||
/// can judge the result of the commit and not each record's intermediate state.
|
||||
/// </summary>
|
||||
public Observable<CultNetDatabaseCommit> WatchCommits()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _commits;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watches one record.
|
||||
/// </summary>
|
||||
|
|
@ -1215,6 +1243,7 @@ namespace GameCult.Networking
|
|||
}
|
||||
|
||||
_changes.Dispose();
|
||||
_commits.Dispose();
|
||||
}
|
||||
|
||||
// The cache is the one owner of "a change was committed"; the journal is this database's one decision point and its
|
||||
|
|
@ -1231,6 +1260,8 @@ namespace GameCult.Networking
|
|||
}
|
||||
|
||||
List<Exception>? failures = null;
|
||||
// One journal call is one admission, so one commit; its first Sequence names it.
|
||||
var commit = new CommitGroup(changes.Count == 0 ? "0" : changes[0].Sequence.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
List<CultNetBurnedSequence>? burned = null;
|
||||
List<Exception>? burnCauses = null;
|
||||
foreach (var change in changes)
|
||||
|
|
@ -1288,7 +1319,10 @@ namespace GameCult.Networking
|
|||
}
|
||||
|
||||
lock (_logGate)
|
||||
_stash[change.Sequence] = new Publication(documentType, kind, change.Key, descriptor.SchemaId, shard, change.Document, change.PreviousDocument);
|
||||
{
|
||||
_stash[change.Sequence] = new Publication(documentType, kind, change.Key, descriptor.SchemaId, shard, change.Document, change.PreviousDocument, commit);
|
||||
commit.Unpublished++;
|
||||
}
|
||||
}
|
||||
|
||||
// Every change is stashed and will be published; the writer hears of a failed log afterwards, in one exception.
|
||||
|
|
@ -1321,8 +1355,9 @@ namespace GameCult.Networking
|
|||
Publish(publication);
|
||||
}
|
||||
|
||||
private void Publish(Publication publication) =>
|
||||
PublishUntyped(
|
||||
private void Publish(Publication publication)
|
||||
{
|
||||
var change = PublishUntyped(
|
||||
publication.DocumentType,
|
||||
publication.Kind,
|
||||
publication.Key,
|
||||
|
|
@ -1330,6 +1365,19 @@ namespace GameCult.Networking
|
|||
publication.Shard,
|
||||
publication.Document,
|
||||
publication.PreviousDocument);
|
||||
CultNetDatabaseCommit? finished = null;
|
||||
lock (_logGate)
|
||||
{
|
||||
var commit = publication.Commit;
|
||||
if (change != null)
|
||||
commit.Published.Add(change);
|
||||
if (--commit.Unpublished == 0 && commit.Published.Count > 0)
|
||||
finished = new CultNetDatabaseCommit(commit.Id, commit.Published.ToArray());
|
||||
}
|
||||
|
||||
if (finished != null)
|
||||
_commits.OnNext(finished);
|
||||
}
|
||||
|
||||
private void RecordReplicatedEntry(
|
||||
CultNetShardDescriptor shard,
|
||||
|
|
@ -1467,8 +1515,10 @@ namespace GameCult.Networking
|
|||
string schemaId,
|
||||
CultNetShardDescriptor shard,
|
||||
object? document,
|
||||
object? previousDocument)
|
||||
object? previousDocument,
|
||||
CommitGroup commit)
|
||||
{
|
||||
Commit = commit;
|
||||
DocumentType = documentType;
|
||||
Kind = kind;
|
||||
Key = key;
|
||||
|
|
@ -1485,6 +1535,16 @@ namespace GameCult.Networking
|
|||
public CultNetShardDescriptor Shard { get; }
|
||||
public object? Document { get; }
|
||||
public object? PreviousDocument { get; }
|
||||
public CommitGroup Commit { get; }
|
||||
}
|
||||
|
||||
// The changes of one cache admission, counted down as they are published. Guarded by _logGate.
|
||||
private sealed class CommitGroup
|
||||
{
|
||||
public CommitGroup(string id) => Id = id;
|
||||
public string Id { get; }
|
||||
public int Unpublished { get; set; }
|
||||
public List<object> Published { get; } = new();
|
||||
}
|
||||
|
||||
private sealed class DoorContext
|
||||
|
|
@ -1964,7 +2024,7 @@ namespace GameCult.Networking
|
|||
return $"{schemaId}:{key.Value}";
|
||||
}
|
||||
|
||||
private void PublishUntyped(
|
||||
private object? PublishUntyped(
|
||||
Type documentType,
|
||||
CultNetDatabaseChangeKind kind,
|
||||
CultRecordKey key,
|
||||
|
|
@ -1987,6 +2047,8 @@ namespace GameCult.Networking
|
|||
{
|
||||
_changes.OnNext(change);
|
||||
}
|
||||
|
||||
return change;
|
||||
}
|
||||
|
||||
// No shard owns a schema and key that no descriptor matches, and nothing is written to one: every write path refuses.
|
||||
|
|
|
|||
|
|
@ -765,14 +765,41 @@ namespace GameCult.Networking
|
|||
{
|
||||
// Keys this subscriber only ever received as a refusal: it was never given them as records.
|
||||
var refused = new HashSet<string>(StringComparer.Ordinal);
|
||||
return _database.WatchAllChanges().Subscribe(change =>
|
||||
return _database.WatchCommits().Subscribe(commit =>
|
||||
{
|
||||
var outbound = DeliverChange(change, subscriptionId, selection, refused, error => peer.SendCultNet(error));
|
||||
if (outbound != null)
|
||||
peer.SendCultNet(outbound);
|
||||
foreach (var message in DeliverCommit(commit, subscriptionId, selection, refused, error => peer.SendCultNet(error)))
|
||||
peer.SendCultNet(message);
|
||||
});
|
||||
}
|
||||
|
||||
// One subscriber's view of one commit. Its changes go out together, tagged with the commit, so a replica applies them as one.
|
||||
internal IReadOnlyList<CultNetDatabaseChangeRawMessage> DeliverCommit(
|
||||
CultNetDatabaseCommit commit,
|
||||
string subscriptionId,
|
||||
CultNetSelection selection,
|
||||
HashSet<string> refused,
|
||||
Action<CultNetErrorMessage> sendError)
|
||||
{
|
||||
var outbound = new List<CultNetDatabaseChangeRawMessage>();
|
||||
foreach (var change in commit.Changes)
|
||||
{
|
||||
var message = DeliverChange(change, subscriptionId, selection, refused, sendError);
|
||||
if (message != null)
|
||||
outbound.Add(message);
|
||||
}
|
||||
|
||||
if (outbound.Count > 1)
|
||||
{
|
||||
foreach (var message in outbound)
|
||||
{
|
||||
message.CommitId = commit.Id;
|
||||
message.CommitCount = outbound.Count;
|
||||
}
|
||||
}
|
||||
|
||||
return outbound;
|
||||
}
|
||||
|
||||
// One subscriber's view of one change. A variant is not a record CultNet can carry (Q6): the subscriber is told, by key, and
|
||||
// is not later told the variant was removed, because it never held it.
|
||||
internal CultNetDatabaseChangeRawMessage? DeliverChange(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@ namespace GameCult.Networking
|
|||
private readonly ConcurrentDictionary<string, PendingSubscription> _initialSnapshots = new();
|
||||
private readonly ConcurrentDictionary<string, CultNetDatabaseSubscriptionDeliveryMode> _deliveryModes =
|
||||
new(StringComparer.Ordinal);
|
||||
// Changes of a commit that has not fully arrived, by subscription and commit id.
|
||||
// Guarded by its own lock: the queue fills it, Unsubscribe and Dispose evict. A commit that does not complete is bounded
|
||||
// by MaxPendingCommits and by MaxBufferedChanges, and a violation faults the client.
|
||||
private const int MaxPendingCommits = 64;
|
||||
// Policy: the most changes the incomplete commits may announce between them, the bound on what the buffer can hold. It
|
||||
// sits far above any commit a source makes; a commit that announces more than this could not be held at all.
|
||||
private const int MaxBufferedChanges = 1_000_000;
|
||||
private readonly Dictionary<(string SubscriptionId, string CommitId), PendingCommit> _commits = new();
|
||||
private readonly object _queueLock = new();
|
||||
private Task _queue = Task.CompletedTask;
|
||||
private bool _disposed;
|
||||
|
|
@ -48,6 +56,15 @@ namespace GameCult.Networking
|
|||
/// <summary>Raised after a live change has been applied to the local typed cache.</summary>
|
||||
public event Action<CultNetReplicatedDocumentChange>? Changed;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the local cache refuses a replicated commit. The replica then differs from its source; the client applies
|
||||
/// no further changes and reports <see cref="Fault"/> until it is replaced by a new subscription.
|
||||
/// </summary>
|
||||
public event Action<Exception>? Faulted;
|
||||
|
||||
/// <summary>The refusal that stopped replication, or null while the replica is intact.</summary>
|
||||
public Exception? Fault { get; private set; }
|
||||
|
||||
/// <summary>Gets the local reactive cache receiving replicated documents.</summary>
|
||||
public CultCache Cache => _cache;
|
||||
|
||||
|
|
@ -109,6 +126,8 @@ namespace GameCult.Networking
|
|||
CultNetDatabaseSubscriptionDeliveryMode deliveryMode = CultNetDatabaseSubscriptionDeliveryMode.ReplicateToCache)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(CultNetDatabaseSubscriptionClient));
|
||||
if (Fault != null)
|
||||
throw new InvalidOperationException("The replica refused a commit and differs from its source; subscribe with a new client.", Fault);
|
||||
if (!_client.Connected) throw new InvalidOperationException("CultNet schema client must be connected before subscribing.");
|
||||
if (string.IsNullOrWhiteSpace(subscriptionId)) throw new ArgumentException("Subscription id is required.", nameof(subscriptionId));
|
||||
|
||||
|
|
@ -166,6 +185,9 @@ namespace GameCult.Networking
|
|||
if (_disposed) return;
|
||||
if (string.IsNullOrWhiteSpace(subscriptionId)) throw new ArgumentException("Subscription id is required.", nameof(subscriptionId));
|
||||
_deliveryModes.TryRemove(subscriptionId, out _);
|
||||
lock (_commits)
|
||||
foreach (var held in _commits.Keys.Where(key => key.SubscriptionId == subscriptionId).ToArray())
|
||||
_commits.Remove(held);
|
||||
_client.SendCultNet(new CultNetDatabaseUnsubscribeMessage
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
|
|
@ -181,6 +203,8 @@ namespace GameCult.Networking
|
|||
pending.Completion.TrySetException(new ObjectDisposedException(nameof(CultNetDatabaseSubscriptionClient)));
|
||||
_initialSnapshots.Clear();
|
||||
_deliveryModes.Clear();
|
||||
lock (_commits)
|
||||
_commits.Clear();
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
|
|
@ -211,48 +235,110 @@ namespace GameCult.Networking
|
|||
|
||||
private void HandleChange(CultNetDatabaseChangeRawMessage message)
|
||||
{
|
||||
Enqueue(async () =>
|
||||
Enqueue(() =>
|
||||
{
|
||||
var deliveryMode = _deliveryModes.TryGetValue(message.SubscriptionId, out var configured)
|
||||
? configured
|
||||
: CultNetDatabaseSubscriptionDeliveryMode.ReplicateToCache;
|
||||
if (string.Equals(message.ChangeKind, "removed", StringComparison.Ordinal))
|
||||
// Live delivery applies nothing, so there is no commit to judge; each change is reported as it arrives.
|
||||
if (deliveryMode != CultNetDatabaseSubscriptionDeliveryMode.ReplicateToCache || message.CommitId == null || !(message.CommitCount > 1))
|
||||
return Apply(deliveryMode, new[] { message });
|
||||
|
||||
// The commit's other changes follow on this connection; the replica applies them as one commit.
|
||||
var held = (message.SubscriptionId, message.CommitId);
|
||||
PendingCommit? pending;
|
||||
lock (_commits)
|
||||
{
|
||||
if (deliveryMode == CultNetDatabaseSubscriptionDeliveryMode.ReplicateToCache)
|
||||
await DeleteAsync(message).ConfigureAwait(false);
|
||||
Changed?.Invoke(new CultNetReplicatedDocumentChange(
|
||||
message.SubscriptionId,
|
||||
"removed",
|
||||
message.RecordKey ?? string.Empty,
|
||||
message.SchemaId ?? string.Empty,
|
||||
null));
|
||||
return;
|
||||
// A straggler of a subscription that was ended, or never made, is not buffered. Judged under the lock that
|
||||
// Unsubscribe evicts under: a check before it could pass, lose the race to the eviction, and then buffer.
|
||||
if (Fault != null || !_deliveryModes.ContainsKey(message.SubscriptionId))
|
||||
return Task.CompletedTask;
|
||||
var count = message.CommitCount!.Value;
|
||||
var refusal = !_commits.TryGetValue(held, out pending)
|
||||
? (_commits.Count >= MaxPendingCommits
|
||||
? $"more than {MaxPendingCommits} commits are incomplete at once"
|
||||
: (long)count + _commits.Sum(entry => (long)entry.Value.Announced) > MaxBufferedChanges
|
||||
? $"commit '{message.CommitId}' announces {count} changes; the incomplete commits may announce {MaxBufferedChanges} between them"
|
||||
: null)
|
||||
: pending.Announced != count
|
||||
? $"commit '{message.CommitId}' announces {pending.Announced} and {count} changes"
|
||||
: pending.Ids.Contains(message.MessageId)
|
||||
? $"commit '{message.CommitId}' delivered message '{message.MessageId}' twice"
|
||||
: null;
|
||||
if (refusal != null)
|
||||
{
|
||||
Break(new InvalidOperationException($"CultNet subscription '{message.SubscriptionId}': {refusal}."));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!_commits.TryGetValue(held, out pending))
|
||||
_commits[held] = pending = new PendingCommit(count);
|
||||
pending.Ids.Add(message.MessageId);
|
||||
pending.Messages.Add(message);
|
||||
if (pending.Messages.Count < count)
|
||||
return Task.CompletedTask;
|
||||
_commits.Remove(held);
|
||||
}
|
||||
|
||||
if (message.Document == null) return;
|
||||
var document = deliveryMode == CultNetDatabaseSubscriptionDeliveryMode.Live
|
||||
? _documents.DeserializeRawDocument(message.Document)
|
||||
: await _documents.ApplyRawDocumentPutMessageAsync(
|
||||
_cache,
|
||||
new CultNetDocumentPutRawMessage
|
||||
{
|
||||
MessageId = message.MessageId,
|
||||
Document = message.Document
|
||||
}).ConfigureAwait(false);
|
||||
Changed?.Invoke(new CultNetReplicatedDocumentChange(
|
||||
message.SubscriptionId,
|
||||
message.ChangeKind,
|
||||
message.Document.RecordKey,
|
||||
message.Document.SchemaId,
|
||||
document));
|
||||
return Apply(deliveryMode, pending.Messages);
|
||||
});
|
||||
}
|
||||
|
||||
private Task DeleteAsync(CultNetDatabaseChangeRawMessage message)
|
||||
private sealed class PendingCommit
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message.RecordKey) || string.IsNullOrWhiteSpace(message.SchemaId)) return Task.CompletedTask;
|
||||
_documents.ResolveDescriptorForSchemaId(message.SchemaId!);
|
||||
_cache.Remove(new CultRecordKey(message.RecordKey!));
|
||||
public PendingCommit(int announced) => Announced = announced;
|
||||
public int Announced { get; }
|
||||
public List<CultNetDatabaseChangeRawMessage> Messages { get; } = new();
|
||||
public HashSet<string> Ids { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// The replica no longer matches its source: say so once, drop what was waiting, and apply nothing more.
|
||||
private void Break(Exception error)
|
||||
{
|
||||
lock (_commits)
|
||||
{
|
||||
if (Fault != null)
|
||||
return;
|
||||
Fault = error;
|
||||
_commits.Clear();
|
||||
}
|
||||
|
||||
Faulted?.Invoke(error);
|
||||
}
|
||||
|
||||
private Task Apply(CultNetDatabaseSubscriptionDeliveryMode deliveryMode, IReadOnlyList<CultNetDatabaseChangeRawMessage> changes)
|
||||
{
|
||||
// A replica that refused a commit no longer matches its source. Applying later commits on top would hide that, so
|
||||
// nothing more is applied: the fault is surfaced and the replica is rebuilt by subscribing again.
|
||||
if (Fault != null)
|
||||
return Task.CompletedTask;
|
||||
IReadOnlyList<object?> applied;
|
||||
try
|
||||
{
|
||||
applied = deliveryMode == CultNetDatabaseSubscriptionDeliveryMode.Live
|
||||
? changes.Select(change => change.Document == null ? null : _documents.DeserializeRawDocument(change.Document)).ToArray()
|
||||
: _documents.ApplyRawChangesAsOneCommit(_cache, changes);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Break(error);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
for (var index = 0; index < changes.Count; index++)
|
||||
{
|
||||
var change = changes[index];
|
||||
var removed = string.Equals(change.ChangeKind, "removed", StringComparison.Ordinal);
|
||||
if (!removed && change.Document == null)
|
||||
continue;
|
||||
Changed?.Invoke(new CultNetReplicatedDocumentChange(
|
||||
change.SubscriptionId,
|
||||
change.ChangeKind,
|
||||
removed ? change.RecordKey ?? string.Empty : change.Document!.RecordKey,
|
||||
removed ? change.SchemaId ?? string.Empty : change.Document!.SchemaId,
|
||||
removed ? null : applied[index]));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,20 @@ namespace GameCult.Networking
|
|||
{
|
||||
if (_disposed) return;
|
||||
foreach (var entry in _requests.ToArray())
|
||||
Reconcile(entry.Key, entry.Value);
|
||||
{
|
||||
// Reconcile can run after the cache has admitted a commit and before Watch has seen it, so its diff can carry
|
||||
// changed values; it is held and sent as one commit of its own, as Watch's is, for the replica to judge whole.
|
||||
var outbox = _outbox = new CommitOutbox(Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
Reconcile(entry.Key, entry.Value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_outbox = null;
|
||||
outbox.Send(entry.Key.Peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -300,50 +313,76 @@ namespace GameCult.Networking
|
|||
SubscriptionKey key)
|
||||
{
|
||||
var legacyRequest = request.ToLegacyShape(subscriptionId);
|
||||
return _database.WatchAllChanges().Subscribe(change =>
|
||||
return _database.WatchCommits().Subscribe(commit =>
|
||||
{
|
||||
lock (_lifecycleGate)
|
||||
{
|
||||
if (_disposed || !_requests.ContainsKey(key) || !_projections.TryGetValue(key, out var projection))
|
||||
return;
|
||||
if (_authorizeRequest?.Invoke(legacyRequest, peer) == false)
|
||||
{
|
||||
Reconcile(key, request);
|
||||
return;
|
||||
}
|
||||
|
||||
// D6: cites/cited are set-dependent - a change to row B can change whether row A
|
||||
// matches - so a hop-bearing selection runs the full diff-against-delivered
|
||||
// reconcile on every change instead of the single-row fast path.
|
||||
if (request.Selection.HasHop)
|
||||
{
|
||||
Reconcile(key, request);
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceRecordKey = ResolveChangeRecordKey(change);
|
||||
CultNetRawDocumentRecord? matched;
|
||||
// What this subscription sends for the commit is held and sent together, tagged with the commit, so the replica
|
||||
// applies it as one commit and judges the result.
|
||||
var outbox = _outbox = new CommitOutbox(commit.Id);
|
||||
try
|
||||
{
|
||||
matched = CreateMatchedRecord(change, request, peer);
|
||||
ForwardCommit(commit, request, key, projection, legacyRequest, peer, subscriptionId);
|
||||
}
|
||||
catch (NotSupportedException refusal)
|
||||
finally
|
||||
{
|
||||
// Q6: a variant is not a record CultNet can carry; the subscriber that would have received it is told, by key.
|
||||
peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message));
|
||||
return;
|
||||
_outbox = null;
|
||||
outbox.Send(peer);
|
||||
}
|
||||
|
||||
ApplyProjectedChange(
|
||||
projection,
|
||||
sourceRecordKey,
|
||||
matched,
|
||||
peer,
|
||||
subscriptionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void ForwardCommit(
|
||||
CultNetDatabaseCommit commit,
|
||||
SubscriptionRequest request,
|
||||
SubscriptionKey key,
|
||||
SubscriptionProjectionState projection,
|
||||
CultNetDatabaseSubscribeMessage legacyRequest,
|
||||
ICultNetSchemaServerPeer peer,
|
||||
string subscriptionId)
|
||||
{
|
||||
if (_authorizeRequest?.Invoke(legacyRequest, peer) == false)
|
||||
{
|
||||
Reconcile(key, request);
|
||||
return;
|
||||
}
|
||||
|
||||
// D6: cites/cited are set-dependent - a change to row B can change whether row A
|
||||
// matches - so a hop-bearing selection runs the full diff-against-delivered
|
||||
// reconcile once per commit instead of the single-row fast path.
|
||||
if (request.Selection.HasHop)
|
||||
{
|
||||
Reconcile(key, request);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var change in commit.Changes)
|
||||
{
|
||||
var sourceRecordKey = ResolveChangeRecordKey(change);
|
||||
CultNetRawDocumentRecord? matched;
|
||||
try
|
||||
{
|
||||
matched = CreateMatchedRecord(change, request, peer);
|
||||
}
|
||||
catch (NotSupportedException refusal)
|
||||
{
|
||||
// Q6: a variant is not a record CultNet can carry; the subscriber that would have received it is told, by key.
|
||||
peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message));
|
||||
continue;
|
||||
}
|
||||
|
||||
ApplyProjectedChange(
|
||||
projection,
|
||||
sourceRecordKey,
|
||||
matched,
|
||||
peer,
|
||||
subscriptionId);
|
||||
}
|
||||
}
|
||||
|
||||
private void Reconcile(SubscriptionKey key, SubscriptionRequest request)
|
||||
{
|
||||
if (!_projections.TryGetValue(key, out var projection)) return;
|
||||
|
|
@ -487,12 +526,15 @@ namespace GameCult.Networking
|
|||
string.Equals(left.SourceAgentId, right.SourceAgentId, StringComparison.Ordinal) &&
|
||||
string.Equals(left.SourceRole, right.SourceRole, StringComparison.Ordinal);
|
||||
|
||||
private static void SendUpsert(
|
||||
// The commit being forwarded, under _lifecycleGate: the change messages it produces are held here, not sent one by one.
|
||||
private CommitOutbox? _outbox;
|
||||
|
||||
private void SendUpsert(
|
||||
ICultNetSchemaServerPeer peer,
|
||||
string subscriptionId,
|
||||
CultNetRawDocumentRecord document,
|
||||
bool added) =>
|
||||
peer.SendCultNet(new CultNetDatabaseChangeRawMessage
|
||||
Deliver(peer, new CultNetDatabaseChangeRawMessage
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
SubscriptionId = subscriptionId,
|
||||
|
|
@ -500,11 +542,11 @@ namespace GameCult.Networking
|
|||
Document = document
|
||||
});
|
||||
|
||||
private static void SendRemoval(
|
||||
private void SendRemoval(
|
||||
ICultNetSchemaServerPeer peer,
|
||||
string subscriptionId,
|
||||
CultNetRawDocumentRecord document) =>
|
||||
peer.SendCultNet(new CultNetDatabaseChangeRawMessage
|
||||
Deliver(peer, new CultNetDatabaseChangeRawMessage
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
SubscriptionId = subscriptionId,
|
||||
|
|
@ -513,6 +555,38 @@ namespace GameCult.Networking
|
|||
SchemaId = document.SchemaId
|
||||
});
|
||||
|
||||
private void Deliver(ICultNetSchemaServerPeer peer, CultNetDatabaseChangeRawMessage message)
|
||||
{
|
||||
if (_outbox is { } outbox)
|
||||
outbox.Messages.Add(message);
|
||||
else
|
||||
peer.SendCultNet(message);
|
||||
}
|
||||
|
||||
private sealed class CommitOutbox
|
||||
{
|
||||
private readonly string _commitId;
|
||||
|
||||
public CommitOutbox(string commitId) => _commitId = commitId;
|
||||
|
||||
public List<CultNetDatabaseChangeRawMessage> Messages { get; } = new();
|
||||
|
||||
public void Send(ICultNetSchemaServerPeer peer)
|
||||
{
|
||||
if (Messages.Count > 1)
|
||||
{
|
||||
foreach (var message in Messages)
|
||||
{
|
||||
message.CommitId = _commitId;
|
||||
message.CommitCount = Messages.Count;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var message in Messages)
|
||||
peer.SendCultNet(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void Withdraw(SubscriptionKey key, bool sendRemovals, bool forgetRequest)
|
||||
{
|
||||
if (_subscriptions.TryRemove(key, out var subscription))
|
||||
|
|
|
|||
|
|
@ -722,6 +722,49 @@ namespace GameCult.Networking
|
|||
return document;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies change messages to a cache as one commit, so the cache judges the result of the changes together and not the
|
||||
/// state between them. Returns the applied document of each message, null for a removal. A refusal by the cache throws
|
||||
/// and nothing is applied.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<object?> ApplyRawChangesAsOneCommit(
|
||||
CultCache cache,
|
||||
IReadOnlyList<CultNetDatabaseChangeRawMessage> changes)
|
||||
{
|
||||
var applied = new object?[changes.Count];
|
||||
var staged = new List<(Type? Type, object? Document, CultRecordKey Key)>(changes.Count);
|
||||
for (var index = 0; index < changes.Count; index++)
|
||||
{
|
||||
var change = changes[index];
|
||||
if (string.Equals(change.ChangeKind, "removed", StringComparison.Ordinal))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(change.RecordKey) || string.IsNullOrWhiteSpace(change.SchemaId))
|
||||
continue;
|
||||
ResolveDescriptorForSchemaId(change.SchemaId!);
|
||||
staged.Add((null, null, new CultRecordKey(change.RecordKey!)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (change.Document == null)
|
||||
continue;
|
||||
var document = DeserializeRawDocument(change.Document);
|
||||
applied[index] = document;
|
||||
staged.Add((ResolveDescriptorForRawDocument(change.Document).DocumentType, document, new CultRecordKey(change.Document.RecordKey)));
|
||||
}
|
||||
|
||||
cache.Commit(batch =>
|
||||
{
|
||||
foreach (var (type, document, key) in staged)
|
||||
{
|
||||
if (type == null)
|
||||
batch.Remove(key);
|
||||
else
|
||||
batch.Upsert(type, document!, key);
|
||||
}
|
||||
});
|
||||
return applied;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes one validated raw document without materializing it in a CultCache replica.
|
||||
/// Use this for ephemeral reactive subscriptions whose callback is the consumer.
|
||||
|
|
@ -754,25 +797,22 @@ namespace GameCult.Networking
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies all documents from a raw snapshot response.
|
||||
/// Applies all documents from a raw snapshot response as one commit, so a snapshot that moves a unique-index value
|
||||
/// between records is judged on its result.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<object>> ApplyRawSnapshotResponseAsync(
|
||||
public Task<IReadOnlyList<object>> ApplyRawSnapshotResponseAsync(
|
||||
CultCache cache,
|
||||
CultNetSnapshotResponseRawMessage response)
|
||||
{
|
||||
var applied = new List<object>(response.Documents.Length);
|
||||
foreach (var document in response.Documents)
|
||||
{
|
||||
applied.Add(await ApplyRawDocumentPutMessageAsync(
|
||||
cache,
|
||||
new CultNetDocumentPutRawMessage
|
||||
{
|
||||
MessageId = response.MessageId,
|
||||
Document = document
|
||||
}).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return applied;
|
||||
var applied = ApplyRawChangesAsOneCommit(
|
||||
cache,
|
||||
response.Documents.Select(document => new CultNetDatabaseChangeRawMessage
|
||||
{
|
||||
SubscriptionId = response.MessageId,
|
||||
ChangeKind = "added",
|
||||
Document = document
|
||||
}).ToArray());
|
||||
return Task.FromResult<IReadOnlyList<object>>(applied.Select(document => document!).ToArray());
|
||||
}
|
||||
|
||||
internal CultDocumentDescriptor ResolveDescriptorForRawDocument(CultNetRawDocumentRecord document)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue