2762 lines
124 KiB
TypeScript
2762 lines
124 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { exec, execFile } from "node:child_process";
|
||
import { EventEmitter } from "node:events";
|
||
import { existsSync } from "node:fs";
|
||
import { access, lstat, mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||
import { homedir, tmpdir } from "node:os";
|
||
import { dirname, join, resolve, delimiter } from "node:path";
|
||
import { test } from "node:test";
|
||
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, documentPayloadBytes, documentPayloadFromBytes } from "../src/document";
|
||
import { SingleFileMessagePackBackingStore, readStoreBytes } from "../src/single-file-messagepack-backing-store";
|
||
import { SchemaConflictError, StoreUnreadableError, refuseUnreadStoreBytes } from "../src/store-format";
|
||
import type { AnyCultCacheDocumentDefinition, CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types";
|
||
|
||
const execFileAsync = promisify(execFile);
|
||
const execAsync = promisify(exec);
|
||
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",
|
||
});
|
||
|
||
await cache.registerNameLookup(noteDocument, "title");
|
||
await 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: documentPayloadFromBytes(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: documentPayloadFromBytes(encode({ theme: "ash" })),
|
||
storedAt: new Date().toISOString(),
|
||
},
|
||
{
|
||
key: "two",
|
||
type: "settings",
|
||
payload: documentPayloadFromBytes(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("the cache keeps its own copy of an envelope's bytes: a caller mutating them afterwards changes nothing", async () => {
|
||
const noteDocument = defineDocumentType({ type: "note", schema: z.object({ title: z.string(), body: z.string() }) });
|
||
const origin = CultCache.builder().withDocumentType(noteDocument)
|
||
.withGenericStore(new SingleFileMessagePackBackingStore(join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "origin.msgpack"))).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 before = Buffer.from(documentPayloadBytes(envelope.payload));
|
||
await target.putEnvelope(noteDocument, envelope);
|
||
documentPayloadBytes(envelope.payload).fill(0);
|
||
|
||
assert.deepEqual(Buffer.from(documentPayloadBytes(target.getRequiredEnvelope(noteDocument, "note:hello").payload)), before);
|
||
const read = target.getRequiredEnvelope(noteDocument, "note:hello");
|
||
documentPayloadBytes(read.payload).fill(0);
|
||
assert.deepEqual(Buffer.from(documentPayloadBytes(target.getRequiredEnvelope(noteDocument, "note:hello").payload)), before);
|
||
assert.deepEqual(target.getRequired(noteDocument, "note:hello"), { title: "Hello", body: "world" });
|
||
});
|
||
|
||
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 refuses a record whose schema the catalog does not publish, whatever its payload opens with", async () => {
|
||
const stampedDocument = defineDocumentType({
|
||
type: "runtime-policy",
|
||
schema: z.tuple([
|
||
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 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 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", async () => {
|
||
const cache = new CultCache();
|
||
await cache.addGenericBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-a.cc")));
|
||
await assert.rejects(
|
||
cache.addGenericBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-b.cc"))),
|
||
/second generic store/u,
|
||
);
|
||
});
|
||
|
||
test("CultCache rejects a type registered to two stores", async () => {
|
||
const cache = new CultCache();
|
||
await cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-a.cc")), "item");
|
||
await assert.rejects(
|
||
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.
|
||
await 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 refuses an attach that would move a held record's home", async () => {
|
||
const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ theme: z.string() }) });
|
||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-home-move-"));
|
||
const genericPath = join(tempDir, "generic.cc");
|
||
const settingsPath = join(tempDir, "settings.cc");
|
||
const cache = CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(settingsDocument))
|
||
.withGenericStore(new SingleFileMessagePackBackingStore(genericPath))
|
||
.build();
|
||
await cache.put(settingsDocument, "app", { theme: "v1-generic" });
|
||
|
||
await assert.rejects(
|
||
async () => cache.addBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument),
|
||
/would move "settings" from the generic store to the store routed to settings/u,
|
||
);
|
||
// Nothing attached: the next write still lands in the generic store, and the refused store stays untouched.
|
||
await cache.put(settingsDocument, "app", { theme: "v2-generic" });
|
||
const keysIn = async (path: string) =>
|
||
(await new SingleFileMessagePackBackingStore(path).pullAll()).map((entry) => `${entry.type}:${entry.key}`);
|
||
assert.deepEqual(await keysIn(genericPath), ["settings:app"]);
|
||
assert.equal(existsSync(settingsPath), false);
|
||
});
|
||
|
||
test("CultCache refuses at load a record delivered by a store that is not its home", async () => {
|
||
const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ theme: z.string() }) });
|
||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-load-home-"));
|
||
const genericPath = join(tempDir, "generic.cc");
|
||
const settingsPath = join(tempDir, "settings.cc");
|
||
const writer = CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(settingsDocument))
|
||
.withGenericStore(new SingleFileMessagePackBackingStore(genericPath))
|
||
.build();
|
||
await writer.put(settingsDocument, "app", { theme: "stray" });
|
||
const genericBytes = await readFile(genericPath);
|
||
|
||
const cache = CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(settingsDocument))
|
||
.withBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument)
|
||
.withGenericStore(new SingleFileMessagePackBackingStore(genericPath))
|
||
.build();
|
||
// The cache already holds a record, in its home store, before the refused load.
|
||
await cache.put(settingsDocument, "held", { theme: "home" });
|
||
const before = cache.snapshot();
|
||
const settingsBytes = await readFile(settingsPath);
|
||
await assert.rejects(
|
||
cache.pullAllBackingStores(),
|
||
/"settings" record "app" was loaded from the generic store, but its home is the store routed to settings/u,
|
||
);
|
||
assert.equal(cache.get(settingsDocument, "app"), undefined);
|
||
assert.deepEqual(cache.snapshot(), before);
|
||
assert.deepEqual(cache.getRequired(settingsDocument, "held"), { theme: "home" });
|
||
assert.deepEqual(await readFile(genericPath), genericBytes);
|
||
assert.deepEqual(await readFile(settingsPath), settingsBytes);
|
||
});
|
||
|
||
async function storeState(path: string): Promise<string[]> {
|
||
if (!existsSync(path)) {
|
||
return [];
|
||
}
|
||
return (await new SingleFileMessagePackBackingStore(path).pullAll())
|
||
.map((entry) => `${entry.type}:${entry.key}:${Buffer.from(documentPayloadBytes(entry.payload)).toString("hex")}`)
|
||
.sort();
|
||
}
|
||
|
||
test("putWithThrowingAccessorChangesNeitherStoreNorCache", async () => {
|
||
const itemDocument = defineDocumentType({
|
||
type: "item",
|
||
schema: z.object({ n: z.string() }),
|
||
name: (value: { n: string }) => {
|
||
if (value.n === "bad") {
|
||
throw new Error("accessor refuses bad");
|
||
}
|
||
return value.n;
|
||
},
|
||
});
|
||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-put-accessor-")), "generic.cc");
|
||
const cache = CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(itemDocument))
|
||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||
.build();
|
||
await cache.put(itemDocument, "k", { n: "good" });
|
||
const storeBefore = await storeState(storePath);
|
||
const cacheBefore = cache.snapshot();
|
||
|
||
await assert.rejects(cache.put(itemDocument, "k", { n: "bad" }), /accessor refuses bad/u);
|
||
assert.deepEqual(await storeState(storePath), storeBefore);
|
||
assert.deepEqual(cache.snapshot(), cacheBefore);
|
||
assert.deepEqual(cache.getRequired(itemDocument, "k"), { n: "good" });
|
||
assert.equal(cache.getKeyByName(itemDocument, "good"), "k");
|
||
});
|
||
|
||
test("overwriteUnderRegisteredAccessorThatNowThrowsChangesNeitherStoreNorCache", async () => {
|
||
const itemDocument = defineDocumentType({ type: "item", schema: z.object({ c: z.string() }) });
|
||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-overwrite-accessor-")), "generic.cc");
|
||
const cache = CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(itemDocument))
|
||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||
.build();
|
||
await cache.put(itemDocument, "k", { c: "old" });
|
||
let refusing = false;
|
||
await cache.registerIndex(itemDocument, "category", (value: { c: string }) => {
|
||
if (refusing) {
|
||
throw new Error("accessor refuses now");
|
||
}
|
||
return value.c;
|
||
});
|
||
refusing = true;
|
||
const storeBefore = await storeState(storePath);
|
||
const cacheBefore = cache.snapshot();
|
||
|
||
await assert.rejects(cache.put(itemDocument, "k", { c: "new" }), /accessor refuses now/u);
|
||
assert.deepEqual(await storeState(storePath), storeBefore);
|
||
assert.deepEqual(cache.snapshot(), cacheBefore);
|
||
assert.equal(cache.getKeyByIndex(itemDocument, "category", "old"), "k");
|
||
// Deleting runs no accessor: it uses the lookup values stored when the record was admitted.
|
||
assert.equal(await cache.delete(itemDocument, "k"), true);
|
||
assert.equal(cache.getKeyByIndex(itemDocument, "category", "old"), undefined);
|
||
});
|
||
|
||
test("registeringAccessorThatThrowsOnHeldRecordInstallsNothing", async () => {
|
||
const itemDocument = defineDocumentType({ type: "item", schema: z.object({ n: z.string() }) });
|
||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-register-accessor-")), "generic.cc");
|
||
const cache = CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(itemDocument))
|
||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||
.build();
|
||
await cache.put(itemDocument, "old", { n: "bad" });
|
||
await assert.rejects(
|
||
async () => cache.registerIndex(itemDocument, "i", (value: { n: string }) => {
|
||
if (value.n === "bad") {
|
||
throw new Error("index refuses bad");
|
||
}
|
||
return value.n;
|
||
}),
|
||
/index refuses bad/u,
|
||
);
|
||
await cache.put(itemDocument, "old", { n: "fine" });
|
||
assert.deepEqual(cache.getRequired(itemDocument, "old"), { n: "fine" });
|
||
assert.equal(cache.getKeyByIndex(itemDocument, "i", "fine"), undefined);
|
||
});
|
||
|
||
test("globalReplaceWithFailingStorePushKeepsOldGlobal", async () => {
|
||
const settingsDocument = defineDocumentType({
|
||
type: "settings",
|
||
schema: z.object({ t: z.string() }),
|
||
global: true,
|
||
});
|
||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-global-push-")), "generic.cc");
|
||
const inner = new SingleFileMessagePackBackingStore(storePath);
|
||
let failPush = false;
|
||
const store: CacheBackingStore = {
|
||
pullAll: () => inner.pullAll(),
|
||
delete: (entry) => inner.delete(entry),
|
||
pushAll: (entries, options) => inner.pushAll(entries, options),
|
||
push: async (entry) => {
|
||
if (failPush) {
|
||
throw new Error("disk full");
|
||
}
|
||
return inner.push(entry);
|
||
},
|
||
};
|
||
const cache = CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(settingsDocument))
|
||
.withGenericStore(store)
|
||
.build();
|
||
await cache.putGlobal(settingsDocument, { t: "base" });
|
||
const storeBefore = await storeState(storePath);
|
||
const cacheBefore = cache.snapshot();
|
||
failPush = true;
|
||
|
||
await assert.rejects(cache.putGlobal(settingsDocument, { t: "new" }), /disk full/u);
|
||
await assert.rejects(cache.put(settingsDocument, "other", { t: "new" }), /must use key "__global__"/u);
|
||
assert.deepEqual(await storeState(storePath), storeBefore);
|
||
assert.deepEqual(cache.snapshot(), cacheBefore);
|
||
assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { t: "base" });
|
||
});
|
||
|
||
test("concurrentGlobalPutsCannotBothReachDisk", async () => {
|
||
const settingsDocument = defineDocumentType({
|
||
type: "settings",
|
||
schema: z.object({ t: z.string() }),
|
||
global: true,
|
||
});
|
||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-global-race-")), "generic.cc");
|
||
const inner = new SingleFileMessagePackBackingStore(storePath);
|
||
// The first push after arming writes, then pauses until released. A second write that reaches the
|
||
// store during that pause is recorded: serialized writes cannot.
|
||
let armed = false;
|
||
let paused = false;
|
||
let reachedStoreWhilePaused = false;
|
||
let release!: () => void;
|
||
let reportPaused!: () => void;
|
||
const pausedSignal = new Promise<void>((resolve) => {
|
||
reportPaused = resolve;
|
||
});
|
||
const store: CacheBackingStore = {
|
||
pullAll: () => inner.pullAll(),
|
||
delete: (entry) => inner.delete(entry),
|
||
push: async (entry) => {
|
||
reachedStoreWhilePaused ||= paused;
|
||
await inner.push(entry);
|
||
if (armed) {
|
||
armed = false;
|
||
paused = true;
|
||
await new Promise<void>((resolve) => {
|
||
release = resolve;
|
||
reportPaused();
|
||
});
|
||
paused = false;
|
||
}
|
||
},
|
||
};
|
||
const build = (backing: CacheBackingStore) => CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(settingsDocument))
|
||
.withGenericStore(backing)
|
||
.build();
|
||
const cache = build(store);
|
||
await cache.putGlobal(settingsDocument, { t: "base" });
|
||
|
||
armed = true;
|
||
const first = cache.putGlobal(settingsDocument, { t: "first" });
|
||
await pausedSignal;
|
||
const second = cache.putGlobal(settingsDocument, { t: "second" });
|
||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||
release();
|
||
await Promise.all([first, second]);
|
||
|
||
assert.equal(reachedStoreWhilePaused, false);
|
||
const onDisk = await inner.pullAll();
|
||
assert.deepEqual(onDisk.map((entry) => entry.key), [CultCache.GLOBAL_KEY]);
|
||
assert.deepEqual(
|
||
Buffer.from(documentPayloadBytes(onDisk[0].payload)),
|
||
Buffer.from(documentPayloadBytes(cache.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY).payload)),
|
||
);
|
||
assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { t: "second" });
|
||
const reloaded = build(new SingleFileMessagePackBackingStore(storePath));
|
||
await reloaded.pullAllBackingStores();
|
||
assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), { t: "second" });
|
||
});
|
||
|
||
test("unawaitedFailingPutIsAnUnhandledRejection", async () => {
|
||
const itemDocument = defineDocumentType({ type: "item", schema: z.object({ n: z.string() }) });
|
||
const store: CacheBackingStore = {
|
||
pullAll: async () => [],
|
||
push: async () => {
|
||
throw new Error("disk full");
|
||
},
|
||
delete: async () => undefined,
|
||
};
|
||
const cache = CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(itemDocument))
|
||
.withGenericStore(store)
|
||
.build();
|
||
|
||
const runnerListeners = process.listeners("unhandledRejection");
|
||
process.removeAllListeners("unhandledRejection");
|
||
const unhandled: unknown[] = [];
|
||
const listener = (reason: unknown) => {
|
||
unhandled.push(reason);
|
||
};
|
||
process.on("unhandledRejection", listener);
|
||
try {
|
||
void cache.put(itemDocument, "k", { n: "x" });
|
||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||
} finally {
|
||
process.off("unhandledRejection", listener);
|
||
for (const runnerListener of runnerListeners) {
|
||
process.on("unhandledRejection", runnerListener);
|
||
}
|
||
}
|
||
|
||
assert.equal(unhandled.length, 1);
|
||
assert.match(String((unhandled[0] as Error).message), /disk full/u);
|
||
// The chain survived the rejection: the next operation still runs.
|
||
await assert.rejects(cache.put(itemDocument, "k", { n: "y" }), /disk full/u);
|
||
});
|
||
|
||
test("storeChangeHandlerWritingBackQueuesAfterTheOperation", async () => {
|
||
const itemDocument = defineDocumentType({ type: "item", schema: z.object({ n: z.string() }) });
|
||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-writeback-")), "generic.cc");
|
||
const inner = new SingleFileMessagePackBackingStore(storePath);
|
||
const changes = new EventEmitter();
|
||
const pushed: string[] = [];
|
||
const store: CacheBackingStore = {
|
||
pullAll: () => inner.pullAll(),
|
||
delete: (entry) => inner.delete(entry),
|
||
push: async (entry) => {
|
||
await inner.push(entry);
|
||
pushed.push(entry.key);
|
||
changes.emit("pushed", entry.key);
|
||
},
|
||
};
|
||
const cache = CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(itemDocument))
|
||
.withGenericStore(store)
|
||
.build();
|
||
// The handler writes back and the store does not wait for it: that write queues behind the put.
|
||
const writeBacks: Promise<unknown>[] = [];
|
||
changes.on("pushed", (key: string) => {
|
||
if (key === "k") {
|
||
writeBacks.push(cache.put(itemDocument, "side", { n: "side" }));
|
||
}
|
||
});
|
||
|
||
await cache.put(itemDocument, "k", { n: "x" });
|
||
await Promise.all(writeBacks);
|
||
|
||
assert.equal(writeBacks.length, 1);
|
||
assert.deepEqual(pushed, ["k", "side"]);
|
||
assert.deepEqual(cache.get(itemDocument, "k"), { n: "x" });
|
||
assert.deepEqual(cache.get(itemDocument, "side"), { n: "side" });
|
||
assert.deepEqual(
|
||
(await inner.pullAll()).map((entry) => entry.key).sort(),
|
||
cache.snapshot().map((entry) => entry.key).sort(),
|
||
);
|
||
});
|
||
|
||
test("legacyKeyGlobalLoadsWithoutWritingAndFirstWriteLeavesOneGlobal", async () => {
|
||
const settingsDocument = defineDocumentType({
|
||
type: "settings",
|
||
schema: z.object({ t: z.string() }),
|
||
global: true,
|
||
});
|
||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-legacy-global-")), "generic.cc");
|
||
const build = () => CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(settingsDocument))
|
||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||
.build();
|
||
const seed = CultCache.builder().withRegistry(defineDocumentRegistry(settingsDocument)).build();
|
||
await seed.putGlobal(settingsDocument, { t: "old" });
|
||
const legacy = { ...seed.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY), key: "legacy" };
|
||
await new SingleFileMessagePackBackingStore(storePath).push(legacy);
|
||
const bytes = await readFile(storePath);
|
||
|
||
const cache = build();
|
||
await cache.pullAllBackingStores();
|
||
assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { t: "old" });
|
||
assert.deepEqual(cache.get(settingsDocument, CultCache.GLOBAL_KEY), { t: "old" });
|
||
assert.deepEqual(await readFile(storePath), bytes);
|
||
|
||
await cache.putGlobal(settingsDocument, { t: "new" });
|
||
assert.deepEqual(
|
||
(await new SingleFileMessagePackBackingStore(storePath).pullAll()).map((entry) => entry.key),
|
||
[CultCache.GLOBAL_KEY],
|
||
);
|
||
const reloaded = build();
|
||
await reloaded.pullAllBackingStores();
|
||
assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), { t: "new" });
|
||
|
||
// Deleting an adopted global removes the legacy record.
|
||
await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy]);
|
||
const deleting = build();
|
||
await deleting.pullAllBackingStores();
|
||
assert.equal(await deleting.deleteGlobal(settingsDocument), true);
|
||
assert.deepEqual(await new SingleFileMessagePackBackingStore(storePath).pullAll(), []);
|
||
|
||
// Two globals of one type on disk, under any keys, are refused.
|
||
await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy, { ...legacy, key: CultCache.GLOBAL_KEY }]);
|
||
await assert.rejects(build().pullAllBackingStores(), /has multiple persisted entries/u);
|
||
});
|
||
|
||
test("concurrentPutAndAttachCannotLandRecordInNonHomeStore", async () => {
|
||
const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ t: z.string() }) });
|
||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-put-attach-race-"));
|
||
const genericPath = join(tempDir, "generic.cc");
|
||
const settingsPath = join(tempDir, "settings.cc");
|
||
const cache = CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(settingsDocument))
|
||
.withGenericStore(new SingleFileMessagePackBackingStore(genericPath))
|
||
.build();
|
||
|
||
const pending = cache.put(settingsDocument, "app", { t: "x" });
|
||
const attach = Promise.resolve().then(() =>
|
||
cache.addBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument),
|
||
);
|
||
await pending;
|
||
await assert.rejects(attach, /would move "settings" from the generic store/u);
|
||
|
||
assert.deepEqual(
|
||
(await new SingleFileMessagePackBackingStore(genericPath).pullAll()).map((entry) => entry.key),
|
||
["app"],
|
||
);
|
||
assert.equal(existsSync(settingsPath), false);
|
||
const reloaded = CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(settingsDocument))
|
||
.withGenericStore(new SingleFileMessagePackBackingStore(genericPath))
|
||
.build();
|
||
await reloaded.pullAllBackingStores();
|
||
assert.deepEqual(reloaded.getRequired(settingsDocument, "app"), { t: "x" });
|
||
});
|
||
|
||
test("pullWithDuplicateGlobalLeavesCacheUnchanged", async () => {
|
||
const itemDocument = defineDocumentType({ type: "item", schema: z.object({ name: z.string() }) });
|
||
const settingsDocument = defineDocumentType({
|
||
type: "settings",
|
||
schema: z.object({ theme: z.string() }),
|
||
global: true,
|
||
});
|
||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-duplicate-global-"));
|
||
const storePath = join(tempDir, "generic.cc");
|
||
const cache = CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(itemDocument, settingsDocument))
|
||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||
.build();
|
||
await cache.put(itemDocument, "potion", { name: "Potion" });
|
||
await cache.putGlobal(settingsDocument, { theme: "ash" });
|
||
const before = cache.snapshot();
|
||
|
||
// A second record of the global type lands in the store file behind the cache's back.
|
||
const stray = cache.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY);
|
||
await new SingleFileMessagePackBackingStore(storePath).push({ ...stray, key: "stray" });
|
||
|
||
await assert.rejects(cache.pullAllBackingStores(), /has multiple persisted entries/u);
|
||
assert.deepEqual(cache.snapshot(), before);
|
||
assert.deepEqual(cache.getRequired(itemDocument, "potion"), { name: "Potion" });
|
||
assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { theme: "ash" });
|
||
});
|
||
|
||
test("pullWithThrowingIndexAccessorLeavesCacheUnchanged", async () => {
|
||
const itemDocument = defineDocumentType({
|
||
type: "item",
|
||
schema: z.object({ name: z.string(), category: z.string() }),
|
||
name: "name",
|
||
indexes: {
|
||
category: (item: { name: string; category: string }) => {
|
||
if (item.name === "Bomb") {
|
||
throw new Error("category accessor refuses Bomb");
|
||
}
|
||
return item.category;
|
||
},
|
||
},
|
||
});
|
||
const settingsDocument = defineDocumentType({
|
||
type: "settings",
|
||
schema: z.object({ theme: z.string() }),
|
||
global: true,
|
||
});
|
||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-throwing-accessor-"));
|
||
const storePath = join(tempDir, "generic.cc");
|
||
const cache = CultCache.builder()
|
||
.withRegistry(defineDocumentRegistry(itemDocument, settingsDocument))
|
||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||
.build();
|
||
await cache.put(itemDocument, "potion", { name: "Potion", category: "consumable" });
|
||
await cache.putGlobal(settingsDocument, { theme: "ash" });
|
||
const before = cache.snapshot();
|
||
|
||
// A record whose index accessor throws lands in the store file behind the cache's back.
|
||
const potion = cache.getRequiredEnvelope(itemDocument, "potion");
|
||
await new SingleFileMessagePackBackingStore(storePath).push({
|
||
...potion,
|
||
key: "bomb",
|
||
payload: documentPayloadFromBytes(encode({ name: "Bomb", category: "explosive" })),
|
||
});
|
||
|
||
await assert.rejects(cache.pullAllBackingStores(), /category accessor refuses Bomb/u);
|
||
assert.deepEqual(cache.snapshot(), before);
|
||
assert.deepEqual(cache.getRequired(itemDocument, "potion"), { name: "Potion", category: "consumable" });
|
||
assert.equal(cache.get(itemDocument, "bomb"), undefined);
|
||
assert.equal(cache.getKeyByName(itemDocument, "Potion"), "potion");
|
||
assert.equal(cache.getKeyByName(itemDocument, "Bomb"), undefined);
|
||
assert.equal(cache.getKeyByIndex(itemDocument, "category", "consumable"), "potion");
|
||
assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { theme: "ash" });
|
||
});
|
||
|
||
test("CultCache with zero stores writes and deletes in memory", async () => {
|
||
const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ theme: z.string() }) });
|
||
const cache = CultCache.builder().withRegistry(defineDocumentRegistry(settingsDocument)).build();
|
||
await cache.put(settingsDocument, "x", { theme: "mem" });
|
||
assert.deepEqual(cache.getRequired(settingsDocument, "x"), { theme: "mem" });
|
||
assert.equal(await cache.delete(settingsDocument, "x"), true);
|
||
assert.equal(cache.get(settingsDocument, "x"), 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.");
|
||
}
|
||
});
|
||
|
||
// Records and catalog entries a runtime does not own survive its writes. C# writes a store and each other runtime writes its own
|
||
// note into it: C# reads its note back, and its record and catalog entry are as C# wrote them. C# then writes again, and the other
|
||
// runtime's record and catalog entry are as that runtime wrote them. Identity is compared on the decoded MessagePack values, every
|
||
// slot of the entry and of its members included.
|
||
test("A runtime's writes lay back another runtime's records and catalog entries unchanged", async () => {
|
||
await buildInteropPeers();
|
||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-lay-back-"));
|
||
const csharpWrite = (file: string, runtimeId: string) =>
|
||
runJsonCommand("csharp-write", dotnetCommand, [csharpInteropDll, "write", "--file", file, "--runtime-id", runtimeId], cultLibRoot);
|
||
const others = [
|
||
{
|
||
name: "rust",
|
||
write: (file: string) => runJsonCommand("rust-write", rustInteropBinary, ["write", "--file", file, "--runtime-id", "rust-writer"], cultcacheRsRoot),
|
||
},
|
||
{ name: "ts", write: (file: string) => writeTsInteropStore(file, "ts-writer") },
|
||
{
|
||
name: "python",
|
||
write: (file: string) => runJsonCommand("python-write", pythonCommand, [
|
||
"-m", "cultcache_py.interop", "write", "--file", file, "--runtime-id", "python-writer",
|
||
], cultcachePyRoot, { PYTHONPATH: cultcachePySrc }),
|
||
},
|
||
];
|
||
const stored = async (file: string, key: string) => {
|
||
const [, catalog, records] = decode(await readFile(file)) as [string, unknown[][], unknown[][]];
|
||
const record = records.find((candidate) => candidate[0] === key);
|
||
assert.ok(record, `${file} holds no record ${key}`);
|
||
const entry = catalog.find((candidate) => candidate[0] === record[1]);
|
||
assert.ok(entry, `${file} publishes no entry owning ${String(record[1])}`);
|
||
return { record, entry };
|
||
};
|
||
|
||
for (const other of others) {
|
||
const file = join(tempDir, `${other.name}.cc`);
|
||
await csharpWrite(file, "csharp-writer");
|
||
const csharp = await stored(file, "note:csharp-writer");
|
||
|
||
const written = await other.write(file);
|
||
assert.deepEqual(await stored(file, "note:csharp-writer"), csharp, `${other.name} rewrote the C# record or its catalog entry`);
|
||
const read = await runJsonCommand("csharp-read", dotnetCommand, [csharpInteropDll, "read", "--file", file], cultLibRoot);
|
||
assert.ok(read.documentId === "note:csharp-writer" || read.documentId === written.documentId, `C# failed to read after ${other.name}`);
|
||
const theirs = await stored(file, written.documentId);
|
||
assert.notEqual(theirs.record[1], csharp.record[1], `${other.name} writes under its own id`);
|
||
|
||
await csharpWrite(file, "csharp-again");
|
||
assert.deepEqual(await stored(file, written.documentId), theirs, `C# rewrote the ${other.name} record or its catalog entry`);
|
||
assert.deepEqual(await stored(file, "note:csharp-writer"), csharp);
|
||
}
|
||
});
|
||
|
||
// A schema renamed under a stable id: the record's schema id names its type, and the name its catalog entry now carries is metadata.
|
||
// A reader that resolved the record by that name would not find its type, so every runtime that can hold the stable id opens it. The
|
||
// C# reader is not among them: its schema ids are content hashes, which this file does not carry.
|
||
test("CultCache v1 stores stay readable across TS, Rust and Python when a schema is renamed under a stable id", async () => {
|
||
await buildInteropPeers();
|
||
const file = join(await mkdtemp(join(tmpdir(), "cultcache-rename-interop-")), "renamed.msgpack");
|
||
const written = await writeTsInteropStore(file, "ts-before-rename");
|
||
await new SingleFileMessagePackBackingStore(file).push({
|
||
key: "note:ts-after-rename",
|
||
type: "cultcache.interop-note.renamed",
|
||
schemaId: interopNoteDocument.schemaId,
|
||
catalogEntry: {
|
||
schemaId: interopNoteDocument.schemaId!,
|
||
schemaName: "cultcache.interop-note.renamed",
|
||
schemaVersion: interopNoteDocument.schemaVersion!,
|
||
contentHash: "renamed",
|
||
canonicalSchemaJson: interopNoteDocument.canonicalSchemaJson!,
|
||
compatibleSchemaIds: [interopNoteDocument.schemaId!],
|
||
members: interopNoteDocument.members,
|
||
},
|
||
storedAt: new Date().toISOString(),
|
||
payload: documentPayloadFromBytes(encode([
|
||
written.schemaVersion,
|
||
"note:ts-after-rename",
|
||
"ts-after-rename",
|
||
"ts wrote a note under the renamed schema",
|
||
"The v1 store format is the contract.",
|
||
["interop"],
|
||
])),
|
||
});
|
||
const catalog = decode(await readFile(file)) as unknown[][];
|
||
assert.deepEqual((catalog[1] as unknown[][]).map((entry) => [entry[0], entry[1]]), [["cultcache.interop-note", "cultcache.interop-note.renamed"]]);
|
||
|
||
const readers = [
|
||
{ name: "ts", read: async () => readTsInteropStore(file) },
|
||
{ name: "rust", read: async () => runJsonCommand("rust-read", rustInteropBinary, ["read", "--file", file], cultcacheRsRoot) },
|
||
{
|
||
name: "python",
|
||
read: async () =>
|
||
runJsonCommand("python-read", pythonCommand, ["-m", "cultcache_py.interop", "read", "--file", file], cultcachePyRoot, {
|
||
PYTHONPATH: cultcachePySrc,
|
||
}),
|
||
},
|
||
];
|
||
for (const reader of readers) {
|
||
const read = await reader.read();
|
||
assert.ok(
|
||
["note:ts-before-rename", "note:ts-after-rename"].includes(read.documentId),
|
||
`${reader.name} opened the renamed store and read a note (${read.documentId})`,
|
||
);
|
||
assert.equal(read.body, "The v1 store format is the contract.");
|
||
}
|
||
});
|
||
|
||
// A record TS wrote under an older schema id and another schema name. The C# note type declares that id compatible, so C# opens the
|
||
// record, stamps it with its own id when it writes it back, and every runtime opens the result.
|
||
test("A C# type that declares a compatible schema id opens a TypeScript record under it and rewrites it under its own id", async () => {
|
||
await buildInteropPeers();
|
||
const legacyId = "cultcache.interop-note.legacy-id";
|
||
const file = join(await mkdtemp(join(tmpdir(), "cultcache-compat-interop-")), "legacy.msgpack");
|
||
await new SingleFileMessagePackBackingStore(file).push({
|
||
key: "note:ts-legacy",
|
||
type: "cultcache.interop-note.legacy",
|
||
schemaId: legacyId,
|
||
catalogEntry: {
|
||
schemaId: legacyId,
|
||
schemaName: "cultcache.interop-note.legacy",
|
||
schemaVersion: interopNoteDocument.schemaVersion!,
|
||
contentHash: "legacy",
|
||
canonicalSchemaJson: interopNoteDocument.canonicalSchemaJson!,
|
||
compatibleSchemaIds: [legacyId],
|
||
members: interopNoteDocument.members,
|
||
},
|
||
storedAt: new Date().toISOString(),
|
||
payload: documentPayloadFromBytes(encode([
|
||
"cultcache.interop_note.v1",
|
||
"note:ts-legacy",
|
||
"ts-legacy",
|
||
"ts wrote a note under an older schema id",
|
||
"The v1 store format is the contract.",
|
||
["interop"],
|
||
])),
|
||
});
|
||
const before = decode(await readFile(file)) as unknown[][];
|
||
assert.equal((before[2] as unknown[][])[0]![1], legacyId);
|
||
|
||
const rewritten = await runJsonCommand("csharp-rewrite", dotnetCommand, [csharpInteropDll, "rewrite", "--file", file], cultLibRoot);
|
||
assert.equal(rewritten.documentId, "note:ts-legacy");
|
||
const after = decode(await readFile(file)) as unknown[][];
|
||
const stampedId = (after[2] as unknown[][])[0]![1] as string;
|
||
assert.notEqual(stampedId, legacyId, "the rewrite stamped the record with the registered id");
|
||
const entry = (after[1] as unknown[][]).find((candidate) => candidate[0] === stampedId) as unknown[];
|
||
assert.ok(entry, "the catalog publishes the id the record now carries");
|
||
assert.ok((entry[5] as string[]).includes(legacyId), "the registered entry still lists the older id");
|
||
|
||
const readers = [
|
||
{ name: "csharp", read: async () => runJsonCommand("csharp-read", dotnetCommand, [csharpInteropDll, "read", "--file", file], cultLibRoot) },
|
||
{ name: "ts", read: async () => readTsInteropStore(file) },
|
||
{ name: "rust", read: async () => runJsonCommand("rust-read", rustInteropBinary, ["read", "--file", file], cultcacheRsRoot) },
|
||
{
|
||
name: "python",
|
||
read: async () =>
|
||
runJsonCommand("python-read", pythonCommand, ["-m", "cultcache_py.interop", "read", "--file", file], cultcachePyRoot, {
|
||
PYTHONPATH: cultcachePySrc,
|
||
}),
|
||
},
|
||
];
|
||
for (const reader of readers) {
|
||
const read = await reader.read();
|
||
assert.equal(read.documentId, "note:ts-legacy", `${reader.name} opened the rewritten store`);
|
||
assert.equal(read.body, "The v1 store format is the contract.");
|
||
}
|
||
});
|
||
|
||
test("CultCache element ids cross C#, TypeScript, Python and Rust as ordinary members", async () => {
|
||
await buildInteropPeers();
|
||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-deck-"));
|
||
const file = join(tempDir, "deck.cc");
|
||
const written = await runJsonCommand("csharp-write-deck", dotnetCommand, [
|
||
csharpInteropDll, "write-deck", "--file", file,
|
||
], cultLibRoot);
|
||
assert.equal(written.ids.length, 3);
|
||
assert.equal(new Set(written.ids).size, 3, "two look-alike marks got distinct ids");
|
||
for (const id of written.ids) assert.match(id, /^[0-9a-f]{12}$/u);
|
||
|
||
// TypeScript: the id is slot 1 of each mark, read from the raw payload.
|
||
const store = decode(await readFile(file)) as any[];
|
||
const payload = decode(store[2][0][3]) as any[];
|
||
assert.deepEqual(payload[1].map((mark: any[]) => mark[1]), written.ids);
|
||
|
||
// Python and Rust read the same store.
|
||
const python = await runJsonCommand("python-deck", pythonCommand, [
|
||
"-c",
|
||
"import msgpack,json,sys;s=msgpack.unpackb(open(sys.argv[1],'rb').read(),raw=False);p=msgpack.unpackb(s[2][0][3],raw=False);print(json.dumps({'ids':[m[1] for m in p[1]]}))",
|
||
file,
|
||
], cultcachePyRoot);
|
||
assert.deepEqual(python.ids, written.ids);
|
||
const rust = await runJsonCommand("rust-read-deck", rustInteropBinary, ["read-deck", "--file", file], cultcacheRsRoot);
|
||
assert.deepEqual(rust.ids, written.ids);
|
||
|
||
// TypeScript writes the ids; C# keeps them.
|
||
const tsIds = ["a00000000001", "a00000000002", "a00000000003"];
|
||
store[2][0][3] = encode(["deck:csharp", [["twin", tsIds[0]], ["twin", tsIds[1]], ["other", tsIds[2]]]]);
|
||
const tsFile = join(tempDir, "ts-deck.cc");
|
||
await writeFile(tsFile, encode(store));
|
||
const back = await runJsonCommand("csharp-read-deck", dotnetCommand, [
|
||
csharpInteropDll, "read-deck", "--file", tsFile,
|
||
], cultLibRoot);
|
||
assert.deepEqual(back.ids, tsIds);
|
||
});
|
||
|
||
// A C# record edited in place is written as edited, however the consumer reached it: a decoded snapshot record's payload bytes, a
|
||
// variant override's value bytes, a collection element or a nested object of the document the cache holds. Rust and Python read the
|
||
// edit through their own readers; the variant and the nested object are read by decoding the file with Python's msgpack.
|
||
test("C# writes an edit made in place, and Rust and Python read the new value", async () => {
|
||
await buildInteropPeers();
|
||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-edit-in-place-"));
|
||
const csharp = (name: string, args: string[]) => runJsonCommand(name, dotnetCommand, [csharpInteropDll, ...args], cultLibRoot);
|
||
const edit = (file: string, kind: string) => csharp(`csharp-edit-${kind}`, ["edit-in-place", "--file", file, "--kind", kind]);
|
||
const readers = (file: string) => [
|
||
runJsonCommand("rust-read", rustInteropBinary, ["read", "--file", file], cultcacheRsRoot),
|
||
runJsonCommand("python-read", pythonCommand, ["-m", "cultcache_py.interop", "read", "--file", file], cultcachePyRoot, { PYTHONPATH: cultcachePySrc }),
|
||
];
|
||
const dump = (file: string) => runJsonCommand("python-dump", pythonCommand, [
|
||
"-c",
|
||
"import msgpack,json,sys\n"
|
||
+ "s=msgpack.unpackb(open(sys.argv[1],'rb').read(),raw=False)\n"
|
||
+ "print(json.dumps([{'key':r[0],'overrides':[o[3] for o in r[4][1]]} if len(r)>4 else {'key':r[0],'payload':msgpack.unpackb(r[3],raw=False)} for r in s[2]]))",
|
||
file,
|
||
], cultcachePyRoot);
|
||
|
||
const payload = join(tempDir, "payload.cc");
|
||
await csharp("csharp-write", ["write", "--file", payload, "--runtime-id", "csharp-writer"]);
|
||
await edit(payload, "payload");
|
||
for (const read of await Promise.all(readers(payload))) assert.equal(read.title, "csharp-writer WROTE a CultCache note");
|
||
|
||
const collection = join(tempDir, "collection.cc");
|
||
await csharp("csharp-write", ["write", "--file", collection, "--runtime-id", "csharp-writer"]);
|
||
await edit(collection, "collection");
|
||
for (const read of await Promise.all(readers(collection))) assert.deepEqual(read.tags, ["EDITED", "csharp", "interop"]);
|
||
|
||
const override = join(tempDir, "override.cc");
|
||
await csharp("csharp-write", ["write", "--file", override, "--runtime-id", "csharp-writer"]);
|
||
await edit(override, "override");
|
||
const variant = (await dump(override)).find((record: any) => record.key === "note:variant");
|
||
assert.deepEqual(variant.overrides, ["note:variant", "VARIANT title"]);
|
||
|
||
const object = join(tempDir, "object.cc");
|
||
await csharp("csharp-write-deck", ["write-deck", "--file", object]);
|
||
await edit(object, "object");
|
||
const deck = (await dump(object)).find((record: any) => record.key === "deck:csharp");
|
||
assert.equal(deck.payload[1][0][0], "EDITED");
|
||
assert.equal(deck.payload[1][1][0], "twin", "the neighbouring mark is untouched");
|
||
});
|
||
|
||
test("CultCache interop reader accepts missing compatible trailing slots and rejects mismatched slots", async () => {
|
||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-interop-"));
|
||
const compatible = join(tempDir, "compatible.msgpack");
|
||
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 refuses a record whose schema the catalog does not publish, and a store of the wrong slot count", async () => {
|
||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-inspector-"));
|
||
const record = [
|
||
"record-1",
|
||
"sha256:stale-schema-id-from-cold-record",
|
||
"2026-06-25T12:00:00Z",
|
||
encode(["tests.schema_stamped_entry.v1", "schema-stamped", "still readable"]),
|
||
];
|
||
|
||
assert.throws(
|
||
() => inspectCultCacheBytes("missing-catalog.cc", encode(["cultcache.store.v1", [], [record]])),
|
||
/references missing schema id/u,
|
||
);
|
||
assert.throws(() => inspectCultCacheBytes("header-only.cc", encode(["cultcache.store.v1"])), /top-level slots/u);
|
||
assert.throws(
|
||
() => inspectCultCacheBytes("extra-slot.cc", encode(["cultcache.store.v1", [], [], 0])),
|
||
/top-level slots/u,
|
||
);
|
||
await rm(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
// A record written under an id its schema only lists as compatible is stamped with the registered id, and the store the write
|
||
// leaves opens and takes the next write. The envelope carries a catalog entry under the foreign id, as a replicated one does.
|
||
const foreignIdDocument = defineDocumentType({
|
||
type: "tests.foreign-id",
|
||
schema: z.object({ name: z.string() }),
|
||
schemaId: "tests.foreign-id.current",
|
||
schemaName: "tests.foreign-id",
|
||
schemaVersion: "tests.foreign_id.v1",
|
||
compatibleSchemaIds: ["tests.foreign-id.current", "tests.foreign-id.older"],
|
||
});
|
||
|
||
async function reopenForeignIdStore(file: string): Promise<CultCache> {
|
||
const cache = CultCache.builder()
|
||
.withDocumentType(foreignIdDocument)
|
||
.withGenericStore(new SingleFileMessagePackBackingStore(file))
|
||
.build();
|
||
await cache.pullAllBackingStores();
|
||
return cache;
|
||
}
|
||
|
||
function schemaIdsOf(bytes: Uint8Array): { record: string; catalog: string[] } {
|
||
const decoded = decode(bytes) as [string, unknown[][], unknown[][]];
|
||
return { record: decoded[2][0]![1] as string, catalog: decoded[1].map((entry) => entry[0] as string) };
|
||
}
|
||
|
||
test("putEnvelope under a compatible schema id writes a store that reopens and takes the next write", async () => {
|
||
const file = join(await mkdtemp(join(tmpdir(), "cultcache-foreign-id-")), "store.msgpack");
|
||
const cache = await reopenForeignIdStore(file);
|
||
const seed = await cache.put(foreignIdDocument, "seed", { name: "seed" });
|
||
const payload = cache.getRequiredEnvelope(foreignIdDocument, "seed").payload;
|
||
|
||
await cache.putEnvelope(foreignIdDocument, {
|
||
key: "foreign",
|
||
type: "tests.foreign-id",
|
||
schemaId: "tests.foreign-id.older",
|
||
payload,
|
||
storedAt: "2026-09-30T00:00:00.0000000Z",
|
||
catalogEntry: {
|
||
schemaId: "tests.foreign-id.older",
|
||
schemaName: "tests.foreign-id",
|
||
schemaVersion: "tests.foreign_id.v1",
|
||
contentHash: "tests.foreign-id.older",
|
||
canonicalSchemaJson: "",
|
||
compatibleSchemaIds: ["tests.foreign-id.older"],
|
||
members: [],
|
||
},
|
||
});
|
||
|
||
const ids = schemaIdsOf(await readFile(file));
|
||
assert.deepEqual(ids, { record: "tests.foreign-id.current", catalog: ["tests.foreign-id.current"] });
|
||
const reopened = await reopenForeignIdStore(file);
|
||
assert.deepEqual(reopened.getRequired(foreignIdDocument, "foreign"), seed);
|
||
await reopened.put(foreignIdDocument, "next", { name: "next" });
|
||
assert.deepEqual((await reopenForeignIdStore(file)).getRequired(foreignIdDocument, "next"), { name: "next" });
|
||
});
|
||
|
||
test("the store publishes a record's schema id even when the envelope's catalog entry does not", async () => {
|
||
const file = join(await mkdtemp(join(tmpdir(), "cultcache-store-publishes-")), "store.msgpack");
|
||
const store = new SingleFileMessagePackBackingStore(file);
|
||
await store.push({
|
||
key: "k",
|
||
type: "tests.store-publishes",
|
||
schemaId: "tests.store-publishes.record",
|
||
payload: documentPayloadFromBytes(encode({ name: "k" })),
|
||
storedAt: "2026-09-30T00:00:00.0000000Z",
|
||
catalogEntry: {
|
||
schemaId: "tests.store-publishes.other",
|
||
schemaName: "tests.store-publishes",
|
||
schemaVersion: "tests.store_publishes.v1",
|
||
contentHash: "tests.store-publishes.other",
|
||
canonicalSchemaJson: "",
|
||
compatibleSchemaIds: ["tests.store-publishes.other"],
|
||
members: [],
|
||
},
|
||
});
|
||
assert.deepEqual(schemaIdsOf(await readFile(file)), { record: "tests.store-publishes.record", catalog: ["tests.store-publishes.record"] });
|
||
assert.equal((await new SingleFileMessagePackBackingStore(file).pullAll()).length, 1);
|
||
});
|
||
|
||
// The catalog a write leaves is derived from its records: one entry per carried id, chosen and never merged, the same whatever order
|
||
// the records arrive in. A registered (supplied) entry wins over an arrived (read back) entry with the same own id.
|
||
const writerEntry = (schemaId: string, schemaName: string, contentHash: string, compatibleSchemaIds: string[]) => ({
|
||
schemaId,
|
||
schemaName,
|
||
schemaVersion: `${schemaName}.v1`,
|
||
contentHash,
|
||
canonicalSchemaJson: "",
|
||
compatibleSchemaIds,
|
||
members: [],
|
||
});
|
||
const writerRecord = (key: string, type: string, schemaId: string, catalogEntry?: ReturnType<typeof writerEntry>) => ({
|
||
key,
|
||
type,
|
||
schemaId,
|
||
payload: documentPayloadFromBytes(encode({ key })),
|
||
storedAt: "2026-09-30T00:00:00.0000000Z",
|
||
...(catalogEntry ? { catalogEntry } : {}),
|
||
});
|
||
const rawCatalogEntry = (entry: ReturnType<typeof writerEntry>) => [
|
||
entry.schemaId,
|
||
entry.schemaName,
|
||
entry.schemaVersion,
|
||
entry.contentHash,
|
||
entry.canonicalSchemaJson,
|
||
entry.compatibleSchemaIds,
|
||
[],
|
||
];
|
||
const catalogOf = async (file: string): Promise<unknown[][]> =>
|
||
(decode(await readFile(file)) as [string, unknown[][], unknown[][]])[1];
|
||
|
||
test("a registered entry is written as it is, over the arrived entries with the same own id, in either order", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-registered-wins-"));
|
||
const stale1 = writerEntry("tests.x", "tests.n", "a-stale-1", ["tests.x", "old"]);
|
||
const stale2 = writerEntry("tests.x", "tests.n", "a-stale-2", ["tests.x"]);
|
||
const registered = writerRecord("a", "tests.n", "tests.x", writerEntry("tests.x", "tests.n", "fresh", ["tests.x"]));
|
||
|
||
for (const [name, entries] of [["12", [stale1, stale2]], ["21", [stale2, stale1]]] as const) {
|
||
const file = join(dir, `${name}.msgpack`);
|
||
await writeFile(
|
||
file,
|
||
encode(["cultcache.store.v1", entries.map(rawCatalogEntry), [["b", "tests.x", "2026-09-30T00:00:00.0000000Z", encode({ b: 1 })]]]),
|
||
);
|
||
await new SingleFileMessagePackBackingStore(file).push(registered);
|
||
const catalog = await catalogOf(file);
|
||
assert.equal(catalog.length, 1, name);
|
||
assert.equal(catalog[0]![3], "fresh", `${name}: the registered entry's hash is written`);
|
||
assert.deepEqual(catalog[0]![5], ["tests.x"], `${name}: written as chosen, no union with the arrived entries' ids`);
|
||
}
|
||
});
|
||
|
||
test("a rename with a stable schema id: a whole-view write names the registered schema", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-rename-"));
|
||
const file = join(dir, "rename.msgpack");
|
||
await writeFile(
|
||
file,
|
||
encode(["cultcache.store.v1", [rawCatalogEntry(writerEntry("tests.x", "tests.old", "stale", ["tests.x"]))], [["a", "tests.x", "2026-09-30T00:00:00.0000000Z", encode({ a: 1 })]]]),
|
||
);
|
||
await new SingleFileMessagePackBackingStore(file).pushAll([writerRecord("a", "tests.new", "tests.x", writerEntry("tests.x", "tests.new", "fresh", ["tests.x"]))]);
|
||
assert.deepEqual((await catalogOf(file)).map((entry) => [entry[1], entry[3]]), [["tests.new", "fresh"]]);
|
||
});
|
||
|
||
test("two entries of one tier that share an own id and disagree on the schema name refuse the write, typed", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-disagree-"));
|
||
const first = writerRecord("b", "tests.first", "tests.x", writerEntry("tests.x", "tests.first", "h1", ["tests.x"]));
|
||
const second = writerRecord("a", "tests.first", "tests.x", writerEntry("tests.x", "tests.second", "h2", ["tests.x"]));
|
||
for (const [name, order] of [["fs", [first, second]], ["sf", [second, first]]] as const) {
|
||
const file = join(dir, `${name}.msgpack`);
|
||
await assert.rejects(
|
||
() => new SingleFileMessagePackBackingStore(file).pushAll([...order]),
|
||
(error) =>
|
||
error instanceof SchemaConflictError &&
|
||
error.schemaId === "tests.x" &&
|
||
error.recordKey === "a" &&
|
||
[...error.schemaNames].sort().join() === "tests.first,tests.second",
|
||
name,
|
||
);
|
||
}
|
||
});
|
||
|
||
test("records of different types under one schema id refuse the write, typed, and no record changes type", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-retype-"));
|
||
const a = writerRecord("a", "tests.n", "tests.y", writerEntry("tests.x", "tests.n", "h1", ["tests.x", "tests.y"]));
|
||
const b = writerRecord("b", "tests.m", "tests.y", writerEntry("tests.y", "tests.m", "h2", ["tests.y"]));
|
||
|
||
for (const [name, order] of [["ab", [a, b]], ["ba", [b, a]]] as const) {
|
||
const file = join(dir, `${name}.msgpack`);
|
||
await assert.rejects(
|
||
() => new SingleFileMessagePackBackingStore(file).pushAll([...order]),
|
||
(error) => error instanceof SchemaConflictError && error.schemaId === "tests.y" && error.schemaNames.join() === "tests.m,tests.n",
|
||
name,
|
||
);
|
||
}
|
||
});
|
||
|
||
test("an entry that owns an id is chosen over one that lists it, and an entry no record needs is not written", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-own-first-"));
|
||
const owner = writerRecord("a", "tests.n", "tests.y", writerEntry("tests.y", "tests.n", "h1", ["tests.y"]));
|
||
const lister = writerRecord("b", "tests.n", "tests.x", writerEntry("tests.x", "tests.n", "h2", ["tests.x", "tests.y"]));
|
||
const onlyOwner = join(dir, "own.msgpack");
|
||
await new SingleFileMessagePackBackingStore(onlyOwner).pushAll([owner]);
|
||
assert.deepEqual((await catalogOf(onlyOwner)).map((entry) => entry[0]), ["tests.y"]);
|
||
|
||
// The lister carried only its own id: its list of y is not needed, and the owner's entry publishes y.
|
||
for (const [name, order] of [["lo", [lister, owner]], ["ol", [owner, lister]]] as const) {
|
||
const file = join(dir, `${name}.msgpack`);
|
||
await new SingleFileMessagePackBackingStore(file).pushAll([...order]);
|
||
const catalog = await catalogOf(file);
|
||
assert.deepEqual(catalog.map((entry) => [entry[0], entry[5]]).sort(), [["tests.x", ["tests.x", "tests.y"]], ["tests.y", ["tests.y"]]], name);
|
||
}
|
||
});
|
||
|
||
test("entries of one tier that tie are taken in one fixed order, and a registered entry that lists an id is chosen over an arrived one", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-tie-"));
|
||
// The content hash orders them against the order their compatible ids would give: h1 sorts first, its list sorts last.
|
||
const one = writerRecord("a", "tests.n", "tests.x", writerEntry("tests.x", "tests.n", "h2", ["tests.x"]));
|
||
const two = writerRecord("b", "tests.n", "tests.x", writerEntry("tests.x", "tests.n", "h1", ["tests.x", "tests.y"]));
|
||
for (const [name, order] of [["12", [one, two]], ["21", [two, one]]] as const) {
|
||
const file = join(dir, `${name}.msgpack`);
|
||
await new SingleFileMessagePackBackingStore(file).pushAll([...order]);
|
||
assert.deepEqual((await catalogOf(file)).map((entry) => entry[3]), ["h1"], name);
|
||
}
|
||
|
||
// z sits under old, which the arrived entry (id x) lists; the supplied entry (id r) lists it too: the registered one publishes it.
|
||
const file = join(dir, "lister.msgpack");
|
||
await writeFile(
|
||
file,
|
||
encode([
|
||
"cultcache.store.v1",
|
||
[rawCatalogEntry(writerEntry("tests.x", "tests.n", "stale", ["tests.x", "old"]))],
|
||
[["z", "old", "2026-09-30T00:00:00.0000000Z", encode({ z: 1 })]],
|
||
]),
|
||
);
|
||
await new SingleFileMessagePackBackingStore(file).push(writerRecord("a", "tests.n", "tests.r", writerEntry("tests.r", "tests.n", "fresh", ["tests.r", "old"])));
|
||
assert.deepEqual((await catalogOf(file)).map((entry) => entry[0]), ["tests.r"]);
|
||
});
|
||
|
||
// A registered entry that owns an id keeps it against an arrived entry that owns it too and lists the id another record sits under,
|
||
// whichever of the two ids sorts first: the arrived entry's list is not merged in, so the record under the listed id is published by
|
||
// no chosen entry.
|
||
for (const [ownId, listedId] of [["tests.a", "tests.b"], ["tests.b", "tests.a"]] as const) {
|
||
test(`an arrived entry that owns a registered id does not replace it nor publish what it lists (${ownId})`, async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-guard-"));
|
||
const file = join(dir, "guard.msgpack");
|
||
await writeFile(
|
||
file,
|
||
encode([
|
||
"cultcache.store.v1",
|
||
[rawCatalogEntry(writerEntry(ownId, "tests.arrived", "stale", [ownId, listedId]))],
|
||
[["k2", listedId, "2026-09-30T00:00:00.0000000Z", encode({ k: 2 })]],
|
||
]),
|
||
);
|
||
const before = await readFile(file);
|
||
await assert.rejects(
|
||
() => new SingleFileMessagePackBackingStore(file).push(writerRecord("k1", "tests.registered", ownId, writerEntry(ownId, "tests.registered", "fresh", [ownId]))),
|
||
(error) => error instanceof SchemaConflictError && error.schemaId === listedId && error.recordKey === "k2",
|
||
);
|
||
assert.ok(before.equals(await readFile(file)), "the file is left as it was");
|
||
});
|
||
}
|
||
|
||
// Ids are taken in sorted order, so which entry survives, and so which id the refusal names, does not depend on the order the records
|
||
// arrive in.
|
||
test("the refusal names the same id whatever order the records arrive in", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-sorted-"));
|
||
const one = writerRecord("r1", "tests.n", "tests.y", writerEntry("tests.x", "tests.n", "h1", ["tests.x", "tests.y"]));
|
||
const two = writerRecord("r2", "tests.n", "tests.z", writerEntry("tests.x", "tests.n", "h2", ["tests.x", "tests.z"]));
|
||
for (const [name, order] of [["yz", [one, two]], ["zy", [two, one]]] as const) {
|
||
await assert.rejects(
|
||
() => new SingleFileMessagePackBackingStore(join(dir, `${name}.msgpack`)).pushAll([...order]),
|
||
(error) => error instanceof SchemaConflictError && error.schemaId === "tests.y" && error.recordKey === "r1",
|
||
name,
|
||
);
|
||
}
|
||
});
|
||
|
||
// A write may not retype records already in the file: across tiers the id keeps the type its records resolve to, unless the entries of
|
||
// both tiers own it, which is a rename under a stable id and the registered descriptor wins.
|
||
test("a write that would change the type an existing record resolves to refuses, typed, and leaves the file", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-cross-tier-"));
|
||
const at = "2026-09-30T00:00:00.0000000Z";
|
||
const cases = [
|
||
{
|
||
name: "registered entry lists the id, arrived entry owns it",
|
||
catalog: writerEntry("tests.x", "tests.arrived", "h1", ["tests.x"]),
|
||
registered: writerRecord("a", "tests.registered", "tests.x", writerEntry("tests.r", "tests.registered", "h2", ["tests.r", "tests.x"])),
|
||
},
|
||
{
|
||
name: "registered entry owns the id, arrived entry lists it",
|
||
catalog: writerEntry("tests.r", "tests.arrived", "h1", ["tests.r", "tests.x"]),
|
||
registered: writerRecord("a", "tests.registered", "tests.x", writerEntry("tests.x", "tests.registered", "h2", ["tests.x"])),
|
||
},
|
||
];
|
||
for (const { name, catalog, registered } of cases) {
|
||
const file = join(dir, `${name}.msgpack`);
|
||
await writeFile(file, encode(["cultcache.store.v1", [rawCatalogEntry(catalog)], [["z", "tests.x", at, encode({ z: 1 })]]]));
|
||
const before = await readFile(file);
|
||
await assert.rejects(
|
||
() => new SingleFileMessagePackBackingStore(file).push(registered),
|
||
(error) =>
|
||
error instanceof SchemaConflictError &&
|
||
error.schemaId === "tests.x" &&
|
||
[...error.schemaNames].sort().join() === "tests.arrived,tests.registered",
|
||
name,
|
||
);
|
||
assert.ok(before.equals(await readFile(file)), `${name}: the file is left as it was`);
|
||
}
|
||
|
||
// The same registered record renames the schema when both tiers own the id: it writes, and the reader finds every record.
|
||
const file = join(dir, "rename.msgpack");
|
||
await writeFile(file, encode(["cultcache.store.v1", [rawCatalogEntry(writerEntry("tests.x", "tests.arrived", "h1", ["tests.x"]))], [["z", "tests.x", at, encode({ z: 1 })]]]));
|
||
await new SingleFileMessagePackBackingStore(file).push(writerRecord("a", "tests.registered", "tests.x", writerEntry("tests.x", "tests.registered", "h2", ["tests.x"])));
|
||
assert.deepEqual((await catalogOf(file)).map((entry) => [entry[0], entry[1]]), [["tests.x", "tests.registered"]]);
|
||
assert.equal((await new SingleFileMessagePackBackingStore(file).pullAll()).length, 2);
|
||
});
|
||
|
||
test("a record no chosen entry publishes refuses the write, typed, and leaves the file", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-orphan-"));
|
||
const file = join(dir, "orphan.msgpack");
|
||
// z sits under old, which only the arrived entry (id x) lists; the supplied entry owns x and does not.
|
||
await writeFile(
|
||
file,
|
||
encode([
|
||
"cultcache.store.v1",
|
||
[rawCatalogEntry(writerEntry("tests.x", "tests.legacy", "stale", ["tests.x", "old"]))],
|
||
[["z", "old", "2026-09-30T00:00:00.0000000Z", encode({ z: 1 })]],
|
||
]),
|
||
);
|
||
const before = await readFile(file);
|
||
await assert.rejects(
|
||
() => new SingleFileMessagePackBackingStore(file).push(writerRecord("a", "tests.legacy", "tests.x", writerEntry("tests.x", "tests.legacy", "fresh", ["tests.x"]))),
|
||
(error) => error instanceof SchemaConflictError && error.schemaId === "old" && error.recordKey === "z",
|
||
);
|
||
assert.ok(before.equals(await readFile(file)), "the file is left as it was");
|
||
});
|
||
|
||
test("an id an entry owns names that entry, not one that lists it as compatible", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-own-id-"));
|
||
for (const vector of ["own-id-over-compatible-v3.bin", "compatible-before-owner-v3.bin"]) {
|
||
const file = await copyVector(dir, join(c2aVectors, "readability", vector), "own.msgpack");
|
||
const envelopes = await new SingleFileMessagePackBackingStore(file).pullAll();
|
||
assert.deepEqual(envelopes.map((envelope) => envelope.type), ["vectors.item", "vectors.item"], vector);
|
||
const inspection = inspectCultCacheBytes("own.msgpack", await readFile(file));
|
||
assert.deepEqual(inspection.records.map((record) => record.schemaName), ["vectors.item", "vectors.item"], vector);
|
||
}
|
||
});
|
||
|
||
// The inspector reads what the reader reads: a record under an id its catalog lists only as compatible is inspected.
|
||
test("CultCache inspector reads a record whose schema the catalog publishes only as a compatible id", async () => {
|
||
const bytes = await readFile(join(c2aVectors, "readability", "compatible-id-only-v3.bin"));
|
||
const inspection = inspectCultCacheBytes("compatible.msgpack", bytes);
|
||
assert.deepEqual(inspection.records.map((record) => record.schemaName), ["vectors.item", "vectors.item"]);
|
||
});
|
||
|
||
test("a record loaded under a compatible schema id is written back under the id its catalog entry carries", async () => {
|
||
const file = join(await mkdtemp(join(tmpdir(), "cultcache-foreign-id-")), "store.msgpack");
|
||
const payload = encode({ name: "old" });
|
||
await writeFile(
|
||
file,
|
||
encode([
|
||
"cultcache.store.v1",
|
||
[[
|
||
"tests.foreign-id.current",
|
||
"tests.foreign-id",
|
||
"tests.foreign_id.v1",
|
||
"tests.foreign-id.current",
|
||
"",
|
||
["tests.foreign-id.current", "tests.foreign-id.older"],
|
||
[],
|
||
]],
|
||
[["old", "tests.foreign-id.older", "2026-09-30T00:00:00.0000000Z", payload]],
|
||
]),
|
||
);
|
||
|
||
const cache = await reopenForeignIdStore(file);
|
||
assert.equal(cache.getRequiredEnvelope(foreignIdDocument, "old").schemaId, "tests.foreign-id.current");
|
||
await cache.put(foreignIdDocument, "next", { name: "next" });
|
||
const reopened = await reopenForeignIdStore(file);
|
||
assert.deepEqual(reopened.getRequired(foreignIdDocument, "old"), { name: "old" });
|
||
assert.deepEqual(reopened.getRequired(foreignIdDocument, "next"), { name: "next" });
|
||
});
|
||
|
||
interface InteropNote {
|
||
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: documentPayloadFromBytes(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);
|
||
}
|
||
|
||
// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
|
||
const variantVectors = resolve(cultLibRoot, "tests", "vectors", "document-variants-c0");
|
||
const itemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788";
|
||
|
||
async function pullVector(name: string): Promise<CultCacheEnvelope[]> {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-vector-"));
|
||
const file = join(dir, "store.msgpack");
|
||
await writeFile(file, await readFile(join(variantVectors, name)));
|
||
return new SingleFileMessagePackBackingStore(file).pullAll();
|
||
}
|
||
|
||
test("SingleFileMessagePackBackingStore refuses an unknown store header by name", async () => {
|
||
await assert.rejects(() => pullVector("unknown-header.msgpack"), /cultcache\.store\.v9/u);
|
||
});
|
||
|
||
test("SingleFileMessagePackBackingStore refuses an extra record slot naming the record", async () => {
|
||
await assert.rejects(
|
||
() => pullVector("extra-slot-full-payload.msgpack"),
|
||
(error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId),
|
||
);
|
||
});
|
||
|
||
test("SingleFileMessagePackBackingStore refuses a variant store by version or record", async () => {
|
||
await assert.rejects(
|
||
() => pullVector("variant-v2.msgpack"),
|
||
(error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("item:anvil-big"),
|
||
);
|
||
});
|
||
|
||
test("SingleFileMessagePackBackingStore refuses the C#-written variant store by version or record", async () => {
|
||
await assert.rejects(
|
||
() => pullVector("../document-variants-c1/variant-store.msgpack"),
|
||
(error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("laser-big"),
|
||
);
|
||
});
|
||
|
||
test("SingleFileMessagePackBackingStore refusals say what was found without blaming variants", async () => {
|
||
await assert.rejects(
|
||
() => pullVector("unknown-header.msgpack"),
|
||
(error: Error) => error.message.includes("not one this runtime reads") && !error.message.includes("variant"),
|
||
);
|
||
await assert.rejects(
|
||
() => pullVector("extra-slot-full-payload.msgpack"),
|
||
(error: Error) => error.message.includes("not a valid store") && !error.message.includes("variant"),
|
||
);
|
||
});
|
||
|
||
test("SingleFileMessagePackBackingStore refusal says what was found and names every format it reads", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-refusal-"));
|
||
const file = join(dir, "store.cc");
|
||
const reads = 'it reads "cultcache.store.v1" and "cultcache.store.v3" only.';
|
||
for (const [header, found] of [
|
||
["cultcache.store.v12", '"cultcache.store.v12"'],
|
||
["cultcache.store.SECRET", "an unrecognised cultcache.store.* header of 22 bytes"],
|
||
["cultcache.store.v", "an unrecognised cultcache.store.* header of 17 bytes"],
|
||
// Digits are ASCII only: two Arabic-Indic digits are four bytes and are not the known shape.
|
||
["cultcache.store.v١٢", "an unrecognised cultcache.store.* header of 21 bytes"],
|
||
] as const) {
|
||
await writeFile(file, encode([header, [], []]));
|
||
await assert.rejects(
|
||
() => new SingleFileMessagePackBackingStore(file).pullAll(),
|
||
(error: Error) => error.message.includes(`CultCache store format ${found} is not one this runtime reads; ${reads}`),
|
||
header,
|
||
);
|
||
}
|
||
});
|
||
|
||
function headerRefused(bytes: Uint8Array): boolean {
|
||
try {
|
||
refuseUnreadStoreBytes(bytes);
|
||
return false;
|
||
} catch (error) {
|
||
assert.match((error as Error).message, /not one this runtime reads/u);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
function storeStart(arrayLead: number[], text: string, strForm: "fix" | "8" | "16" | "32"): Uint8Array {
|
||
const body = Buffer.from(text, "utf8");
|
||
const n = body.length;
|
||
const tag =
|
||
strForm === "fix" ? [0xa0 | n]
|
||
: strForm === "8" ? [0xd9, n]
|
||
: strForm === "16" ? [0xda, n >> 8, n & 0xff]
|
||
: [0xdb, 0, 0, n >> 8, n & 0xff];
|
||
return new Uint8Array([...arrayLead, ...tag, ...body]);
|
||
}
|
||
|
||
test("a store's header is judged from its first bytes, in every MessagePack array and string form", () => {
|
||
const leads = [[0x91], [0x9f], [0xdc, 0x00, 0x01], [0xdc, 0x01, 0x00], [0xdd, 0x00, 0x00, 0x00, 0x01], [0xdd, 0x00, 0x01, 0x00, 0x00]];
|
||
for (const lead of leads) {
|
||
for (const form of ["fix", "8", "16", "32"] as const) {
|
||
const v4 = storeStart(lead, "cultcache.store.v4", form);
|
||
assert.equal(headerRefused(v4), true, `${lead} ${form} v4`);
|
||
assert.equal(headerRefused(v4.subarray(0, v4.length - 1)), false, `${lead} ${form} v4 one byte short`);
|
||
assert.equal(headerRefused(storeStart(lead, "cultcache.store.v1", form)), false, `${lead} ${form} v1`);
|
||
assert.equal(headerRefused(storeStart(lead, "cultcache.store.v3", form)), false, `${lead} ${form} v3`);
|
||
assert.equal(headerRefused(storeStart(lead, "other.store.v4", form)), false, `${lead} ${form} other`);
|
||
assert.equal(headerRefused(storeStart(lead, "", form)), false, `${lead} ${form} empty`);
|
||
}
|
||
}
|
||
// The longest fixstr, and a string long enough to need str16.
|
||
assert.equal(headerRefused(storeStart([0x91], `cultcache.store.v4${"x".repeat(13)}`, "fix")), true);
|
||
assert.equal(headerRefused(storeStart([0x91], `cultcache.store.v4${"x".repeat(300)}`, "16")), true);
|
||
assert.equal(headerRefused(storeStart([0x91], `cultcache.store.v4${"x".repeat(300)}`, "32")), true);
|
||
// A view into a larger buffer is read from its own start, in every form that reads a length through the DataView.
|
||
for (const lead of leads) {
|
||
for (const form of ["fix", "8", "16", "32"] as const) {
|
||
const padded = new Uint8Array([7, 7, 7, ...storeStart(lead, "cultcache.store.v4", form)]);
|
||
assert.equal(padded.subarray(3).byteOffset, 3);
|
||
assert.equal(headerRefused(padded.subarray(3)), true, `${lead} ${form} v4 at a byte offset`);
|
||
}
|
||
}
|
||
// The stamp is judged as written: a stamp with a space is not the stamp this runtime reads, and not one it ignores.
|
||
assert.equal(headerRefused(storeStart([0x91], "cultcache.store.v3 ", "fix")), true, "a v3 stamp with a trailing space is a header this runtime does not read");
|
||
assert.equal(headerRefused(storeStart([0x91], " cultcache.store.v4", "fix")), false, "a leading space makes it no cultcache.store.* header");
|
||
});
|
||
|
||
test("bytes with no readable store stamp are not refused", () => {
|
||
const stamp = [...Buffer.from("cultcache.store.v4", "utf8")];
|
||
const cases: Record<string, number[]> = {
|
||
empty: [],
|
||
"empty array": [0x90, 0xb2, ...stamp],
|
||
"empty array16": [0xdc, 0x00, 0x00, 0xb2, ...stamp],
|
||
"empty array32": [0xdd, 0x00, 0x00, 0x00, 0x00, 0xb2, ...stamp],
|
||
"array16 cut before its count": [0xdc, 0x00],
|
||
"array32 cut before its count": [0xdd, 0x00, 0x00, 0x00],
|
||
"array with no element": [0x91],
|
||
"first element is not a string": [0x92, 0x01, 0xb2, ...stamp],
|
||
"a bare string": [0xb2, ...stamp],
|
||
"a map": [0x81, 0xb2, ...stamp],
|
||
"str8 cut before its length": [0x91, 0xd9],
|
||
"str16 cut before its length": [0x91, 0xda, 0x00],
|
||
"str32 cut before its length": [0x91, 0xdb, 0x00, 0x00, 0x00],
|
||
};
|
||
for (const [name, bytes] of Object.entries(cases)) {
|
||
assert.equal(headerRefused(new Uint8Array(bytes)), false, name);
|
||
}
|
||
});
|
||
|
||
test("a newer store is refused by name whatever its body: truncated, undecodable or trailing, by a read, a whole write and the inspector", async () => {
|
||
const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" };
|
||
const whole = encode(["cultcache.store.v4", [], [["k", "s", "t", new Uint8Array([0x90])]]]);
|
||
const bodies: Record<string, Uint8Array> = {
|
||
truncated: whole.subarray(0, whole.length - 3),
|
||
"header only": whole.subarray(0, 20),
|
||
undecodable: Uint8Array.of(...whole.subarray(0, 20), 0xc1),
|
||
trailing: Uint8Array.of(...whole, 0xff),
|
||
whole,
|
||
};
|
||
for (const [name, bytes] of Object.entries(bodies)) {
|
||
const file = join(await mkdtemp(join(tmpdir(), "cultcache-newer-")), "store.msgpack");
|
||
await writeFile(file, bytes);
|
||
const store = new SingleFileMessagePackBackingStore(file);
|
||
await assert.rejects(() => store.pullAll(), /not one this runtime reads/u, `${name} read`);
|
||
await assert.rejects(() => store.pushAll([envelope]), /not one this runtime reads/u, `${name} pushAll`);
|
||
await assert.rejects(() => store.pushAll([envelope], { soft: true }), /not one this runtime reads/u, `${name} soft pushAll`);
|
||
assert.throws(() => inspectCultCacheBytes(file, bytes), /not one this runtime reads/u, `${name} inspect`);
|
||
assert.deepEqual([...(await readFile(file))], [...bytes], `${name} left as it was`);
|
||
}
|
||
});
|
||
|
||
test("a stamp this runtime reads, followed by bytes that are not UTF-8, gets the same refusal from a read and from a whole write", async () => {
|
||
const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" };
|
||
for (const header of ["cultcache.store.v1", "cultcache.store.v3"]) {
|
||
const stamp = [...Buffer.from(header, "utf8")];
|
||
const forms = { fixstr: [0x91, 0xa0 | (stamp.length + 1), ...stamp, 0xff], str8: [0x91, 0xd9, stamp.length + 1, ...stamp, 0xff] };
|
||
for (const [name, bytes] of Object.entries(forms)) {
|
||
const file = join(await mkdtemp(join(tmpdir(), "cultcache-stamp-")), "store.msgpack");
|
||
await writeFile(file, new Uint8Array(bytes));
|
||
const store = new SingleFileMessagePackBackingStore(file);
|
||
await assert.rejects(() => store.pullAll(), /not one this runtime reads/u, `${header} ${name} read`);
|
||
await assert.rejects(() => store.pushAll([envelope]), /not one this runtime reads/u, `${header} ${name} pushAll`);
|
||
assert.deepEqual([...(await readFile(file))], bytes, `${header} ${name} left as it was`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test("pushAll leaves a store it reads alone when soft, and replaces one it reads otherwise", async () => {
|
||
const vector = await readFile(resolve(cultLibRoot, "tests", "vectors", "document-variants-c2a", "v3-base.msgpack"));
|
||
const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" };
|
||
const existing = join(await mkdtemp(join(tmpdir(), "cultcache-pushall-soft-")), "store.msgpack");
|
||
await writeFile(existing, vector);
|
||
await new SingleFileMessagePackBackingStore(existing).pushAll([envelope], { soft: true });
|
||
assert.equal(Buffer.compare(await readFile(existing), vector), 0);
|
||
|
||
const own = join(await mkdtemp(join(tmpdir(), "cultcache-pushall-own-")), "store.msgpack");
|
||
const store = new SingleFileMessagePackBackingStore(own);
|
||
await store.pushAll([envelope]);
|
||
await store.pushAll([{ ...envelope, key: "second" }]);
|
||
assert.deepEqual((await store.pullAll()).map((entry) => entry.key), ["second"]);
|
||
});
|
||
|
||
test("SingleFileMessagePackBackingStore reads only a missing store as empty", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-absent-"));
|
||
assert.deepEqual(await new SingleFileMessagePackBackingStore(join(dir, "store.cc")).pullAll(), []);
|
||
|
||
// An existing empty file is not a store: no CultCache writer leaves one.
|
||
const empty = join(dir, "empty.cc");
|
||
await writeFile(empty, new Uint8Array());
|
||
await assert.rejects(() => new SingleFileMessagePackBackingStore(empty).pullAll());
|
||
assert.equal((await readFile(empty)).length, 0);
|
||
|
||
// An empty array is the legacy envelope array with no envelopes: no header, no format claimed.
|
||
const legacyEmpty = join(dir, "legacy-empty.cc");
|
||
await writeFile(legacyEmpty, Uint8Array.of(0x90));
|
||
assert.deepEqual(await new SingleFileMessagePackBackingStore(legacyEmpty).pullAll(), []);
|
||
});
|
||
|
||
test("reading a store that is not there writes nothing, and a read of a store that is readable does not rewrite it", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-read-writes-nothing-"));
|
||
const missing = join(dir, "store.cc");
|
||
assert.deepEqual(await new SingleFileMessagePackBackingStore(missing).pullAll(), []);
|
||
assert.equal(existsSync(missing), false);
|
||
assert.deepEqual(await readdir(dir), []);
|
||
|
||
const written = join(dir, "written.cc");
|
||
const envelope = { key: "k", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" };
|
||
await new SingleFileMessagePackBackingStore(written).push(envelope);
|
||
const before = await readFile(written);
|
||
await new SingleFileMessagePackBackingStore(written).pullAll();
|
||
assert.deepEqual(await readFile(written), before);
|
||
});
|
||
|
||
test("a soft pushAll writes where nothing is, leaves a readable store alone, and refuses what the reader refuses", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-soft-"));
|
||
const envelope = { key: "k", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" };
|
||
const other = { key: "other", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x91, 0x01)), storedAt: "2026-09-30T00:00:00Z" };
|
||
|
||
// Absent: the soft write seeds the store.
|
||
const absent = join(dir, "absent.cc");
|
||
await new SingleFileMessagePackBackingStore(absent).pushAll([envelope], { soft: true });
|
||
assert.deepEqual((await new SingleFileMessagePackBackingStore(absent).pullAll()).map((entry) => entry.key), ["k"]);
|
||
|
||
// Readable: the soft write is skipped, byte for byte.
|
||
const seeded = await readFile(absent);
|
||
await new SingleFileMessagePackBackingStore(absent).pushAll([other], { soft: true });
|
||
assert.deepEqual(await readFile(absent), seeded);
|
||
|
||
// Unreadable: refused, as pullAll refuses it, and the file is left exactly as it was.
|
||
const refused: Array<[string, Uint8Array]> = [
|
||
["zero-byte", new Uint8Array()],
|
||
["unknown-format", encode(["cultcache.store.v9", [], []])],
|
||
["bin-header", Uint8Array.of(0x93, 0xc4, 0x01, 0x76, 0x90, 0x90)],
|
||
["truncated", seeded.subarray(0, seeded.length - 1)],
|
||
["garbage", Uint8Array.of(0xc1)],
|
||
];
|
||
for (const [name, bytes] of refused) {
|
||
const file = join(dir, `${name}.cc`);
|
||
await writeFile(file, bytes);
|
||
await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pullAll(), StoreUnreadableError, name);
|
||
await assert.rejects(() => new SingleFileMessagePackBackingStore(file).pushAll([other], { soft: true }), StoreUnreadableError, name);
|
||
assert.deepEqual(await readFile(file), Buffer.from(bytes), name);
|
||
}
|
||
});
|
||
|
||
test("a header that does not start at the store prefix is not echoed whole, though it ends like a version", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-forged-header-"));
|
||
const file = join(dir, "store.cc");
|
||
for (const header of ["cultcache.store.SECRETcultcache.store.v9", "cultcache.store.\ncultcache.store.v9", "SECRETcultcache.store.v9"]) {
|
||
await writeFile(file, encode([header, [], []]));
|
||
await assert.rejects(
|
||
() => new SingleFileMessagePackBackingStore(file).pullAll(),
|
||
(error: Error) => error instanceof StoreUnreadableError && !error.message.includes("SECRET") && !error.message.includes(header),
|
||
header,
|
||
);
|
||
}
|
||
});
|
||
|
||
test("SingleFileMessagePackBackingStore reports a store it cannot reach as an I/O error", { skip: process.platform === "win32" }, async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-unreachable-"));
|
||
const loop = join(dir, "loop.cc");
|
||
await symlink(loop, loop);
|
||
const parent = join(dir, "body");
|
||
await writeFile(parent, "not a directory");
|
||
for (const [path, code] of [[loop, "ELOOP"], [join(parent, "store.cc"), "ENOTDIR"]] as const) {
|
||
await assert.rejects(
|
||
() => new SingleFileMessagePackBackingStore(path).pullAll(),
|
||
(error: NodeJS.ErrnoException) => error.code === code,
|
||
);
|
||
}
|
||
});
|
||
|
||
test("SingleFileMessagePackBackingStore refusals never echo a value from the store", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-canary-"));
|
||
const file = join(dir, "store.cc");
|
||
const cases: Array<[unknown[], boolean | undefined]> = [
|
||
[["cultcache.store.v12", [], []], true],
|
||
[["cultcache.store.SECRET-HEADER", [], []], false],
|
||
[["cultcache.store.v12SECRET", [], []], false],
|
||
[["cultcache.store.v", [], []], false],
|
||
[["cultcache.store.v1", ["SECRET-CATALOG-TEXT"], []], undefined],
|
||
];
|
||
for (const [value, echoed] of cases) {
|
||
await writeFile(file, encode(value));
|
||
const header = value[0] as string;
|
||
await assert.rejects(
|
||
() => new SingleFileMessagePackBackingStore(file).pullAll(),
|
||
(error: Error) => {
|
||
if (echoed) {
|
||
return error.message.includes(`"${header}"`);
|
||
}
|
||
return !error.message.includes("SECRET")
|
||
&& (echoed === undefined || (!error.message.includes(`"${header}"`) && error.message.includes(`of ${header.length} bytes`)));
|
||
},
|
||
);
|
||
}
|
||
});
|
||
|
||
test("SingleFileMessagePackBackingStore refuses a dangling symlink at its path and writes nothing", { skip: process.platform === "win32" }, async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-dangling-"));
|
||
const volume = join(dir, "volume");
|
||
await mkdir(volume);
|
||
const file = join(dir, "store.cc");
|
||
await symlink(join(volume, "store.cc"), file);
|
||
const store = new SingleFileMessagePackBackingStore(file);
|
||
const envelope = { key: "k", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" };
|
||
await assert.rejects(
|
||
() => store.pullAll(),
|
||
(error: NodeJS.ErrnoException) => error.code === "ENOENT" && error.message.includes("symbolic link") && error.message.includes(file),
|
||
);
|
||
await assert.rejects(() => store.push(envelope), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
|
||
await assert.rejects(() => store.pushAll([envelope], { soft: true }), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
|
||
assert.ok((await lstat(file)).isSymbolicLink());
|
||
assert.deepEqual(await readdir(volume), []);
|
||
});
|
||
|
||
// The second look of the read, staged: nothing is at the path only when the read and the lstat both find nothing. A path
|
||
// that changed between them (a link in a parent swapped for a file, a store that appeared) is never an empty store.
|
||
test("readStoreBytes reports nothing at the path only when the lstat finds nothing too", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-second-look-"));
|
||
const file = join(dir, "store.cc");
|
||
const failing = (code: string) => () => {
|
||
throw Object.assign(new Error(code), { code });
|
||
};
|
||
assert.equal(readStoreBytes(file, failing("ENOENT")), undefined);
|
||
for (const code of ["ENOTDIR", "EACCES", "ELOOP"]) {
|
||
assert.throws(() => readStoreBytes(file, failing(code)), (error: NodeJS.ErrnoException) => error.code === code, code);
|
||
}
|
||
|
||
assert.throws(
|
||
() => readStoreBytes(file, () => ({ isSymbolicLink: () => true })),
|
||
(error: NodeJS.ErrnoException) => error.code === "ENOENT" && error.message.includes("symbolic link"),
|
||
);
|
||
assert.throws(
|
||
() => readStoreBytes(file, () => ({ isSymbolicLink: () => false })),
|
||
(error: NodeJS.ErrnoException) => error.code === "ENOENT" && error.message.includes("appeared while it was being read"),
|
||
);
|
||
});
|
||
|
||
test("SingleFileMessagePackBackingStore record refusal names only a string key", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-record-key-"));
|
||
const file = join(dir, "store.cc");
|
||
const secret = new TextEncoder().encode("SECRET-BYTES");
|
||
for (const [key, schema] of [[["SECRET-IN-ARRAY"], "s"], [secret, "s"], ["k", { SECRET: 1 }]] as const) {
|
||
await writeFile(file, encode(["cultcache.store.v1", [], [[key, schema, "t", Uint8Array.of(0x90), "extra"]]]));
|
||
await assert.rejects(
|
||
() => new SingleFileMessagePackBackingStore(file).pullAll(),
|
||
(error: Error) => !error.message.includes("SECRET") && !error.message.includes("83,69,67") && error.message.includes("not a valid store"),
|
||
);
|
||
}
|
||
});
|
||
|
||
// What a write does through a symbolic link today. R3 decides whether a write resolves the link
|
||
// or refuses it; these pin the current behaviour so that change is deliberate.
|
||
test("R3 decides resolve-or-refuse: a push through a live link replaces the link", { skip: process.platform === "win32" }, async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-live-link-"));
|
||
const volume = join(dir, "volume");
|
||
await mkdir(volume);
|
||
const target = join(volume, "store.cc");
|
||
const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" };
|
||
await new SingleFileMessagePackBackingStore(target).push(envelope);
|
||
const before = await readFile(target);
|
||
const link = join(dir, "store.cc");
|
||
await symlink(target, link);
|
||
await new SingleFileMessagePackBackingStore(link).push({ ...envelope, key: "b" });
|
||
assert.ok((await lstat(link)).isFile());
|
||
assert.deepEqual(await readFile(target), before);
|
||
});
|
||
|
||
test("R3 decides resolve-or-refuse: a pushAll through a dangling link replaces the link", { skip: process.platform === "win32" }, async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-dangling-push-all-"));
|
||
const volume = join(dir, "volume");
|
||
await mkdir(volume);
|
||
const link = join(dir, "store.cc");
|
||
await symlink(join(volume, "store.cc"), link);
|
||
await new SingleFileMessagePackBackingStore(link).pushAll([{ key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }]);
|
||
assert.ok((await lstat(link)).isFile());
|
||
assert.deepEqual(await readdir(volume), []);
|
||
});
|
||
|
||
test("CultCache inspector refuses the same vectors by name", async () => {
|
||
const inspect = async (name: string) => inspectCultCacheBytes(name, await readFile(join(variantVectors, name)));
|
||
await assert.rejects(() => inspect("unknown-header.msgpack"), /cultcache\.store\.v9/u);
|
||
await assert.rejects(
|
||
() => inspect("extra-slot-full-payload.msgpack"),
|
||
(error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId),
|
||
);
|
||
});
|
||
|
||
test("a v1 store written at the base commit still reads byte for byte", async () => {
|
||
const envelopes = await pullVector("v1-base.msgpack");
|
||
assert.deepEqual(envelopes.map((entry) => [entry.key, entry.type]), [["alpha", "vectors.item"], ["beta", "vectors.item"]]);
|
||
assert.deepEqual([...documentPayloadBytes(envelopes[0]!.payload)], [0x92, 0xa5, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x01]);
|
||
assert.deepEqual([...documentPayloadBytes(envelopes[1]!.payload)], [0x92, 0xa4, 0x62, 0x65, 0x74, 0x61, 0x02]);
|
||
});
|
||
|
||
// The element-id marker: tests/vectors/document-variants-c2a/v3-base.msgpack is v1-base with its header replaced.
|
||
const c2aVectors = resolve(cultLibRoot, "tests", "vectors", "document-variants-c2a");
|
||
|
||
async function copyVector(directory: string, source: string, name: string): Promise<string> {
|
||
const file = join(directory, name);
|
||
await writeFile(file, await readFile(source));
|
||
return file;
|
||
}
|
||
|
||
test("SingleFileMessagePackBackingStore pushAll applies the header the file on disk carries", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-v3-pushall-"));
|
||
const header = async (file: string) => (decode(await readFile(file)) as unknown[])[0];
|
||
|
||
// A store that never read the file still keeps a marked file marked.
|
||
const v3 = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "v3.msgpack");
|
||
const envelopes = await new SingleFileMessagePackBackingStore(v3).pullAll();
|
||
await new SingleFileMessagePackBackingStore(v3).pushAll(envelopes);
|
||
assert.equal(await header(v3), "cultcache.store.v3");
|
||
|
||
// A store that read a marked file, whose file is then gone, writes unmarked: the disk decides, not the last read.
|
||
const reader = new SingleFileMessagePackBackingStore(v3);
|
||
await reader.pullAll();
|
||
await rm(v3);
|
||
await reader.pushAll(envelopes);
|
||
assert.equal(await header(v3), "cultcache.store.v1");
|
||
|
||
const v1 = await copyVector(dir, join(variantVectors, "v1-base.msgpack"), "v1.msgpack");
|
||
await new SingleFileMessagePackBackingStore(v1).pushAll(envelopes);
|
||
assert.equal(await header(v1), "cultcache.store.v1");
|
||
});
|
||
|
||
test("SingleFileMessagePackBackingStore push after a read of a marked file that is gone writes v1", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-v3-gone-"));
|
||
const v3 = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "v3.msgpack");
|
||
const store = new SingleFileMessagePackBackingStore(v3);
|
||
const envelopes = await store.pullAll();
|
||
await rm(v3);
|
||
await store.push(envelopes[0]!);
|
||
assert.equal((decode(await readFile(v3)) as unknown[])[0], "cultcache.store.v1");
|
||
});
|
||
|
||
test("SingleFileMessagePackBackingStore reads a v3 element-id store and a rewrite keeps the marker", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-v3-"));
|
||
const v3 = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "v3.msgpack");
|
||
const store = new SingleFileMessagePackBackingStore(v3);
|
||
const envelopes = await store.pullAll();
|
||
assert.deepEqual(envelopes.map((entry) => entry.key), ["alpha", "beta"]);
|
||
assert.deepEqual([...documentPayloadBytes(envelopes[0]!.payload)], [0x92, 0xa5, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x01]);
|
||
await store.push({ ...envelopes[0]!, storedAt: "2026-09-30T00:00:00.0000000Z" });
|
||
assert.equal((decode(await readFile(v3)) as unknown[])[0], "cultcache.store.v3");
|
||
|
||
// A v1 store stays v1: the marker is carried, never invented.
|
||
const v1 = await copyVector(dir, join(variantVectors, "v1-base.msgpack"), "v1.msgpack");
|
||
const plain = new SingleFileMessagePackBackingStore(v1);
|
||
const plainEnvelopes = await plain.pullAll();
|
||
await plain.push({ ...plainEnvelopes[0]!, storedAt: "2026-09-30T00:00:00.0000000Z" });
|
||
assert.equal((decode(await readFile(v1)) as unknown[])[0], "cultcache.store.v1");
|
||
|
||
const inspection = inspectCultCacheBytes("v3.msgpack", await readFile(join(c2aVectors, "v3-base.msgpack")));
|
||
assert.equal(inspection.format, "cultcache.store.v3");
|
||
});
|
||
|
||
test("SingleFileMessagePackBackingStore pushAll refuses a store whose header it cannot read and leaves the file untouched", async () => {
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-pushall-refused-"));
|
||
const envelopes = await new SingleFileMessagePackBackingStore(
|
||
await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "seed.msgpack"),
|
||
).pullAll();
|
||
for (const vector of [
|
||
join(variantVectors, "unknown-header.msgpack"),
|
||
join(variantVectors, "variant-v2.msgpack"),
|
||
resolve(cultLibRoot, "tests", "vectors", "document-variants-c1", "variant-store.msgpack"),
|
||
]) {
|
||
const file = await copyVector(dir, vector, "store.msgpack");
|
||
const before = await readFile(file);
|
||
await assert.rejects(
|
||
() => new SingleFileMessagePackBackingStore(file).pushAll(envelopes),
|
||
(error) => error instanceof StoreUnreadableError && /is not readable/u.test(error.message),
|
||
vector,
|
||
);
|
||
assert.deepEqual(await readFile(file), before, `${vector} was rewritten`);
|
||
}
|
||
});
|
||
|
||
// A file is replaced by a rewrite exactly when this runtime's own reader opens it: one verdict per file, asked by pullAll,
|
||
// pushAll, soft pushAll, push and delete alike; a soft flush leaves the files that read as they are. The bytes and every runtime's verdict: tests/vectors/document-variants-c2a/readability.
|
||
test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", async () => {
|
||
const root = join(c2aVectors, "readability");
|
||
const rows = (await readFile(join(root, "manifest.txt"), "utf8"))
|
||
.split(/\r?\n/u)
|
||
.filter((line) => line.length > 0 && !line.startsWith("#"))
|
||
.map((line) => line.split(/\s+/u));
|
||
// Every vector in the folder has a manifest row.
|
||
const listed = rows.map(([vector]) => vector!).filter((vector) => !vector.startsWith("..")).sort();
|
||
assert.deepEqual((await readdir(root)).filter((name) => name.endsWith(".bin")).sort(), listed);
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-readability-"));
|
||
const seed = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "seed.msgpack");
|
||
const envelopes = await new SingleFileMessagePackBackingStore(seed).pullAll();
|
||
for (const [vector, , , typescript] of rows) {
|
||
const reads = typescript === "reads";
|
||
const bytes = await readFile(join(root, vector));
|
||
for (const operation of ["pullAll", "softPushAll", "pushAll", "push", "delete"] as const) {
|
||
const file = join(dir, `${vector.replace(/[^a-z0-9.-]/giu, "_")}.${operation}.msgpack`);
|
||
await writeFile(file, bytes);
|
||
const store = new SingleFileMessagePackBackingStore(file);
|
||
const run = () =>
|
||
operation === "pullAll"
|
||
? store.pullAll()
|
||
: operation === "softPushAll"
|
||
? store.pushAll(envelopes, { soft: true })
|
||
: operation === "pushAll"
|
||
? store.pushAll(envelopes)
|
||
: operation === "push"
|
||
? store.push(envelopes[0]!)
|
||
: store.delete(envelopes[0]!);
|
||
if (reads) {
|
||
await run();
|
||
if (operation === "pullAll") {
|
||
continue;
|
||
}
|
||
|
||
// A soft flush leaves a store it reads byte for byte: nothing is written where something is.
|
||
if (operation === "softPushAll") {
|
||
assert.ok(bytes.equals(await readFile(file)), `${vector} ${operation} rewrote a store it reads`);
|
||
continue;
|
||
}
|
||
|
||
// The replaced file is a store: it carries the header its old content decides (a v3 store keeps its marker, all else
|
||
// is v1), and it holds what the operation wrote.
|
||
assert.equal((decode(await readFile(file)) as unknown[])[0], vector.includes("v3") ? "cultcache.store.v3" : "cultcache.store.v1", `${vector} ${operation}`);
|
||
const keys = (await new SingleFileMessagePackBackingStore(file).pullAll()).map((entry) => entry.key);
|
||
if (operation === "pushAll") {
|
||
assert.deepEqual(keys, envelopes.map((entry) => entry.key).sort(), `${vector} ${operation}`);
|
||
} else if (operation === "push") {
|
||
assert.ok(keys.includes(envelopes[0]!.key), `${vector} ${operation}`);
|
||
} else {
|
||
assert.ok(!keys.includes(envelopes[0]!.key), `${vector} ${operation}`);
|
||
}
|
||
} else {
|
||
await assert.rejects(
|
||
run,
|
||
(error) => error instanceof StoreUnreadableError && error.cause !== undefined,
|
||
`${vector} ${operation}`,
|
||
);
|
||
assert.ok(bytes.equals(await readFile(file)), `${vector} ${operation} rewrote a file it cannot read`);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Owner beats lister: a definition that owns an id and one that lists it as compatible register together in either order. A record
|
||
// under the id is the owner's, and a lister's only when nothing owns it; two listers and no owner name no single definition.
|
||
const ownsId = defineDocumentType({ type: "tests.owns", schema: z.object({ name: z.string() }), schemaId: "id.owned", schemaName: "tests.owns" });
|
||
const listsId = defineDocumentType({
|
||
type: "tests.lists",
|
||
schema: z.object({ name: z.string() }),
|
||
schemaId: "id.lister",
|
||
schemaName: "tests.lists",
|
||
compatibleSchemaIds: ["id.owned"],
|
||
});
|
||
const alsoListsId = defineDocumentType({
|
||
type: "tests.also-lists",
|
||
schema: z.object({ name: z.string() }),
|
||
schemaId: "id.also-lister",
|
||
schemaName: "tests.also-lists",
|
||
compatibleSchemaIds: ["id.owned"],
|
||
});
|
||
|
||
async function storeUnder(records: [string, string][]): Promise<string> {
|
||
const seed = CultCache.builder().withDocumentType(ownsId).build();
|
||
await seed.put(ownsId, "seed", { name: "n" });
|
||
const payload = seed.getRequiredEnvelope(ownsId, "seed").payload;
|
||
const file = join(await mkdtemp(join(tmpdir(), "cultcache-owner-lister-")), "store.msgpack");
|
||
const ids = [...new Set(records.map(([, schemaId]) => schemaId))];
|
||
await writeFile(file, encode(["cultcache.store.v1",
|
||
ids.map((schemaId) => [schemaId, schemaId, `${schemaId}.v1`, schemaId, "", [schemaId], []]),
|
||
records.map(([key, schemaId]) => [key, schemaId, "2026-09-30T00:00:00.0000000Z", payload])]));
|
||
return file;
|
||
}
|
||
|
||
async function openWith(file: string, ...definitions: AnyCultCacheDocumentDefinition[]): Promise<CultCache> {
|
||
let builder = CultCache.builder();
|
||
for (const definition of definitions) builder = builder.withDocumentType(definition);
|
||
const cache = builder.withGenericStore(new SingleFileMessagePackBackingStore(file)).build();
|
||
await cache.pullAllBackingStores();
|
||
return cache;
|
||
}
|
||
|
||
test("a definition listing an id registers beside the one owning it in either order, and the owner wins", async () => {
|
||
const file = await storeUnder([["o", "id.owned"], ["l", "id.lister"]]);
|
||
for (const order of [[ownsId, listsId], [listsId, ownsId]]) {
|
||
const cache = await openWith(file, ...order);
|
||
assert.deepEqual(cache.getRequired(ownsId, "o"), { name: "n" });
|
||
assert.equal(cache.get(listsId, "o"), undefined);
|
||
assert.deepEqual(cache.getRequired(listsId, "l"), { name: "n" });
|
||
}
|
||
const listerOnly = await openWith(await storeUnder([["o", "id.owned"]]), listsId);
|
||
assert.deepEqual(listerOnly.getRequired(listsId, "o"), { name: "n" });
|
||
});
|
||
|
||
test("two definitions listing one id register, and a record under it needs its owner", async () => {
|
||
const file = await storeUnder([["k", "id.owned"]]);
|
||
for (const listers of [[listsId, alsoListsId], [alsoListsId, listsId]]) {
|
||
await assert.rejects(openWith(file, ...listers), (error: unknown) => {
|
||
assert.ok(error instanceof SchemaConflictError);
|
||
assert.equal(error.schemaId, "id.owned");
|
||
assert.equal(error.recordKey, "k");
|
||
assert.deepEqual(error.schemaNames, ["tests.also-lists", "tests.lists"]);
|
||
return true;
|
||
});
|
||
for (const order of [[...listers, ownsId], [ownsId, ...listers]]) {
|
||
assert.deepEqual((await openWith(file, ...order)).getRequired(ownsId, "k"), { name: "n" });
|
||
}
|
||
}
|
||
});
|
||
|
||
test("every registration refusal is a SchemaConflictError naming the id and both schema names", () => {
|
||
const sameId = defineDocumentType({ type: "tests.owns-too", schema: z.object({ name: z.string() }), schemaId: "id.owned", schemaName: "tests.owns-too" });
|
||
const sameName = defineDocumentType({ type: "tests.named-too", schema: z.object({ name: z.string() }), schemaId: "id.other", schemaName: "tests.owns" });
|
||
const sameType = defineDocumentType({ type: "tests.owns", schema: z.object({ name: z.string() }), schemaId: "id.third", schemaName: "tests.third" });
|
||
for (const [claimant, schemaId, names] of [
|
||
[sameId, "id.owned", ["tests.owns", "tests.owns-too"]],
|
||
[sameName, "id.other", ["tests.owns", "tests.owns"]],
|
||
[sameType, "id.third", ["tests.owns", "tests.third"]],
|
||
] as const) {
|
||
for (const order of [[ownsId, claimant], [claimant, ownsId]]) {
|
||
assert.throws(() => CultCache.builder().withDocumentType(order[0]).withDocumentType(order[1]).build(), (error: unknown) => {
|
||
assert.ok(error instanceof SchemaConflictError, String(error));
|
||
const first = order[0] === ownsId;
|
||
assert.equal(error.schemaId, first ? schemaId : ownsId.schemaId);
|
||
assert.deepEqual(error.schemaNames, first ? names : [...names].reverse());
|
||
return true;
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Cross-runtime store lock: each runtime's writer excludes the others on `<file>.lock`.
|
||
|
||
import { spawn } from "node:child_process";
|
||
import { withStoreWriteLock } from "../src/store-file";
|
||
|
||
type LockHolder = { exited: Promise<number | null>; stderr: () => string };
|
||
|
||
/** Starts a peer's `hold-lock` mode and resolves once it prints {"held":true}. */
|
||
async function holdLockWith(command: string, args: string[], cwd: string): Promise<LockHolder> {
|
||
const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
||
let errors = "";
|
||
child.stderr.on("data", (chunk) => {
|
||
errors += String(chunk);
|
||
});
|
||
const exited = new Promise<number | null>((resolveExit) => child.on("exit", (code) => resolveExit(code)));
|
||
await new Promise<void>((resolveHeld, rejectHeld) => {
|
||
let seen = "";
|
||
child.stdout.on("data", (chunk) => {
|
||
seen += String(chunk);
|
||
if (seen.includes('"held":true')) {
|
||
resolveHeld();
|
||
}
|
||
});
|
||
void exited.then((code) => rejectHeld(new Error(`hold-lock exited ${code} before holding: ${errors}`)));
|
||
});
|
||
return { exited, stderr: () => errors };
|
||
}
|
||
|
||
const lockHolders: Record<"csharp" | "rust", (file: string, ms: number) => Promise<LockHolder>> = {
|
||
csharp: (file, ms) =>
|
||
holdLockWith(dotnetCommand, [csharpInteropDll, "hold-lock", "--file", file, "--ms", String(ms)], cultLibRoot),
|
||
rust: (file, ms) =>
|
||
holdLockWith(rustInteropBinary, ["hold-lock", "--file", file, "--ms", String(ms)], cultcacheRsRoot),
|
||
};
|
||
|
||
const lockProbeEntry = (key: string): CultCacheEnvelope => ({
|
||
key,
|
||
type: "t",
|
||
payload: documentPayloadFromBytes(Uint8Array.of(0x90)),
|
||
storedAt: "2026-10-20T00:00:00Z",
|
||
});
|
||
|
||
for (const holder of ["csharp", "rust"] as const) {
|
||
test(`a TypeScript write waits while a ${holder === "csharp" ? "C#" : "Rust"} writer holds the store lock`, async () => {
|
||
await buildInteropPeers();
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-lock-ts-waits-"));
|
||
const file = join(dir, "store.cc");
|
||
const held = await lockHolders[holder](file, 1500);
|
||
let finished = false;
|
||
const push = new SingleFileMessagePackBackingStore(file).push(lockProbeEntry("ts-record")).then(() => {
|
||
finished = true;
|
||
});
|
||
await new Promise((resolveWait) => setTimeout(resolveWait, 500));
|
||
assert.equal(finished, false, "the push must wait for the foreign lock");
|
||
assert.equal(existsSync(file), false, "nothing is written while the foreign writer holds the lock");
|
||
assert.equal(await held.exited, 0, held.stderr());
|
||
await push;
|
||
const keys = (await new SingleFileMessagePackBackingStore(file).pullAll()).map((entry) => entry.key);
|
||
assert.deepEqual(keys, ["ts-record"]);
|
||
});
|
||
}
|
||
|
||
for (const writer of ["csharp", "rust"] as const) {
|
||
test(`a ${writer === "csharp" ? "C#" : "Rust"} writer waits while a TypeScript writer holds the store lock`, async () => {
|
||
await buildInteropPeers();
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-lock-peer-waits-"));
|
||
const file = join(dir, "store.cc");
|
||
const noteKey = `note:${writer}-writer`;
|
||
let peer: Promise<unknown> | undefined;
|
||
let peerDone = false;
|
||
await withStoreWriteLock(file, async () => {
|
||
peer =
|
||
writer === "csharp"
|
||
? execFileAsync(dotnetCommand, [csharpInteropDll, "write", "--file", file, "--runtime-id", "csharp-writer"], { cwd: cultLibRoot, timeout: 60_000 })
|
||
: execFileAsync(rustInteropBinary, ["write", "--file", file, "--runtime-id", "rust-writer"], { cwd: cultcacheRsRoot, timeout: 60_000 });
|
||
void peer.then(
|
||
() => (peerDone = true),
|
||
() => (peerDone = true),
|
||
);
|
||
await new Promise((resolveWait) => setTimeout(resolveWait, 1500));
|
||
assert.equal(peerDone, false, "the peer must still be waiting for the lock");
|
||
assert.equal(existsSync(file), false, `${noteKey} is absent while TypeScript holds the lock`);
|
||
});
|
||
await peer;
|
||
const keys = (await new SingleFileMessagePackBackingStore(file).pullAll()).map((entry) => entry.key);
|
||
assert.ok(keys.includes(noteKey), `${noteKey} is in the store after release`);
|
||
});
|
||
}
|
||
|
||
test("the store lock gives up on a Rust holder after its wait bound and names the lock path", async () => {
|
||
await buildInteropPeers();
|
||
const dir = await mkdtemp(join(tmpdir(), "cultcache-lock-timeout-"));
|
||
const file = join(dir, "store.cc");
|
||
const held = await lockHolders.rust(file, 2000);
|
||
await assert.rejects(
|
||
withStoreWriteLock(file, async () => undefined, { timeoutMs: 200 }),
|
||
(error: Error) => error.message.includes(`${file}.lock`),
|
||
);
|
||
assert.equal(await held.exited, 0, held.stderr());
|
||
});
|