CultLib/packages/cultnet-ts/test/replication-codec.test.ts
Meta Vens 0c5207b55f One typed owner of document-payload reads and writes: DocumentPayload is opaque, the definition is required
DocumentPayload is an opaque object type over the same runtime Uint8Array, keyed by a string
property, and types CultCacheEnvelope.payload, CultNetRawDocumentRecord.payload and
CultMeshRealtimeFrame.payload. decodeDocumentPayload(definition, payload) and
encodeDocumentPayload(definition, value) are the only reader and writer; documentPayloadFromBytes
and documentPayloadBytes are the two named escapes at the edges where bytes enter and leave.
resolveDocumentFormatter leaves the package index. The cache, cultnet replication, the mesh read
paths, the provider command (now a CultCache document), and the browser's decodeCultNetPayload
(definition required, cultcache-ts/document entry) all go through the owner.

Compile-fail fixtures per payload-carrying package pin the type with expect-error lines.
check-typescript-tests-wired.mjs fails when a test file is run by no script; cultcache-ts now
runs dist-test/test/*.test.js, and the portability workflow runs the check and the cache suite.

Builds in every package (tsc, src and tests); the suites run once, at the end of the pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 19:33:42 +02:00

104 lines
5.2 KiB
TypeScript

import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import test from "node:test";
import { z } from "zod";
import { CultCache, codecs, defineDocumentType, documentPayloadBytes, SingleFileMessagePackBackingStore } from "@gamecult/cultcache-ts";
import { CultNetDocumentRegistry, defineCultNetDocumentBinding } from "../src";
const { float32, float64, int32, int64, slots, string } = codecs;
// tests/vectors/numeric-leaves/mixed-record.msgpack is the bytes the C# reference writes for Mixed { "n1", 7, 8, 1.5f, 2.5 }
// (NumericLeafVectorTests). A payload equal to it is a payload the reference reads.
function referenceMixedRecord(): Uint8Array {
for (let dir = __dirname; ; dir = resolve(dir, "..")) {
const file = join(dir, "tests", "vectors", "numeric-leaves", "mixed-record.msgpack");
if (existsSync(file)) return new Uint8Array(readFileSync(file));
if (resolve(dir, "..") === dir) throw new Error("tests/vectors/numeric-leaves not found above the test.");
}
}
const mixedCodec = slots<{ id: string; i: number; l: number | bigint; f: number; d: number }>({
0: ["id", string],
1: ["i", int32],
2: ["l", int64],
3: ["f", float32],
4: ["d", float64],
});
const mixedDefinition = defineDocumentType({
type: "tests.mixed_record",
schemaId: "tests.mixed_record.v1",
schemaName: "tests.mixed_record",
schemaVersion: "tests.mixed_record.v1",
schema: z.object({ id: z.string(), i: z.number(), l: z.union([z.number(), z.bigint()]), f: z.number(), d: z.number() }),
codec: mixedCodec,
name: "id",
});
const value = { id: "n1", i: 7, l: 8, f: 1.5, d: 2.5 };
const hex = (data: Uint8Array) => Buffer.from(data).toString("hex");
function registry() {
return new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: mixedDefinition })]);
}
test("a codec document replicates as the bytes the C# reference writes, and a snapshot of it applies on another cache", async () => {
const dir = mkdtempSync(join(tmpdir(), "cultnet-codec-"));
try {
const reference = referenceMixedRecord();
const documents = registry();
const origin = CultCache.builder().withDocumentType(mixedDefinition).withGenericStore(new SingleFileMessagePackBackingStore(join(dir, "origin.cc"))).build();
const target = CultCache.builder().withDocumentType(mixedDefinition).withGenericStore(new SingleFileMessagePackBackingStore(join(dir, "target.cc"))).build();
const put = documents.createRawDocumentPutMessage(documents.get("tests.mixed_record")!, "put-1", "n1", value);
assert.equal(hex(documentPayloadBytes(put.document.payload)), hex(reference), "a raw put carries the reference's record, not a map");
await documents.applyRawDocumentPutMessage(origin, put);
assert.equal(hex(documentPayloadBytes(origin.getRequiredEnvelope(mixedDefinition, "n1").payload)), hex(reference));
const raw = documents.createRawSnapshotResponse(origin, "snapshot-raw");
assert.equal(hex(documentPayloadBytes(raw.documents[0].payload)), hex(reference));
await documents.applyRawSnapshotResponse(target, raw);
assert.deepEqual(target.getRequired(mixedDefinition, "n1"), value);
assert.equal(hex(documentPayloadBytes(target.getRequiredEnvelope(mixedDefinition, "n1").payload)), hex(reference));
// The value-level snapshot decodes the stored record through the codec, so a peer applying it parses a record.
const values = documents.createSnapshotResponse(origin, "snapshot-values");
assert.deepEqual(values.documents[0].payload, value);
const other = CultCache.builder().withDocumentType(mixedDefinition).withGenericStore(new SingleFileMessagePackBackingStore(join(dir, "other.cc"))).build();
await documents.applySnapshotResponse(other, values);
assert.equal(hex(documentPayloadBytes(other.getRequiredEnvelope(mixedDefinition, "n1").payload)), hex(reference));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("a definition that carries a codec and a formatter is refused where replication reads it", () => {
const both = {
...mixedDefinition,
formatter: { encode: () => new Uint8Array(), decode: () => value },
} as unknown as typeof mixedDefinition;
const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: both })]);
assert.throws(
() => documents.createRawDocumentPutMessage(documents.get("tests.mixed_record")!, "put-2", "n1", value),
{ message: 'CultCache document type "tests.mixed_record" declares both a codec and a formatter.' },
);
});
test("a document with neither a codec nor a formatter replicates as a map, as before", () => {
const plain = defineDocumentType({
type: "tests.plain_record",
schemaId: "tests.plain_record.v1",
schemaName: "tests.plain_record",
schemaVersion: "tests.plain_record.v1",
schema: z.object({ id: z.string() }),
name: "id",
});
const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: plain })]);
const put = documents.createRawDocumentPutMessage(documents.get("tests.plain_record")!, "put-3", "n1", { id: "n1" });
assert.equal(hex(documentPayloadBytes(put.document.payload)), "81a26964a26e31");
});