Huginn/test/cult-cache.test.ts
2026-05-07 22:28:38 +01:00

312 lines
9.1 KiB
TypeScript

import assert from "node:assert/strict";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { decode, encode } from "@msgpack/msgpack";
import { z } from "zod";
import { CultCache } from "../src/cult-cache";
import { defineDocumentRegistry, defineDocumentType } from "../src/document";
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
import type { CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types";
test("CultCache supports registry bootstrap, global documents, and lookup by name and index", async () => {
const itemDocument = defineDocumentType({
type: "item",
schema: z.object({
name: z.string(),
category: z.string(),
value: z.number().int(),
}),
name: "name",
indexes: {
category: "category",
},
});
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({
theme: z.string(),
retries: z.number().int().nonnegative(),
}),
global: true,
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
const registry = defineDocumentRegistry(itemDocument, settingsDocument);
const cache = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.put(itemDocument, "item:potion", {
name: "Potion",
category: "Consumable",
value: 50,
});
await cache.putGlobal(settingsDocument, {
theme: "ash",
retries: 3,
});
assert.deepEqual(cache.getByName(itemDocument, "Potion"), {
name: "Potion",
category: "Consumable",
value: 50,
});
assert.equal(cache.getKeyByIndex(itemDocument, "category", "Consumable"), "item:potion");
assert.deepEqual(cache.getGlobal(settingsDocument), {
theme: "ash",
retries: 3,
});
const snapshot = cache.snapshot();
assert.equal(snapshot.length, 2);
assert.ok(snapshot.every((entry) => entry.payload instanceof Uint8Array && entry.payload.length > 0));
const reloaded = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await reloaded.pullAllBackingStores();
assert.deepEqual(reloaded.getByName(itemDocument, "Potion"), {
name: "Potion",
category: "Consumable",
value: 50,
});
assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), {
theme: "ash",
retries: 3,
});
});
test("CultCache can register name and index lookups after entries already exist", async () => {
const noteDocument = defineDocumentType({
type: "note",
schema: z.object({
title: z.string(),
author: z.string(),
body: z.string(),
}),
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
const cache = CultCache.builder()
.withDocumentType(noteDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.put(noteDocument, "note:hello", {
title: "Hello",
author: "ari",
body: "world",
});
cache.registerNameLookup(noteDocument, "title");
cache.registerIndex(noteDocument, "author", "author");
assert.equal(cache.getIdByName(noteDocument, "Hello"), "note:hello");
assert.equal(cache.getIdByIndex(noteDocument, "author", "ari"), "note:hello");
});
test("CultCache fails closed on unknown or illegal persisted polymorphic state", async () => {
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({
theme: z.string(),
}),
global: true,
});
class InMemoryStore implements CacheBackingStore {
constructor(private readonly entries: CultCacheEnvelope[]) {}
async pullAll(): Promise<CultCacheEnvelope[]> {
return this.entries;
}
async push(): Promise<void> {
throw new Error("not used");
}
async delete(): Promise<void> {
throw new Error("not used");
}
}
const cacheWithUnknownType = CultCache.builder()
.withDocumentType(settingsDocument)
.withGenericStore(new InMemoryStore([
{
key: "mystery",
type: "forbidden",
payload: encode({ theme: "ash" }),
storedAt: new Date().toISOString(),
},
]))
.build();
await assert.rejects(
async () => cacheWithUnknownType.pullAllBackingStores(),
/No schema is registered for persisted document type "forbidden"\./,
);
const cacheWithDuplicateGlobal = CultCache.builder()
.withDocumentType(settingsDocument)
.withGenericStore(new InMemoryStore([
{
key: "one",
type: "settings",
payload: encode({ theme: "ash" }),
storedAt: new Date().toISOString(),
},
{
key: "two",
type: "settings",
payload: encode({ theme: "ember" }),
storedAt: new Date().toISOString(),
},
]))
.build();
await assert.rejects(
async () => cacheWithDuplicateGlobal.pullAllBackingStores(),
/has multiple persisted entries/,
);
});
test("CultCache accepts generated parse-style schemas without a Zod mirror", async () => {
type GeneratedSettings = {
schema_version: "generated.settings.v0";
theme: string;
retries: number;
};
const generatedSchema: CultCacheSchema<GeneratedSettings> = {
parse(input: unknown): GeneratedSettings {
if (!input || typeof input !== "object") {
throw new Error("generated settings must be an object");
}
const value = input as Record<string, unknown>;
if (value.schema_version !== "generated.settings.v0") {
throw new Error("generated settings schema_version mismatch");
}
if (typeof value.theme !== "string") {
throw new Error("generated settings theme must be a string");
}
if (typeof value.retries !== "number") {
throw new Error("generated settings retries must be a number");
}
return {
schema_version: "generated.settings.v0",
theme: value.theme,
retries: value.retries,
};
},
};
const generatedDocument = defineDocumentType({
type: "generated-settings",
schema: generatedSchema,
global: true,
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "generated.msgpack");
const cache = CultCache.builder()
.withDocumentType(generatedDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.putGlobal(generatedDocument, {
schema_version: "generated.settings.v0",
theme: "ash",
retries: 3,
});
const settings = cache.getRequiredGlobal(generatedDocument);
assert.equal(settings.schema_version, "generated.settings.v0");
assert.equal(settings.theme, "ash");
assert.equal(settings.retries, 3);
});
test("CultCache can ingest raw envelopes without re-encoding the payload", async () => {
const noteDocument = defineDocumentType({
type: "note",
schema: z.object({
title: z.string(),
body: z.string(),
}),
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
const origin = CultCache.builder()
.withDocumentType(noteDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
const target = CultCache.builder()
.withDocumentType(noteDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "target.msgpack")))
.build();
await origin.put(noteDocument, "note:hello", {
title: "Hello",
body: "world",
});
const envelope = origin.getRequiredEnvelope(noteDocument, "note:hello");
const applied = await target.putEnvelope(noteDocument, envelope);
assert.deepEqual(applied, {
title: "Hello",
body: "world",
});
assert.deepEqual(target.getRequired(noteDocument, "note:hello"), applied);
assert.deepEqual(target.getRequiredEnvelope(noteDocument, "note:hello").payload, envelope.payload);
});
test("SingleFileMessagePackBackingStore 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 Array<{ payload: unknown }>;
assert.equal(rewritten.length, 1);
assert.ok(rewritten[0]?.payload instanceof Uint8Array);
});