Cut 5 (TypeScript). The cache no longer mirrors: StoreRoute and its mirrors list are gone, #resolveRoute returns the single home store (the store that claims the type, else the generic store), and put, putEnvelope and delete write exactly one store. addBackingStore now refuses a second generic store and a type already claimed by another store, attaching nothing when it refuses, matching C#. No bytes change; nothing in CultLib attached more than one store. Tests: rejects a second generic backing store, rejects a type registered to two stores, routes each type to its home store. The writer-by-reader interop test now runs the C# write-routed mode and checks that catalog.cc and run.cc are each a complete v1 snapshot holding only their own record, and that every reader (TS, Rust, C#, Python) reads the routed catalog store. Version 0.13.5 -> 0.14.0; cultnet-ts and cultmesh-ts depend on ^0.14.0 so the workspace link still satisfies their range. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
940 lines
31 KiB
TypeScript
940 lines
31 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { exec, execFile } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
import { homedir, tmpdir } from "node:os";
|
|
import { dirname, join, resolve, delimiter } from "node:path";
|
|
import { test } from "node:test";
|
|
import { promisify } from "node:util";
|
|
|
|
import { decode, encode } from "@msgpack/msgpack";
|
|
import { z } from "zod";
|
|
|
|
import { CultCache } from "../src/cult-cache";
|
|
import { inspectCultCacheBytes } from "../src/cult-cache-inspector";
|
|
import { defineDocumentRegistry, defineDocumentType } from "../src/document";
|
|
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
|
|
import type { CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const execAsync = promisify(exec);
|
|
const cargoCommand = process.env.CARGO ?? (process.platform === "win32" ? join(homedir(), ".cargo", "bin", "cargo.exe") : "cargo");
|
|
const dotnetCommand = process.env.DOTNET ?? (process.platform === "win32" ? join("C:", "Program Files", "dotnet", "dotnet.exe") : "dotnet");
|
|
const pythonCommand = process.env.PYTHON ?? "python";
|
|
const cultCacheTsRoot = resolve(__dirname, "../..");
|
|
const cultLibRoot = findAncestor(cultCacheTsRoot, "CultLib.sln") ?? resolve(cultCacheTsRoot, "..", "CultLib");
|
|
const cultcachePyRoot = resolve(cultLibRoot, "packages", "cultcache-py");
|
|
const cultcachePySrc = ["cultcache-py", "cultnet-py", "cultmesh-py"]
|
|
.map((name) => resolve(cultLibRoot, "packages", name, "src"))
|
|
.join(delimiter);
|
|
const cultcacheRsRoot = existsSync(resolve(cultLibRoot, "packages", "cultcache-rs"))
|
|
? resolve(cultLibRoot, "packages", "cultcache-rs")
|
|
: resolve(cultCacheTsRoot, "..", "cultcache-rs");
|
|
const rustInteropBinary = resolve(
|
|
cultcacheRsRoot,
|
|
"target",
|
|
"debug",
|
|
"examples",
|
|
process.platform === "win32" ? "cultcache_interop.exe" : "cultcache_interop",
|
|
);
|
|
const csharpInteropProject = resolve(
|
|
cultLibRoot,
|
|
"tests",
|
|
"GameCult.Caching.InteropPeer",
|
|
"GameCult.Caching.InteropPeer.csproj",
|
|
);
|
|
const csharpInteropDll = resolve(
|
|
cultLibRoot,
|
|
"bin",
|
|
"GameCult.Caching.InteropPeer",
|
|
"Debug",
|
|
"net10.0",
|
|
"GameCult.Caching.InteropPeer.dll",
|
|
);
|
|
|
|
function findAncestor(start: string, marker: string): string | undefined {
|
|
let current = start;
|
|
while (true) {
|
|
if (existsSync(resolve(current, marker))) {
|
|
return current;
|
|
}
|
|
|
|
const parent = dirname(current);
|
|
if (parent === current) {
|
|
return undefined;
|
|
}
|
|
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
test("CultCache supports registry bootstrap, global documents, and lookup by name and index", async () => {
|
|
const itemDocument = defineDocumentType({
|
|
type: "item",
|
|
schema: z.object({
|
|
name: z.string(),
|
|
category: z.string(),
|
|
value: z.number().int(),
|
|
}),
|
|
name: "name",
|
|
indexes: {
|
|
category: "category",
|
|
},
|
|
});
|
|
|
|
const settingsDocument = defineDocumentType({
|
|
type: "settings",
|
|
schema: z.object({
|
|
theme: z.string(),
|
|
retries: z.number().int().nonnegative(),
|
|
}),
|
|
global: true,
|
|
});
|
|
|
|
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
|
|
const registry = defineDocumentRegistry(itemDocument, settingsDocument);
|
|
|
|
const cache = CultCache.builder()
|
|
.withRegistry(registry)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
|
.build();
|
|
|
|
await cache.put(itemDocument, "item:potion", {
|
|
name: "Potion",
|
|
category: "Consumable",
|
|
value: 50,
|
|
});
|
|
await cache.putGlobal(settingsDocument, {
|
|
theme: "ash",
|
|
retries: 3,
|
|
});
|
|
|
|
assert.deepEqual(cache.getByName(itemDocument, "Potion"), {
|
|
name: "Potion",
|
|
category: "Consumable",
|
|
value: 50,
|
|
});
|
|
assert.equal(cache.getKeyByIndex(itemDocument, "category", "Consumable"), "item:potion");
|
|
assert.deepEqual(cache.getGlobal(settingsDocument), {
|
|
theme: "ash",
|
|
retries: 3,
|
|
});
|
|
|
|
const snapshot = cache.snapshot();
|
|
assert.equal(snapshot.length, 2);
|
|
assert.ok(snapshot.every((entry) => entry.payload instanceof Uint8Array && entry.payload.length > 0));
|
|
|
|
const reloaded = CultCache.builder()
|
|
.withRegistry(registry)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
|
.build();
|
|
await reloaded.pullAllBackingStores();
|
|
|
|
assert.deepEqual(reloaded.getByName(itemDocument, "Potion"), {
|
|
name: "Potion",
|
|
category: "Consumable",
|
|
value: 50,
|
|
});
|
|
assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), {
|
|
theme: "ash",
|
|
retries: 3,
|
|
});
|
|
});
|
|
|
|
test("CultCache can register name and index lookups after entries already exist", async () => {
|
|
const noteDocument = defineDocumentType({
|
|
type: "note",
|
|
schema: z.object({
|
|
title: z.string(),
|
|
author: z.string(),
|
|
body: z.string(),
|
|
}),
|
|
});
|
|
|
|
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
|
|
const cache = CultCache.builder()
|
|
.withDocumentType(noteDocument)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
|
.build();
|
|
|
|
await cache.put(noteDocument, "note:hello", {
|
|
title: "Hello",
|
|
author: "ari",
|
|
body: "world",
|
|
});
|
|
|
|
cache.registerNameLookup(noteDocument, "title");
|
|
cache.registerIndex(noteDocument, "author", "author");
|
|
|
|
assert.equal(cache.getIdByName(noteDocument, "Hello"), "note:hello");
|
|
assert.equal(cache.getIdByIndex(noteDocument, "author", "ari"), "note:hello");
|
|
});
|
|
|
|
test("CultCache fails closed on unknown or illegal persisted polymorphic state", async () => {
|
|
const settingsDocument = defineDocumentType({
|
|
type: "settings",
|
|
schema: z.object({
|
|
theme: z.string(),
|
|
}),
|
|
global: true,
|
|
});
|
|
|
|
class InMemoryStore implements CacheBackingStore {
|
|
constructor(private readonly entries: CultCacheEnvelope[]) {}
|
|
|
|
async pullAll(): Promise<CultCacheEnvelope[]> {
|
|
return this.entries;
|
|
}
|
|
|
|
async push(): Promise<void> {
|
|
throw new Error("not used");
|
|
}
|
|
|
|
async delete(): Promise<void> {
|
|
throw new Error("not used");
|
|
}
|
|
}
|
|
|
|
const cacheWithUnknownType = CultCache.builder()
|
|
.withDocumentType(settingsDocument)
|
|
.withGenericStore(new InMemoryStore([
|
|
{
|
|
key: "mystery",
|
|
type: "forbidden",
|
|
payload: encode({ theme: "ash" }),
|
|
storedAt: new Date().toISOString(),
|
|
},
|
|
]))
|
|
.build();
|
|
|
|
await assert.rejects(
|
|
async () => cacheWithUnknownType.pullAllBackingStores(),
|
|
/No schema is registered for persisted document type "forbidden"\./,
|
|
);
|
|
|
|
const cacheWithDuplicateGlobal = CultCache.builder()
|
|
.withDocumentType(settingsDocument)
|
|
.withGenericStore(new InMemoryStore([
|
|
{
|
|
key: "one",
|
|
type: "settings",
|
|
payload: encode({ theme: "ash" }),
|
|
storedAt: new Date().toISOString(),
|
|
},
|
|
{
|
|
key: "two",
|
|
type: "settings",
|
|
payload: encode({ theme: "ember" }),
|
|
storedAt: new Date().toISOString(),
|
|
},
|
|
]))
|
|
.build();
|
|
|
|
await assert.rejects(
|
|
async () => cacheWithDuplicateGlobal.pullAllBackingStores(),
|
|
/has multiple persisted entries/,
|
|
);
|
|
});
|
|
|
|
test("CultCache accepts generated parse-style schemas without a Zod mirror", async () => {
|
|
type GeneratedSettings = {
|
|
schema_version: "generated.settings.v0";
|
|
theme: string;
|
|
retries: number;
|
|
};
|
|
|
|
const generatedSchema: CultCacheSchema<GeneratedSettings> = {
|
|
parse(input: unknown): GeneratedSettings {
|
|
if (!input || typeof input !== "object") {
|
|
throw new Error("generated settings must be an object");
|
|
}
|
|
|
|
const value = input as Record<string, unknown>;
|
|
if (value.schema_version !== "generated.settings.v0") {
|
|
throw new Error("generated settings schema_version mismatch");
|
|
}
|
|
if (typeof value.theme !== "string") {
|
|
throw new Error("generated settings theme must be a string");
|
|
}
|
|
if (typeof value.retries !== "number") {
|
|
throw new Error("generated settings retries must be a number");
|
|
}
|
|
|
|
return {
|
|
schema_version: "generated.settings.v0",
|
|
theme: value.theme,
|
|
retries: value.retries,
|
|
};
|
|
},
|
|
};
|
|
|
|
const generatedDocument = defineDocumentType({
|
|
type: "generated-settings",
|
|
schema: generatedSchema,
|
|
global: true,
|
|
});
|
|
|
|
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "generated.msgpack");
|
|
const cache = CultCache.builder()
|
|
.withDocumentType(generatedDocument)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
|
.build();
|
|
|
|
await cache.putGlobal(generatedDocument, {
|
|
schema_version: "generated.settings.v0",
|
|
theme: "ash",
|
|
retries: 3,
|
|
});
|
|
|
|
const settings = cache.getRequiredGlobal(generatedDocument);
|
|
assert.equal(settings.schema_version, "generated.settings.v0");
|
|
assert.equal(settings.theme, "ash");
|
|
assert.equal(settings.retries, 3);
|
|
});
|
|
|
|
test("CultCache can ingest raw envelopes without re-encoding the payload", async () => {
|
|
const noteDocument = defineDocumentType({
|
|
type: "note",
|
|
schema: z.object({
|
|
title: z.string(),
|
|
body: z.string(),
|
|
}),
|
|
});
|
|
|
|
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
|
|
const origin = CultCache.builder()
|
|
.withDocumentType(noteDocument)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
|
.build();
|
|
const target = CultCache.builder()
|
|
.withDocumentType(noteDocument)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "target.msgpack")))
|
|
.build();
|
|
|
|
await origin.put(noteDocument, "note:hello", {
|
|
title: "Hello",
|
|
body: "world",
|
|
});
|
|
|
|
const envelope = origin.getRequiredEnvelope(noteDocument, "note:hello");
|
|
const applied = await target.putEnvelope(noteDocument, envelope);
|
|
|
|
assert.deepEqual(applied, {
|
|
title: "Hello",
|
|
body: "world",
|
|
});
|
|
assert.deepEqual(target.getRequired(noteDocument, "note:hello"), applied);
|
|
assert.deepEqual(target.getRequiredEnvelope(noteDocument, "note:hello").payload, envelope.payload);
|
|
});
|
|
|
|
test("SingleFileMessagePackBackingStore writes the CultCache v1 snapshot shape", async () => {
|
|
const namedDocument = defineDocumentType({
|
|
type: "tests.named_entry",
|
|
schema: z.object({
|
|
Name: z.string(),
|
|
Value: z.string(),
|
|
}),
|
|
schemaId: "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8",
|
|
schemaName: "tests.named_entry",
|
|
schemaVersion: "tests.named_entry.v1",
|
|
contentHash: "sha256:23150930afcc1d84f0cb3012ccc2debcb9b4685f62083033bbaab0083f1e832e",
|
|
canonicalSchemaJson: "{\"schemaName\":\"tests.named_entry\",\"schemaVersion\":\"tests.named_entry.v1\",\"members\":[{\"slot\":0,\"name\":\"Name\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":true},{\"slot\":1,\"name\":\"Value\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false}]}",
|
|
members: [
|
|
{
|
|
slot: 0,
|
|
memberName: "Name",
|
|
typeName: "System.String",
|
|
isName: true,
|
|
},
|
|
{
|
|
slot: 1,
|
|
memberName: "Value",
|
|
typeName: "System.String",
|
|
},
|
|
],
|
|
name: "Name",
|
|
});
|
|
|
|
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "snapshot.msgpack");
|
|
const cache = CultCache.builder()
|
|
.withDocumentType(namedDocument)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
|
.build();
|
|
|
|
await cache.put(namedDocument, "record-1", {
|
|
Name: "Teeth",
|
|
Value: "slot-array",
|
|
});
|
|
|
|
const snapshot = decode(await readFile(storePath)) as unknown[];
|
|
assert.equal(snapshot[0], "cultcache.store.v1");
|
|
|
|
const catalog = snapshot[1] as unknown[][];
|
|
assert.equal(catalog.length, 1);
|
|
assert.equal(catalog[0]?.[0], "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8");
|
|
assert.equal(catalog[0]?.[1], "tests.named_entry");
|
|
assert.equal(catalog[0]?.[2], "tests.named_entry.v1");
|
|
|
|
const records = snapshot[2] as unknown[][];
|
|
assert.equal(records.length, 1);
|
|
assert.equal(records[0]?.[0], "record-1");
|
|
assert.equal(records[0]?.[1], "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8");
|
|
assert.ok(records[0]?.[3] instanceof Uint8Array);
|
|
});
|
|
|
|
test("SingleFileMessagePackBackingStore reads CultCache v1 snapshots by schema id", async () => {
|
|
const noteDocument = defineDocumentType({
|
|
type: "tests.named_entry",
|
|
schema: z.object({
|
|
Name: z.string(),
|
|
Value: z.string(),
|
|
}),
|
|
schemaId: "schema-1",
|
|
schemaName: "tests.named_entry",
|
|
schemaVersion: "tests.named_entry.v1",
|
|
contentHash: "hash-1",
|
|
canonicalSchemaJson: "{\"fields\":2}",
|
|
});
|
|
|
|
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "csharp.msgpack");
|
|
await writeFile(
|
|
storePath,
|
|
encode([
|
|
"cultcache.store.v1",
|
|
[
|
|
[
|
|
"schema-1",
|
|
"tests.named_entry",
|
|
"tests.named_entry.v1",
|
|
"hash-1",
|
|
"{\"fields\":2}",
|
|
["schema-1"],
|
|
[],
|
|
],
|
|
],
|
|
[
|
|
[
|
|
"record-1",
|
|
"schema-1",
|
|
"2026-05-08T12:00:00Z",
|
|
encode({
|
|
Name: "Teeth",
|
|
Value: "slot-array",
|
|
}),
|
|
],
|
|
],
|
|
]),
|
|
);
|
|
|
|
const cache = CultCache.builder()
|
|
.withDocumentType(noteDocument)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
|
.build();
|
|
|
|
await cache.pullAllBackingStores();
|
|
assert.deepEqual(cache.getRequired(noteDocument, "record-1"), {
|
|
Name: "Teeth",
|
|
Value: "slot-array",
|
|
});
|
|
assert.equal(cache.getRequiredEnvelope(noteDocument, "record-1").schemaId, "schema-1");
|
|
});
|
|
|
|
test("SingleFileMessagePackBackingStore recovers schema-stamped records missing catalog entries", async () => {
|
|
const stampedDocument = defineDocumentType({
|
|
type: "runtime-policy",
|
|
schema: z.tuple([
|
|
z.literal("tests.schema_stamped_entry.v1"),
|
|
z.string(),
|
|
z.string(),
|
|
]),
|
|
schemaId: "schema-current",
|
|
schemaName: "tests.schema_stamped_entry",
|
|
schemaVersion: "tests.schema_stamped_entry.v1",
|
|
compatibleSchemaIds: ["schema-current"],
|
|
});
|
|
|
|
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "missing-catalog.msgpack");
|
|
await writeFile(
|
|
storePath,
|
|
encode([
|
|
"cultcache.store.v1",
|
|
[],
|
|
[
|
|
[
|
|
"record-1",
|
|
"sha256:stale-schema-id-from-cold-record",
|
|
"2026-06-25T12:00:00Z",
|
|
encode([
|
|
"tests.schema_stamped_entry.v1",
|
|
"schema-stamped",
|
|
"still readable",
|
|
]),
|
|
],
|
|
],
|
|
]),
|
|
);
|
|
|
|
const cache = CultCache.builder()
|
|
.withDocumentType(stampedDocument)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
|
.build();
|
|
|
|
await cache.pullAllBackingStores();
|
|
assert.deepEqual(cache.getRequired(stampedDocument, "record-1"), [
|
|
"tests.schema_stamped_entry.v1",
|
|
"schema-stamped",
|
|
"still readable",
|
|
]);
|
|
assert.equal(
|
|
cache.getRequiredEnvelope(stampedDocument, "record-1").schemaId,
|
|
"sha256:stale-schema-id-from-cold-record",
|
|
);
|
|
});
|
|
|
|
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 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 cache = CultCache.builder()
|
|
.withDocumentType(noteDocument)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
|
.build();
|
|
|
|
await cache.pullAllBackingStores();
|
|
assert.deepEqual(cache.getRequired(noteDocument, "note:hello"), envelopePayload);
|
|
|
|
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);
|
|
});
|
|
|
|
test("CultCache rejects a second generic backing store", () => {
|
|
const cache = new CultCache();
|
|
cache.addGenericBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-a.cc")));
|
|
assert.throws(
|
|
() => cache.addGenericBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-b.cc"))),
|
|
/second generic store/u,
|
|
);
|
|
});
|
|
|
|
test("CultCache rejects a type registered to two stores", () => {
|
|
const cache = new CultCache();
|
|
cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-a.cc")), "item");
|
|
assert.throws(
|
|
() => cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-b.cc")), "settings", "item"),
|
|
/"item" is already routed/u,
|
|
);
|
|
// A refused registration claims nothing, so "settings" is still free.
|
|
cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-c.cc")), "settings");
|
|
});
|
|
|
|
test("CultCache routes each type to its home store", async () => {
|
|
const itemDocument = defineDocumentType({
|
|
type: "item",
|
|
schema: z.object({ name: z.string() }),
|
|
});
|
|
const settingsDocument = defineDocumentType({
|
|
type: "settings",
|
|
schema: z.object({ theme: z.string() }),
|
|
});
|
|
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-routes-"));
|
|
const genericPath = join(tempDir, "generic.cc");
|
|
const settingsPath = join(tempDir, "settings.cc");
|
|
const build = () => CultCache.builder()
|
|
.withRegistry(defineDocumentRegistry(itemDocument, settingsDocument))
|
|
.withBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(genericPath))
|
|
.build();
|
|
const keysIn = async (path: string) =>
|
|
(await new SingleFileMessagePackBackingStore(path).pullAll()).map((entry) => `${entry.type}:${entry.key}`);
|
|
|
|
const cache = build();
|
|
await cache.put(itemDocument, "potion", { name: "Potion" });
|
|
await cache.put(settingsDocument, "app", { theme: "ash" });
|
|
assert.deepEqual(await keysIn(genericPath), ["item:potion"]);
|
|
assert.deepEqual(await keysIn(settingsPath), ["settings:app"]);
|
|
|
|
await cache.delete(settingsDocument, "app");
|
|
assert.deepEqual(await keysIn(settingsPath), []);
|
|
assert.deepEqual(await keysIn(genericPath), ["item:potion"]);
|
|
|
|
const reloaded = build();
|
|
await reloaded.pullAllBackingStores();
|
|
assert.deepEqual(reloaded.getRequired(itemDocument, "potion"), { name: "Potion" });
|
|
assert.equal(reloaded.get(settingsDocument, "app"), undefined);
|
|
});
|
|
|
|
test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Python", async () => {
|
|
await buildInteropPeers();
|
|
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-interop-"));
|
|
const writers = [
|
|
{
|
|
name: "ts",
|
|
write: async (file: string) => writeTsInteropStore(file, "ts-writer"),
|
|
},
|
|
{
|
|
name: "rust",
|
|
write: async (file: string) => runJsonCommand("rust-write", rustInteropBinary, [
|
|
"write",
|
|
"--file", file,
|
|
"--runtime-id", "rust-writer",
|
|
], cultcacheRsRoot),
|
|
},
|
|
{
|
|
name: "csharp",
|
|
write: async (file: string) => runJsonCommand("csharp-write", dotnetCommand, [
|
|
csharpInteropDll,
|
|
"write",
|
|
"--file", file,
|
|
"--runtime-id", "csharp-writer",
|
|
], cultLibRoot),
|
|
},
|
|
{
|
|
name: "python",
|
|
write: async (file: string) => runJsonCommand("python-write", pythonCommand, [
|
|
"-m", "cultcache_py.interop",
|
|
"write",
|
|
"--file", file,
|
|
"--runtime-id", "python-writer",
|
|
], cultcachePyRoot, { PYTHONPATH: cultcachePySrc }),
|
|
},
|
|
];
|
|
const readers = [
|
|
{
|
|
name: "ts",
|
|
read: async (file: string) => readTsInteropStore(file),
|
|
},
|
|
{
|
|
name: "rust",
|
|
read: async (file: string) => runJsonCommand("rust-read", rustInteropBinary, [
|
|
"read",
|
|
"--file", file,
|
|
], cultcacheRsRoot),
|
|
},
|
|
{
|
|
name: "csharp",
|
|
read: async (file: string) => runJsonCommand("csharp-read", dotnetCommand, [
|
|
csharpInteropDll,
|
|
"read",
|
|
"--file", file,
|
|
], cultLibRoot),
|
|
},
|
|
{
|
|
name: "python",
|
|
read: async (file: string) => runJsonCommand("python-read", pythonCommand, [
|
|
"-m", "cultcache_py.interop",
|
|
"read",
|
|
"--file", file,
|
|
], cultcachePyRoot, { PYTHONPATH: cultcachePySrc }),
|
|
},
|
|
];
|
|
|
|
for (const writer of writers) {
|
|
const file = join(tempDir, `${writer.name}.msgpack`);
|
|
const written = await writer.write(file);
|
|
const decoded = decode(await readFile(file)) as unknown[];
|
|
assert.equal(decoded[0], "cultcache.store.v1");
|
|
assert.ok(Array.isArray(decoded[1]), `${writer.name} did not write a schema catalog`);
|
|
assert.ok(Array.isArray(decoded[2]), `${writer.name} did not write records`);
|
|
|
|
for (const reader of readers) {
|
|
const read = await reader.read(file);
|
|
assert.equal(read.documentId, written.documentId, `${reader.name} failed to read ${writer.name}`);
|
|
assert.equal(read.authorRuntimeId, written.authorRuntimeId);
|
|
assert.equal(read.body, "The v1 store format is the contract.");
|
|
assert.ok(read.tags.includes("interop"));
|
|
}
|
|
}
|
|
|
|
// C# write-routed attaches one home store per type: each file is a complete v1 snapshot holding only its own records.
|
|
const catalogFile = join(tempDir, "catalog.cc");
|
|
const runFile = join(tempDir, "run.cc");
|
|
const routed = await runJsonCommand("csharp-write-routed", dotnetCommand, [
|
|
csharpInteropDll,
|
|
"write-routed",
|
|
catalogFile,
|
|
runFile,
|
|
], cultLibRoot);
|
|
const recordsIn = async (file: string) => {
|
|
const decoded = decode(await readFile(file)) as unknown[];
|
|
assert.equal(decoded[0], "cultcache.store.v1");
|
|
assert.ok(Array.isArray(decoded[1]), `${file} has no schema catalog`);
|
|
return (decoded[2] as unknown[][]).map((record) => ({ schemaId: record[1], key: record[0] }));
|
|
};
|
|
const catalogRecords = await recordsIn(catalogFile);
|
|
const runRecords = await recordsIn(runFile);
|
|
assert.deepEqual(catalogRecords.map((record) => record.key), ["note:csharp-routed"]);
|
|
assert.deepEqual(runRecords.map((record) => record.key), ["run-note:csharp-routed"]);
|
|
assert.notEqual(catalogRecords[0]?.schemaId, runRecords[0]?.schemaId);
|
|
for (const reader of readers) {
|
|
const read = await reader.read(catalogFile);
|
|
assert.equal(read.documentId, routed.documentId, `${reader.name} failed to read the routed catalog store`);
|
|
assert.equal(read.body, "One home store per document type.");
|
|
}
|
|
});
|
|
|
|
test("CultCache interop reader accepts missing compatible trailing slots and rejects mismatched slots", async () => {
|
|
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-interop-"));
|
|
const compatible = join(tempDir, "compatible.msgpack");
|
|
const mismatch = join(tempDir, "mismatch.msgpack");
|
|
await writeTsInteropStore(compatible, "legacy-writer", { legacyPayload: true });
|
|
assert.deepEqual(await readTsInteropStore(compatible), {
|
|
schemaVersion: "cultcache.interop_note.v1",
|
|
documentId: "note:legacy-writer",
|
|
authorRuntimeId: "legacy-writer",
|
|
title: "legacy-writer wrote a CultCache note",
|
|
body: "The v1 store format is the contract.",
|
|
tags: [],
|
|
});
|
|
|
|
await writeTsInteropStore(mismatch, "bad-writer", { mismatchedPayload: true });
|
|
await assert.rejects(
|
|
() => readTsInteropStore(mismatch),
|
|
/Expected string/u,
|
|
);
|
|
});
|
|
|
|
test("CultCache inspector decodes v1 store catalog and record payloads from bytes", async () => {
|
|
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-inspector-"));
|
|
const file = join(tempDir, "cache.cc");
|
|
const written = await writeTsInteropStore(file, "inspector");
|
|
|
|
const inspection = inspectCultCacheBytes(file, await readFile(file));
|
|
assert.equal(inspection.format, "cultcache.store.v1");
|
|
assert.equal(inspection.catalog.length, 1);
|
|
assert.equal(inspection.catalog[0]?.schemaName, "cultcache.interop-note");
|
|
assert.equal(inspection.records.length, 1);
|
|
assert.equal(inspection.records[0]?.key, written.documentId);
|
|
assert.deepEqual(inspection.records[0]?.payloadPreview, [
|
|
"cultcache.interop_note.v1",
|
|
written.documentId,
|
|
written.authorRuntimeId,
|
|
written.title,
|
|
written.body,
|
|
written.tags,
|
|
]);
|
|
});
|
|
|
|
test("CultCache inspector recovers schema-stamped records missing catalog entries", async () => {
|
|
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-inspector-"));
|
|
const file = join(tempDir, "missing-catalog.cc");
|
|
|
|
await writeFile(
|
|
file,
|
|
encode([
|
|
"cultcache.store.v1",
|
|
[],
|
|
[
|
|
[
|
|
"record-1",
|
|
"sha256:stale-schema-id-from-cold-record",
|
|
"2026-06-25T12:00:00Z",
|
|
encode([
|
|
"tests.schema_stamped_entry.v1",
|
|
"schema-stamped",
|
|
"still readable",
|
|
]),
|
|
],
|
|
],
|
|
]),
|
|
);
|
|
|
|
const inspection = inspectCultCacheBytes(file, await readFile(file));
|
|
assert.equal(inspection.catalog.length, 1);
|
|
assert.equal(inspection.catalog[0]?.schemaId, "sha256:stale-schema-id-from-cold-record");
|
|
assert.equal(inspection.catalog[0]?.schemaName, "tests.schema_stamped_entry");
|
|
assert.equal(inspection.records[0]?.schemaName, "tests.schema_stamped_entry");
|
|
assert.deepEqual(inspection.records[0]?.payloadPreview, [
|
|
"tests.schema_stamped_entry.v1",
|
|
"schema-stamped",
|
|
"still readable",
|
|
]);
|
|
});
|
|
|
|
interface InteropNote {
|
|
schemaVersion: "cultcache.interop_note.v1";
|
|
documentId: string;
|
|
authorRuntimeId: string;
|
|
title: string;
|
|
body: string;
|
|
tags: string[];
|
|
}
|
|
|
|
const interopNoteDocument = defineDocumentType({
|
|
type: "cultcache.interop-note",
|
|
schemaId: "cultcache.interop-note",
|
|
schemaName: "cultcache.interop-note",
|
|
schemaVersion: "cultcache.interop_note.v1",
|
|
contentHash: "cultcache.interop-note",
|
|
canonicalSchemaJson: "{\"schemaName\":\"cultcache.interop-note\",\"schemaVersion\":\"cultcache.interop_note.v1\",\"members\":[{\"slot\":0,\"name\":\"SchemaVersion\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":1,\"name\":\"DocumentId\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":true},{\"slot\":2,\"name\":\"AuthorRuntimeId\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":3,\"name\":\"Title\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":4,\"name\":\"Body\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":5,\"name\":\"Tags\",\"type\":\"System.String[]\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false}]}",
|
|
compatibleSchemaIds: ["cultcache.interop-note"],
|
|
members: [
|
|
{ slot: 0, memberName: "SchemaVersion", typeName: "System.String" },
|
|
{ slot: 1, memberName: "DocumentId", typeName: "System.String", isName: true },
|
|
{ slot: 2, memberName: "AuthorRuntimeId", typeName: "System.String" },
|
|
{ slot: 3, memberName: "Title", typeName: "System.String" },
|
|
{ slot: 4, memberName: "Body", typeName: "System.String" },
|
|
{ slot: 5, memberName: "Tags", typeName: "System.String[]" },
|
|
],
|
|
schema: z.object({
|
|
schemaVersion: z.literal("cultcache.interop_note.v1"),
|
|
documentId: z.string().min(1),
|
|
authorRuntimeId: z.string().min(1),
|
|
title: z.string().min(1),
|
|
body: z.string().min(1),
|
|
tags: z.array(z.string().min(1)),
|
|
}),
|
|
formatter: {
|
|
encode(value: InteropNote): Uint8Array {
|
|
return encode([
|
|
value.schemaVersion,
|
|
value.documentId,
|
|
value.authorRuntimeId,
|
|
value.title,
|
|
value.body,
|
|
value.tags,
|
|
]);
|
|
},
|
|
decode(payload: Uint8Array): InteropNote {
|
|
const decoded = decode(payload);
|
|
if (!Array.isArray(decoded) || decoded.length < 5) {
|
|
throw new Error("CultCache interop note payload must be a MessagePack slot array.");
|
|
}
|
|
const [schemaVersion, documentId, authorRuntimeId, title, body, tags] = decoded;
|
|
return interopNoteDocument.schema.parse({
|
|
schemaVersion,
|
|
documentId,
|
|
authorRuntimeId,
|
|
title,
|
|
body,
|
|
tags: Array.isArray(tags) ? tags : [],
|
|
});
|
|
},
|
|
},
|
|
});
|
|
|
|
async function writeTsInteropStore(
|
|
file: string,
|
|
runtimeId: string,
|
|
options: { legacyPayload?: boolean; mismatchedPayload?: boolean } = {},
|
|
): Promise<InteropNote> {
|
|
const cache = CultCache.builder()
|
|
.withDocumentType(interopNoteDocument)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(file))
|
|
.build();
|
|
const note: InteropNote = {
|
|
schemaVersion: "cultcache.interop_note.v1",
|
|
documentId: `note:${runtimeId}`,
|
|
authorRuntimeId: runtimeId,
|
|
title: `${runtimeId} wrote a CultCache note`,
|
|
body: "The v1 store format is the contract.",
|
|
tags: [runtimeId, "ts", "interop"],
|
|
};
|
|
|
|
if (options.legacyPayload || options.mismatchedPayload) {
|
|
const payload = options.mismatchedPayload
|
|
? encode([note.schemaVersion, note.documentId, 42, note.title, note.body, note.tags])
|
|
: encode([note.schemaVersion, note.documentId, note.authorRuntimeId, note.title, note.body]);
|
|
await new SingleFileMessagePackBackingStore(file).push({
|
|
key: note.documentId,
|
|
type: interopNoteDocument.type,
|
|
schemaId: interopNoteDocument.schemaId,
|
|
catalogEntry: {
|
|
schemaId: interopNoteDocument.schemaId!,
|
|
schemaName: interopNoteDocument.schemaName!,
|
|
schemaVersion: interopNoteDocument.schemaVersion!,
|
|
contentHash: interopNoteDocument.contentHash!,
|
|
canonicalSchemaJson: interopNoteDocument.canonicalSchemaJson!,
|
|
compatibleSchemaIds: [interopNoteDocument.schemaId!],
|
|
members: interopNoteDocument.members,
|
|
},
|
|
storedAt: new Date().toISOString(),
|
|
payload,
|
|
});
|
|
return note;
|
|
}
|
|
|
|
await cache.put(interopNoteDocument, note.documentId, note);
|
|
return note;
|
|
}
|
|
|
|
async function readTsInteropStore(file: string): Promise<InteropNote> {
|
|
const cache = CultCache.builder()
|
|
.withDocumentType(interopNoteDocument)
|
|
.withGenericStore(new SingleFileMessagePackBackingStore(file))
|
|
.build();
|
|
await cache.pullAllBackingStores();
|
|
const notes = cache.getAll(interopNoteDocument);
|
|
const note = notes[0];
|
|
if (!note) {
|
|
throw new Error("No cultcache.interop-note records found.");
|
|
}
|
|
return note;
|
|
}
|
|
|
|
async function buildInteropPeers(): Promise<void> {
|
|
if (!(await exists(rustInteropBinary))) {
|
|
await execAsync(`"${cargoCommand}" build --quiet --example cultcache_interop`, {
|
|
cwd: cultcacheRsRoot,
|
|
});
|
|
}
|
|
if (!(await exists(csharpInteropDll))) {
|
|
await execAsync(`"${dotnetCommand}" build "${csharpInteropProject}" -nologo`, {
|
|
cwd: cultLibRoot,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function exists(path: string): Promise<boolean> {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function runJsonCommand(
|
|
name: string,
|
|
command: string,
|
|
args: string[],
|
|
cwd: string,
|
|
env: NodeJS.ProcessEnv = {},
|
|
): Promise<any> {
|
|
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
cwd,
|
|
env: { ...process.env, ...env },
|
|
timeout: 30_000,
|
|
});
|
|
const trimmed = stdout.trim();
|
|
if (!trimmed) {
|
|
throw new Error(`${name} produced no stdout.\n${stderr}`);
|
|
}
|
|
|
|
return JSON.parse(trimmed.split(/\r?\n/).at(-1) as string);
|
|
}
|